diff --git a/docs/docs/stdlib-persistence.md b/docs/docs/stdlib-persistence.md index 1a03061f5..9f847600a 100644 --- a/docs/docs/stdlib-persistence.md +++ b/docs/docs/stdlib-persistence.md @@ -14,6 +14,16 @@ The `persistence` module provides a simple key-value store that persists data ac - LRU cache behavior with maximum weight limits - Batch writes for performance +### Web debugger inspection + +The web binding exposes a read-only, bounded snapshot for attached developer +tools. It inspects the existing in-memory stores and the established +`valdi.PersistentStore.` browser records without hydrating, rewriting, or +removing them. Store names, entry keys, values, serialized records, store and +entry counts, browser-key scans, and the aggregate UTF-16/UTF-8 payload are all +bounded. Truncation and corruption are reported as snapshot metadata so an +inspector cannot wedge application persistence or perform unbounded work. + ## Installation Add the `persistence` module to your `BUILD.bazel` dependencies: diff --git a/src/valdi_modules/src/valdi/persistence/web/PersistentStoreNative.ts b/src/valdi_modules/src/valdi/persistence/web/PersistentStoreNative.ts index d521bb209..5b295046c 100644 --- a/src/valdi_modules/src/valdi/persistence/web/PersistentStoreNative.ts +++ b/src/valdi_modules/src/valdi/persistence/web/PersistentStoreNative.ts @@ -1,5 +1,5 @@ -import { PropertyList } from 'valdi_tsx/src/PropertyList'; -import { PersistentStoreNative } from '../src/PersistentStoreNative'; +import type { PropertyList } from 'valdi_tsx/src/PropertyList'; +import type { PersistentStoreNative } from '../src/PersistentStoreNative'; const enc = new TextEncoder(); const dec = new TextDecoder(); @@ -40,6 +40,8 @@ const LS_PREFIX = 'valdi.PersistentStore.'; interface LocalStorageLike { getItem(key: string): string | null; + key?(index: number): string | null; + readonly length?: number; setItem(key: string, value: string): void; removeItem(key: string): void; } @@ -324,3 +326,663 @@ export function __resetInMemoryForTest(): void { memoryStores.clear(); hydratedStores.clear(); } + +// --- Read-only debugger adapter ------------------------------------------- + +const MAX_DEBUG_STORES = 100; +const MAX_DEBUG_ENTRIES_PER_STORE = 100; +const MAX_DEBUG_INSPECTED_ENTRIES_PER_STORE = 200; +const MAX_DEBUG_INSPECTED_STORAGE_KEYS = 1000; +const MAX_DEBUG_NAME_CHARACTERS = 512; +const MAX_DEBUG_STORAGE_KEY_CHARACTERS = LS_PREFIX.length + MAX_DEBUG_NAME_CHARACTERS; +const MAX_DEBUG_KEY_CHARACTERS = 1024; +const MAX_DEBUG_VALUE_CHARACTERS = 32 * 1024; +const MAX_DEBUG_ERROR_CHARACTERS = 1024; +const MAX_DEBUG_SERIALIZED_STORE_CHARACTERS = 128 * 1024; +const MAX_DEBUG_TOTAL_CHARACTERS = 256 * 1024; +const MAX_DEBUG_TOTAL_BYTES = 512 * 1024; +const DEBUG_BASE_STRUCTURAL_CHARACTERS = 1024; +const DEBUG_BASE_STRUCTURAL_BYTES = 2048; +const DEBUG_STORE_STRUCTURAL_CHARACTERS = 512; +const DEBUG_STORE_STRUCTURAL_BYTES = 1024; +const DEBUG_ENTRY_STRUCTURAL_CHARACTERS = 512; +const DEBUG_ENTRY_STRUCTURAL_BYTES = 1024; + +interface BoundedString { + readonly bytes: number; + readonly characters: number; + readonly originalLength: number; + readonly truncated: boolean; + readonly value: string; +} + +export interface WebPersistentStoreDiagnostics { + readonly hydratedStores: number; + readonly memoryStores: number; + readonly storageAvailable: boolean; +} + +export interface WebPersistentStoreDebugEntry { + readonly encoding: number; + readonly expiresAt?: number; + readonly key: string; + readonly keyLength?: number; + readonly keyTruncated?: boolean; + readonly unavailableReason?: string; + readonly value: string; + readonly valueLength?: number; + readonly valueTruncated?: boolean; + readonly weight?: number; +} + +export interface WebPersistentStoreDebugStore { + readonly backend: 'browser' | 'memory'; + readonly entries: readonly WebPersistentStoreDebugEntry[]; + readonly entriesTruncated?: boolean; + readonly error?: string; + readonly errorLength?: number; + readonly errorTruncated?: boolean; + readonly inspectedEntries: number; + readonly inspectionTruncated?: boolean; + readonly name: string; + readonly nameLength?: number; + readonly nameTruncated?: boolean; + readonly serializedLength?: number; +} + +export interface WebPersistentStoreSnapshotLimits { + readonly maxEntriesPerStore: number; + readonly maxErrorCharacters: number; + readonly maxInspectedEntriesPerStore: number; + readonly maxInspectedStorageKeys: number; + readonly maxKeyCharacters: number; + readonly maxNameCharacters: number; + readonly maxSerializedStoreCharacters: number; + readonly maxStorageKeyCharacters: number; + readonly maxStores: number; + readonly maxTotalBytes: number; + readonly maxTotalCharacters: number; + readonly maxValueCharacters: number; +} + +export interface WebPersistentStoreSnapshot { + readonly diagnostics: WebPersistentStoreDiagnostics; + readonly inspectedStorageKeys: number; + readonly limits: WebPersistentStoreSnapshotLimits; + readonly rejectedStorageKeys: number; + readonly storageError?: string; + readonly storageErrorLength?: number; + readonly storageErrorTruncated?: boolean; + readonly storageInspectionTruncated?: boolean; + readonly stores: readonly WebPersistentStoreDebugStore[]; + readonly truncated: boolean; + readonly usage: { readonly bytes: number; readonly characters: number }; +} + +const DEBUG_LIMITS: WebPersistentStoreSnapshotLimits = { + maxEntriesPerStore: MAX_DEBUG_ENTRIES_PER_STORE, + maxErrorCharacters: MAX_DEBUG_ERROR_CHARACTERS, + maxInspectedEntriesPerStore: MAX_DEBUG_INSPECTED_ENTRIES_PER_STORE, + maxInspectedStorageKeys: MAX_DEBUG_INSPECTED_STORAGE_KEYS, + maxKeyCharacters: MAX_DEBUG_KEY_CHARACTERS, + maxNameCharacters: MAX_DEBUG_NAME_CHARACTERS, + maxSerializedStoreCharacters: MAX_DEBUG_SERIALIZED_STORE_CHARACTERS, + maxStorageKeyCharacters: MAX_DEBUG_STORAGE_KEY_CHARACTERS, + maxStores: MAX_DEBUG_STORES, + maxTotalBytes: MAX_DEBUG_TOTAL_BYTES, + maxTotalCharacters: MAX_DEBUG_TOTAL_CHARACTERS, + maxValueCharacters: MAX_DEBUG_VALUE_CHARACTERS, +}; + +class DebugSnapshotBudget { + bytes = 0; + characters = 0; + + reserve(characters: number, bytes: number): boolean { + if (this.characters + characters > MAX_DEBUG_TOTAL_CHARACTERS || this.bytes + bytes > MAX_DEBUG_TOTAL_BYTES) { + return false; + } + this.characters += characters; + this.bytes += bytes; + return true; + } + + take(value: string, maximumCharacters: number): BoundedString | undefined { + const remainingCharacters = MAX_DEBUG_TOTAL_CHARACTERS - this.characters; + const remainingBytes = MAX_DEBUG_TOTAL_BYTES - this.bytes; + if (remainingCharacters < 2 || remainingBytes < 2) { + return undefined; + } + const result = truncateDebugString(value, maximumCharacters, remainingCharacters, remainingBytes); + this.characters += result.characters; + this.bytes += result.bytes; + return result; + } +} + +interface DebugSnapshotBuildState { + aggregateExhausted: boolean; + readonly budget: DebugSnapshotBudget; + readonly stores: WebPersistentStoreDebugStore[]; + truncated: boolean; +} + +function utf8BytesForDebugCodePoint(codePoint: number): number { + if (codePoint <= 0x7f) { + return 1; + } + if (codePoint <= 0x7ff) { + return 2; + } + if (codePoint <= 0xffff) { + return 3; + } + return 4; +} + +function debugJsonCost(value: string, index: number, codePoint: number, characterLength: number): { + bytes: number; + characters: number; +} { + const codeUnit = value.charCodeAt(index); + if (characterLength === 1 && (codeUnit === 0x22 || codeUnit === 0x5c)) { + return { bytes: 2, characters: 2 }; + } + if (characterLength === 1 && codeUnit <= 0x1f) { + const shortEscape = + codeUnit === 0x08 || + codeUnit === 0x09 || + codeUnit === 0x0a || + codeUnit === 0x0c || + codeUnit === 0x0d; + return shortEscape ? { bytes: 2, characters: 2 } : { bytes: 6, characters: 6 }; + } + if (characterLength === 1 && codeUnit >= 0xd800 && codeUnit <= 0xdfff) { + return { bytes: 6, characters: 6 }; + } + return { bytes: utf8BytesForDebugCodePoint(codePoint), characters: characterLength }; +} + +function truncateDebugString( + value: string, + maximumCharacters: number, + maximumBudgetCharacters: number, + maximumBudgetBytes: number, +): BoundedString { + let bytes = 2; + let characters = 2; + let end = 0; + while (end < value.length && end < maximumCharacters) { + const codePoint = value.codePointAt(end) as number; + const characterLength = codePoint > 0xffff ? 2 : 1; + const cost = debugJsonCost(value, end, codePoint, characterLength); + if ( + end + characterLength > maximumCharacters || + characters + cost.characters > maximumBudgetCharacters || + bytes + cost.bytes > maximumBudgetBytes + ) { + break; + } + end += characterLength; + bytes += cost.bytes; + characters += cost.characters; + } + return { bytes, characters, originalLength: value.length, truncated: end < value.length, value: value.slice(0, end) }; +} + +function safeErrorMessage(error: unknown): string { + return typeof error === 'string' ? error : 'Storage inspection failed.'; +} + +function diagnosticsForStorage(storageAvailable: boolean): WebPersistentStoreDiagnostics { + return { + hydratedStores: hydratedStores.size, + memoryStores: memoryStores.size, + storageAvailable, + }; +} + +/** Return value-free state for an attached debugger without touching persistence data. */ +export function getPersistentStoreDiagnostics(): WebPersistentStoreDiagnostics { + return diagnosticsForStorage(getLocalStorage() !== undefined); +} + +function boundedDebugError(state: DebugSnapshotBuildState, error: unknown): BoundedString | undefined { + const bounded = state.budget.take(safeErrorMessage(error), MAX_DEBUG_ERROR_CHARACTERS); + if (bounded === undefined) { + state.aggregateExhausted = true; + state.truncated = true; + } else if (bounded.truncated) { + state.truncated = true; + } + return bounded; +} + +function memoryDebugValue(entry: Entry): { + encoding: number; + originalLength: number; + preview: string; + truncated: boolean; +} { + if (typeof entry.value === 'string') { + return { encoding: 0, originalLength: entry.value.length, preview: entry.value, truncated: false }; + } + const byteLength = entry.value.byteLength; + const originalLength = byteLength === 0 ? 0 : Math.ceil(byteLength / 3) * 4; + const maximumPreviewBytes = Math.floor(Math.floor(MAX_DEBUG_VALUE_CHARACTERS / 4) * 3 / 3) * 3; + const previewByteLength = Math.min(byteLength, maximumPreviewBytes); + const preview = bytesToBase64(new Uint8Array(entry.value, 0, previewByteLength)); + return { encoding: 1, originalLength, preview, truncated: previewByteLength < byteLength }; +} + +function appendMemoryDebugStore( + state: DebugSnapshotBuildState, + storeName: string, + store: Map, +): boolean { + if (!state.budget.reserve(DEBUG_STORE_STRUCTURAL_CHARACTERS, DEBUG_STORE_STRUCTURAL_BYTES)) { + state.aggregateExhausted = true; + state.truncated = true; + return false; + } + const name = state.budget.take(storeName, MAX_DEBUG_NAME_CHARACTERS); + if (name === undefined) { + state.aggregateExhausted = true; + state.truncated = true; + return false; + } + + const entries: WebPersistentStoreDebugEntry[] = []; + let entriesTruncated = false; + let inspectedEntries = 0; + let inspectionTruncated = false; + const entryIterator = store.entries(); + while (true) { + const nextEntry = entryIterator.next(); + if (nextEntry.done) { + break; + } + const [key, entry] = nextEntry.value; + if (entries.length >= MAX_DEBUG_ENTRIES_PER_STORE || inspectedEntries >= MAX_DEBUG_INSPECTED_ENTRIES_PER_STORE) { + entriesTruncated = entries.length >= MAX_DEBUG_ENTRIES_PER_STORE; + inspectionTruncated = true; + state.truncated = true; + break; + } + inspectedEntries++; + if (isExpired(entry)) { + continue; + } + if (!state.budget.reserve(DEBUG_ENTRY_STRUCTURAL_CHARACTERS, DEBUG_ENTRY_STRUCTURAL_BYTES)) { + state.aggregateExhausted = true; + state.truncated = true; + entriesTruncated = true; + inspectionTruncated = true; + break; + } + const boundedKey = state.budget.take(key, MAX_DEBUG_KEY_CHARACTERS); + if (boundedKey === undefined) { + state.aggregateExhausted = true; + state.truncated = true; + entriesTruncated = true; + inspectionTruncated = true; + break; + } + + let debugValue: ReturnType; + try { + debugValue = memoryDebugValue(entry); + } catch { + entries.push({ + encoding: typeof entry.value === 'string' ? 0 : 1, + key: boundedKey.value, + ...(boundedKey.truncated ? { keyLength: boundedKey.originalLength, keyTruncated: true } : {}), + unavailableReason: 'unavailable-memory-value', + value: '', + }); + state.truncated = true; + continue; + } + const boundedValue = state.budget.take(debugValue.preview, MAX_DEBUG_VALUE_CHARACTERS); + if (boundedValue === undefined) { + state.aggregateExhausted = true; + state.truncated = true; + entriesTruncated = true; + inspectionTruncated = true; + break; + } + const valueTruncated = debugValue.truncated || boundedValue.truncated; + const metadataInvalid = + (entry.expiresAt !== undefined && !Number.isFinite(entry.expiresAt)) || + (entry.weight !== undefined && !Number.isFinite(entry.weight)); + entries.push({ + encoding: debugValue.encoding, + ...(entry.expiresAt !== undefined && Number.isFinite(entry.expiresAt) ? { expiresAt: entry.expiresAt } : {}), + key: boundedKey.value, + ...(boundedKey.truncated ? { keyLength: boundedKey.originalLength, keyTruncated: true } : {}), + ...(metadataInvalid ? { unavailableReason: 'invalid-entry-metadata' } : {}), + value: boundedValue.value, + ...(valueTruncated ? { valueLength: debugValue.originalLength, valueTruncated: true } : {}), + ...(entry.weight !== undefined && Number.isFinite(entry.weight) ? { weight: entry.weight } : {}), + }); + state.truncated = state.truncated || boundedKey.truncated || valueTruncated || metadataInvalid; + } + + state.stores.push({ + backend: 'memory', + entries, + ...(entriesTruncated ? { entriesTruncated: true } : {}), + inspectedEntries, + ...(inspectionTruncated ? { inspectionTruncated: true } : {}), + name: name.value, + ...(name.truncated ? { nameLength: name.originalLength, nameTruncated: true } : {}), + }); + state.truncated = state.truncated || name.truncated; + return true; +} + +function appendPersistedDebugStore( + state: DebugSnapshotBuildState, + storage: LocalStorageLike, + storageName: string, +): boolean { + if (!state.budget.reserve(DEBUG_STORE_STRUCTURAL_CHARACTERS, DEBUG_STORE_STRUCTURAL_BYTES)) { + state.aggregateExhausted = true; + state.truncated = true; + return false; + } + const name = state.budget.take(storageName, MAX_DEBUG_NAME_CHARACTERS); + if (name === undefined) { + state.aggregateExhausted = true; + state.truncated = true; + return false; + } + + let raw: string | null; + try { + raw = storage.getItem(LS_PREFIX + storageName); + } catch (error) { + const boundedError = boundedDebugError(state, error); + state.stores.push({ + backend: 'browser', + entries: [], + ...(boundedError === undefined ? {} : { error: boundedError.value }), + ...(boundedError?.truncated ? { errorLength: boundedError.originalLength, errorTruncated: true } : {}), + inspectedEntries: 0, + name: name.value, + ...(name.truncated ? { nameLength: name.originalLength, nameTruncated: true } : {}), + }); + state.truncated = true; + return true; + } + if (raw === null) { + return true; + } + if (typeof raw !== 'string') { + state.stores.push({ + backend: 'browser', + entries: [], + error: 'unsupported-serialized-store', + inspectedEntries: 0, + name: name.value, + ...(name.truncated ? { nameLength: name.originalLength, nameTruncated: true } : {}), + }); + state.truncated = true; + return true; + } + if (raw.length > MAX_DEBUG_SERIALIZED_STORE_CHARACTERS) { + state.stores.push({ + backend: 'browser', + entries: [], + error: 'serialized-store-too-large', + inspectedEntries: 0, + name: name.value, + ...(name.truncated ? { nameLength: name.originalLength, nameTruncated: true } : {}), + serializedLength: raw.length, + }); + state.truncated = true; + return true; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + const boundedError = boundedDebugError(state, error); + state.stores.push({ + backend: 'browser', + entries: [], + ...(boundedError === undefined ? {} : { error: boundedError.value }), + ...(boundedError?.truncated ? { errorLength: boundedError.originalLength, errorTruncated: true } : {}), + inspectedEntries: 0, + name: name.value, + ...(name.truncated ? { nameLength: name.originalLength, nameTruncated: true } : {}), + serializedLength: raw.length, + }); + state.truncated = true; + return true; + } + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + state.stores.push({ + backend: 'browser', + entries: [], + error: 'unsupported-store-metadata', + inspectedEntries: 0, + name: name.value, + ...(name.truncated ? { nameLength: name.originalLength, nameTruncated: true } : {}), + serializedLength: raw.length, + }); + state.truncated = true; + return true; + } + + const entries: WebPersistentStoreDebugEntry[] = []; + let entriesTruncated = false; + let inspectedEntries = 0; + let inspectionTruncated = false; + for (const key in parsed) { + if (!Object.prototype.hasOwnProperty.call(parsed, key)) { + continue; + } + if (entries.length >= MAX_DEBUG_ENTRIES_PER_STORE || inspectedEntries >= MAX_DEBUG_INSPECTED_ENTRIES_PER_STORE) { + entriesTruncated = entries.length >= MAX_DEBUG_ENTRIES_PER_STORE; + inspectionTruncated = true; + state.truncated = true; + break; + } + inspectedEntries++; + if (!state.budget.reserve(DEBUG_ENTRY_STRUCTURAL_CHARACTERS, DEBUG_ENTRY_STRUCTURAL_BYTES)) { + state.aggregateExhausted = true; + state.truncated = true; + entriesTruncated = true; + inspectionTruncated = true; + break; + } + const boundedKey = state.budget.take(key, MAX_DEBUG_KEY_CHARACTERS); + if (boundedKey === undefined) { + state.aggregateExhausted = true; + state.truncated = true; + entriesTruncated = true; + inspectionTruncated = true; + break; + } + + const persisted = (parsed as Record)[key]; + if (typeof persisted !== 'object' || persisted === null || Array.isArray(persisted)) { + entries.push({ + encoding: 0, + key: boundedKey.value, + ...(boundedKey.truncated ? { keyLength: boundedKey.originalLength, keyTruncated: true } : {}), + unavailableReason: 'unsupported-entry-metadata', + value: '', + }); + state.truncated = true; + continue; + } + const pe = persisted as PersistedEntry; + const encoding = typeof pe.b === 'string' ? 1 : 0; + const persistedValue = typeof pe.b === 'string' ? pe.b : typeof pe.s === 'string' ? pe.s : undefined; + if (persistedValue === undefined) { + entries.push({ + encoding, + key: boundedKey.value, + ...(boundedKey.truncated ? { keyLength: boundedKey.originalLength, keyTruncated: true } : {}), + unavailableReason: 'unsupported-entry-metadata', + value: '', + }); + state.truncated = true; + continue; + } + if (typeof pe.e === 'number' && Number.isFinite(pe.e) && nowSec() >= pe.e) { + continue; + } + const boundedValue = state.budget.take(persistedValue, MAX_DEBUG_VALUE_CHARACTERS); + if (boundedValue === undefined) { + state.aggregateExhausted = true; + state.truncated = true; + entriesTruncated = true; + inspectionTruncated = true; + break; + } + const metadataInvalid = + (pe.e !== undefined && (typeof pe.e !== 'number' || !Number.isFinite(pe.e))) || + (pe.w !== undefined && (typeof pe.w !== 'number' || !Number.isFinite(pe.w))); + entries.push({ + encoding, + ...(typeof pe.e === 'number' && Number.isFinite(pe.e) ? { expiresAt: pe.e } : {}), + key: boundedKey.value, + ...(boundedKey.truncated ? { keyLength: boundedKey.originalLength, keyTruncated: true } : {}), + ...(metadataInvalid ? { unavailableReason: 'invalid-entry-metadata' } : {}), + value: boundedValue.value, + ...(boundedValue.truncated ? { valueLength: boundedValue.originalLength, valueTruncated: true } : {}), + ...(typeof pe.w === 'number' && Number.isFinite(pe.w) ? { weight: pe.w } : {}), + }); + state.truncated = state.truncated || boundedKey.truncated || boundedValue.truncated || metadataInvalid; + } + + state.stores.push({ + backend: 'browser', + entries, + ...(entriesTruncated ? { entriesTruncated: true } : {}), + inspectedEntries, + ...(inspectionTruncated ? { inspectionTruncated: true } : {}), + name: name.value, + ...(name.truncated ? { nameLength: name.originalLength, nameTruncated: true } : {}), + serializedLength: raw.length, + }); + state.truncated = state.truncated || name.truncated; + return true; +} + +/** Return a bounded, read-only view over current and persisted legacy web stores. */ +export function getPersistentStoreSnapshot(): WebPersistentStoreSnapshot { + const storage = getLocalStorage(); + const budget = new DebugSnapshotBudget(); + budget.reserve(DEBUG_BASE_STRUCTURAL_CHARACTERS, DEBUG_BASE_STRUCTURAL_BYTES); + const state: DebugSnapshotBuildState = { + aggregateExhausted: false, + budget, + stores: [], + truncated: false, + }; + const seenStoreNames = new Set(); + + const storeIterator = memoryStores.entries(); + while (true) { + const nextStore = storeIterator.next(); + if (nextStore.done) { + break; + } + const [storeName, store] = nextStore.value; + if (state.stores.length >= MAX_DEBUG_STORES) { + state.truncated = true; + break; + } + if (!appendMemoryDebugStore(state, storeName, store)) { + break; + } + seenStoreNames.add(storeName); + if (state.aggregateExhausted) { + break; + } + } + + if (state.stores.length >= MAX_DEBUG_STORES && storage !== undefined) { + state.truncated = true; + } + + let inspectedStorageKeys = 0; + let rejectedStorageKeys = 0; + let storageInspectionTruncated = false; + let storageError: BoundedString | undefined; + if (storage !== undefined && !state.aggregateExhausted && state.stores.length < MAX_DEBUG_STORES) { + let storageLength = 0; + try { + const length = storage.length; + storageLength = typeof length === 'number' && Number.isFinite(length) ? Math.max(0, Math.floor(length)) : 0; + } catch (error) { + storageError = boundedDebugError(state, error); + state.truncated = true; + storageInspectionTruncated = true; + } + + for (let index = 0; index < storageLength; index++) { + if (state.stores.length >= MAX_DEBUG_STORES || state.aggregateExhausted) { + state.truncated = true; + storageInspectionTruncated = true; + break; + } + if (inspectedStorageKeys >= MAX_DEBUG_INSPECTED_STORAGE_KEYS) { + state.truncated = true; + storageInspectionTruncated = true; + break; + } + inspectedStorageKeys++; + let fullStorageKey: string | null; + try { + fullStorageKey = storage.key?.(index) ?? null; + } catch (error) { + storageError = boundedDebugError(state, error); + state.truncated = true; + storageInspectionTruncated = true; + break; + } + if (typeof fullStorageKey !== 'string') { + continue; + } + if (fullStorageKey.length > MAX_DEBUG_STORAGE_KEY_CHARACTERS) { + rejectedStorageKeys++; + state.truncated = true; + continue; + } + if (!fullStorageKey.startsWith(LS_PREFIX)) { + continue; + } + const storeName = fullStorageKey.slice(LS_PREFIX.length); + if (seenStoreNames.has(storeName)) { + continue; + } + if (!appendPersistedDebugStore(state, storage, storeName)) { + storageInspectionTruncated = true; + break; + } + seenStoreNames.add(storeName); + } + } + + return { + diagnostics: diagnosticsForStorage(storage !== undefined), + inspectedStorageKeys, + limits: { ...DEBUG_LIMITS }, + rejectedStorageKeys, + ...(storageError === undefined ? {} : { storageError: storageError.value }), + ...(storageError?.truncated + ? { storageErrorLength: storageError.originalLength, storageErrorTruncated: true } + : {}), + ...(storageInspectionTruncated ? { storageInspectionTruncated: true } : {}), + stores: state.stores, + truncated: state.truncated, + usage: { bytes: budget.bytes, characters: budget.characters }, + }; +} diff --git a/src/valdi_modules/src/valdi/persistence/web/test/PersistentStoreNativeWebTest.ts b/src/valdi_modules/src/valdi/persistence/web/test/PersistentStoreNativeWebTest.ts index b59f52787..e895f14d2 100644 --- a/src/valdi_modules/src/valdi/persistence/web/test/PersistentStoreNativeWebTest.ts +++ b/src/valdi_modules/src/valdi/persistence/web/test/PersistentStoreNativeWebTest.ts @@ -1,33 +1,61 @@ -// Web-only test for github.com/Snapchat/Valdi#119: the web PersistentStore was -// in-memory only, so data vanished on page reload. This runs the real web impl -// under node with a fake localStorage and asserts durability across a simulated -// reload (all in-memory state dropped, localStorage kept). -// -// It runs via js_test (see BUILD.bazel), not the standalone TestsRunner, because -// the standalone runner resolves 'PersistentStoreNative' to the native binding, -// not this web implementation. +// Web-only coverage for github.com/Snapchat/Valdi#119 and the read-only +// debugger adapter. This runs the real web implementation under Node because +// the standalone runner resolves PersistentStoreNative to the native binding. -import { newPersistentStore, __resetInMemoryForTest } from '../PersistentStoreNative'; +import { + __resetInMemoryForTest, + getPersistentStoreDiagnostics, + getPersistentStoreSnapshot, + newPersistentStore, +} from '../PersistentStoreNative'; declare const process: { exit(code: number): void }; -function installFakeLocalStorage(): Map { - const m = new Map(); - (globalThis as any).localStorage = { - getItem: (k: string) => (m.has(k) ? m.get(k)! : null), - setItem: (k: string, v: string) => { - m.set(k, String(v)); - }, - removeItem: (k: string) => { - m.delete(k); - }, - clear: () => m.clear(), - key: (i: number) => Array.from(m.keys())[i] ?? null, - get length() { - return m.size; - }, - }; - return m; +class FakeStorage { + private readonly entries = new Map(); + private readonly orderedKeys: string[] = []; + keyCalls = 0; + + get length(): number { + return this.orderedKeys.length; + } + + clear(): void { + this.entries.clear(); + this.orderedKeys.splice(0); + this.keyCalls = 0; + } + + getItem(key: string): string | null { + return this.entries.get(key) ?? null; + } + + key(index: number): string | null { + this.keyCalls++; + return this.orderedKeys[index] ?? null; + } + + removeItem(key: string): void { + if (!this.entries.delete(key)) { + return; + } + const index = this.orderedKeys.indexOf(key); + if (index !== -1) { + this.orderedKeys.splice(index, 1); + } + } + + setItem(key: string, value: string): void { + if (!this.entries.has(key)) { + this.orderedKeys.push(key); + } + this.entries.set(key, String(value)); + } +} + +function installFakeLocalStorage(storage = new FakeStorage()): FakeStorage { + (globalThis as any).localStorage = storage; + return storage; } type NativeStore = ReturnType; @@ -35,24 +63,81 @@ type NativeStore = ReturnType; const makeStore = (name: string): NativeStore => newPersistentStore(name, true, false, 0, undefined, undefined, false); -const store = (s: NativeStore, k: string, v: ArrayBuffer | string): Promise => - new Promise((res, rej) => s.store(k, v, undefined, undefined, e => (e ? rej(new Error(e)) : res()))); -const fetchStr = (s: NativeStore, k: string): Promise => - new Promise((res, rej) => s.fetch(k, (v, e) => (e ? rej(new Error(e)) : res(v as string)), true)); -const fetchBuf = (s: NativeStore, k: string): Promise => - new Promise((res, rej) => s.fetch(k, (v, e) => (e ? rej(new Error(e)) : res(v as ArrayBuffer)), false)); -const removeAll = (s: NativeStore): Promise => - new Promise((res, rej) => s.removeAll(e => (e ? rej(new Error(e)) : res()))); - -function assert(cond: boolean, message: string): void { - if (!cond) { +// PersistentStore.ts reaches this binding through Valdi's +// require('PersistentStoreNative') module linker, which raw Node ESM does not +// provide. Keep the unchanged public constructor mapping explicit here: +// deviceGlobal defaults false, so userScoped is true; every other option keeps +// its existing default. +const PUBLIC_DEFAULT_NATIVE_MAPPING = { + disableBatchWrites: false, + enableEncryption: undefined, + maxWeight: 0, + mockedTime: undefined, + mockedUserId: undefined, + userScoped: true, +} as const; + +const makeDefaultStyleStore = (name: string): NativeStore => + newPersistentStore( + name, + PUBLIC_DEFAULT_NATIVE_MAPPING.disableBatchWrites, + PUBLIC_DEFAULT_NATIVE_MAPPING.userScoped, + PUBLIC_DEFAULT_NATIVE_MAPPING.maxWeight, + PUBLIC_DEFAULT_NATIVE_MAPPING.mockedTime, + PUBLIC_DEFAULT_NATIVE_MAPPING.mockedUserId, + PUBLIC_DEFAULT_NATIVE_MAPPING.enableEncryption, + ); + +const makeStoreAtTime = (name: string, time: number): NativeStore => + newPersistentStore(name, true, false, 0, time, undefined, false); + +const store = ( + persistentStore: NativeStore, + key: string, + value: ArrayBuffer | string, + ttl?: number, + weight?: number, +): Promise => + new Promise((resolve, reject) => + persistentStore.store(key, value, ttl, weight, error => (error ? reject(new Error(error)) : resolve())), + ); + +const fetchStr = (persistentStore: NativeStore, key: string): Promise => + new Promise((resolve, reject) => + persistentStore.fetch(key, (value, error) => (error ? reject(new Error(error)) : resolve(value as string)), true), + ); + +const fetchBuf = (persistentStore: NativeStore, key: string): Promise => + new Promise((resolve, reject) => + persistentStore.fetch( + key, + (value, error) => (error ? reject(new Error(error)) : resolve(value as ArrayBuffer)), + false, + ), + ); + +const removeAll = (persistentStore: NativeStore): Promise => + new Promise((resolve, reject) => + persistentStore.removeAll(error => (error ? reject(new Error(error)) : resolve())), + ); + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { throw new Error(`ASSERT FAILED: ${message}`); } } -async function rejects(p: Promise): Promise { +function assertEqual(actual: unknown, expected: unknown, message: string): void { + if (actual !== expected) { + throw new Error( + `ASSERT FAILED: ${message}; expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`, + ); + } +} + +async function rejects(promise: Promise): Promise { try { - await p; + await promise; return false; } catch { return true; @@ -60,88 +145,436 @@ async function rejects(p: Promise): Promise { } // Resolves to how a promise settled within `ms`: 'resolved', 'rejected', or -// 'hang' if it never settled. Distinguishes a graceful rejection (good) from a -// dropped completion (bad). -function settleWithin(p: Promise, ms: number): Promise<'resolved' | 'rejected' | 'hang'> { +// 'hang' if it never settled. Distinguishes a graceful rejection from a dropped +// completion callback. +function settleWithin(promise: Promise, ms: number): Promise<'resolved' | 'rejected' | 'hang'> { return new Promise(resolve => { - const t = setTimeout(() => resolve('hang'), ms); - p.then( + const timeout = setTimeout(() => resolve('hang'), ms); + promise.then( () => { - clearTimeout(t); + clearTimeout(timeout); resolve('resolved'); }, () => { - clearTimeout(t); + clearTimeout(timeout); resolve('rejected'); }, ); }); } -async function main(): Promise { - // 1. A string write is durable and restored after a reload. - const backing = installFakeLocalStorage(); - const a1 = makeStore('storeA'); - await store(a1, 'greeting', 'hello world'); - assert(backing.size > 0, 'write landed in localStorage, not just memory'); +async function verifyFoundationCompatibility(backing: FakeStorage): Promise { + assertEqual( + PUBLIC_DEFAULT_NATIVE_MAPPING.userScoped, + true, + 'deviceGlobal undefined must retain the public userScoped=true native mapping', + ); + const defaultStyle = makeDefaultStyleStore('defaultStyle'); + await store(defaultStyle, 'key', 'value'); + assertEqual( + await fetchStr(defaultStyle, 'key'), + 'value', + 'the default public constructor argument shape must remain usable without a web identity', + ); + assert( + backing.getItem('valdi.PersistentStore.defaultStyle') !== null, + 'the default constructor shape must retain the established browser key format', + ); + + const first = makeStore('storeA'); + await store(first, 'greeting', 'hello world'); + assertEqual( + backing.getItem('valdi.PersistentStore.storeA'), + '{"greeting":{"s":"hello world"}}', + 'writes must preserve the established whole-store JSON format', + ); + + __resetInMemoryForTest(); + const second = makeStore('storeA'); + assertEqual(await fetchStr(second, 'greeting'), 'hello world', 'strings must restore after reload'); - __resetInMemoryForTest(); // simulate page reload - const a2 = makeStore('storeA'); - assert((await fetchStr(a2, 'greeting')) === 'hello world', 'string restored after reload'); + const ttlAtWrite = makeStoreAtTime('ttlStore', 100); + await store(ttlAtWrite, 'temporary', 'available', 2); + const ttlPersisted = JSON.parse(backing.getItem('valdi.PersistentStore.ttlStore') ?? '{}'); + assertEqual(ttlPersisted.temporary?.e, 102, 'TTL writes must retain the established absolute e field'); + __resetInMemoryForTest(); + assertEqual( + await fetchStr(makeStoreAtTime('ttlStore', 101), 'temporary'), + 'available', + 'a persisted TTL entry must hydrate before its expiry second', + ); + __resetInMemoryForTest(); + assert( + await rejects(fetchStr(makeStoreAtTime('ttlStore', 102), 'temporary')), + 'a persisted TTL entry must be absent at its expiry second', + ); - // 2. Binary values round-trip across a reload. - const buf = new ArrayBuffer(8); - const view = new Uint32Array(buf); + const buffer = new ArrayBuffer(8); + const view = new Uint32Array(buffer); view[0] = 42; view[1] = 84; - await store(a2, 'blob', buf); + await store(second, 'blob', buffer); __resetInMemoryForTest(); - const a3 = makeStore('storeA'); - const restored = new Uint32Array(await fetchBuf(a3, 'blob')); - assert(restored[0] === 42 && restored[1] === 84, 'binary round-trips across reload'); + const third = makeStore('storeA'); + const restored = new Uint32Array(await fetchBuf(third, 'blob')); + assert(restored[0] === 42 && restored[1] === 84, 'binary values must round-trip across reload'); - // 3. Stores are isolated by name. - const b = makeStore('storeB'); - assert(await rejects(fetchStr(b, 'greeting')), 'stores are isolated by name'); + const isolated = makeStore('storeB'); + assert(await rejects(fetchStr(isolated, 'greeting')), 'stores must remain isolated by name'); - // 4. removeAll clears the persisted copy too. - await removeAll(a3); + await removeAll(third); + assertEqual( + backing.getItem('valdi.PersistentStore.storeA'), + null, + 'removeAll must delete the established whole-store record', + ); __resetInMemoryForTest(); - const a4 = makeStore('storeA'); - assert(await rejects(fetchStr(a4, 'greeting')), 'removeAll cleared the persisted copy'); + assert(await rejects(fetchStr(makeStore('storeA'), 'greeting')), 'removeAll must remain cleared after reload'); - // 5. The reserved key "__proto__" persists across a reload (a plain {} would - // drop it via prototype assignment + JSON.stringify). - const p1 = makeStore('storeProto'); - await store(p1, '__proto__', 'safe'); + const protoStore = makeStore('storeProto'); + await store(protoStore, '__proto__', 'safe'); __resetInMemoryForTest(); - const p2 = makeStore('storeProto'); - assert((await fetchStr(p2, '__proto__')) === 'safe', '"__proto__" key survives a reload'); + assertEqual( + await fetchStr(makeStore('storeProto'), '__proto__'), + 'safe', + 'the reserved __proto__ key must survive reload', + ); - // 6. Corrupt / externally-tampered localStorage is discarded gracefully - - // fetch must still settle (reject not-found), never hang the caller. for (const bad of ['null', 'not json', '{"k":{"b":123}}', '{"k":null}']) { - (globalThis as any).localStorage.setItem('valdi.PersistentStore.storeCorrupt', bad); + backing.setItem('valdi.PersistentStore.storeCorrupt', bad); __resetInMemoryForTest(); - const corrupt = makeStore('storeCorrupt'); - // Either outcome is fine (not-found reject, or a coerced empty value); the - // point is the completion fires and the caller never hangs. - const outcome = await settleWithin(fetchStr(corrupt, 'k'), 2000); - assert(outcome !== 'hang', `corrupt blob ${JSON.stringify(bad)} must not hang the caller, got ${outcome}`); + const outcome = await settleWithin(fetchStr(makeStore('storeCorrupt'), 'k'), 2000); + assert(outcome !== 'hang', `corrupt blob ${JSON.stringify(bad)} must not hang callers`); } - // 7. Falls back to memory (no throw) when localStorage is unavailable. delete (globalThis as any).localStorage; - const c = makeStore('storeC'); - await store(c, 'k', 'v'); - assert((await fetchStr(c, 'k')) === 'v', 'memory fallback works without localStorage'); + const fallback = makeStore('storeC'); + await store(fallback, 'k', 'v'); + assertEqual(await fetchStr(fallback, 'k'), 'v', 'memory fallback must work without localStorage'); + installFakeLocalStorage(backing); +} + +async function verifyReadOnlySnapshot(backing: FakeStorage): Promise { + backing.clear(); + __resetInMemoryForTest(); + + backing.setItem( + 'valdi.PersistentStore.persistedOnly', + JSON.stringify({ alpha: { s: 'one' }, binary: { b: 'AAf/gP8=', w: 3 } }), + ); + backing.setItem('valdi.PersistentStore.corruptSnapshot', 'not json'); + const persistedBefore = backing.getItem('valdi.PersistentStore.persistedOnly'); + const corruptBefore = backing.getItem('valdi.PersistentStore.corruptSnapshot'); + const diagnosticsBefore = getPersistentStoreDiagnostics(); + const snapshot = getPersistentStoreSnapshot(); + const diagnosticsAfter = getPersistentStoreDiagnostics(); + + assertEqual(diagnosticsBefore.memoryStores, 0, 'setup must start without hydrated stores'); + assertEqual(diagnosticsAfter.memoryStores, 0, 'snapshotting must not hydrate persisted stores'); + assertEqual( + backing.getItem('valdi.PersistentStore.persistedOnly'), + persistedBefore, + 'snapshotting must not rewrite valid persisted data', + ); + assertEqual( + backing.getItem('valdi.PersistentStore.corruptSnapshot'), + corruptBefore, + 'snapshotting must not remove corrupt persisted data', + ); + const persisted = snapshot.stores.find(storeSnapshot => storeSnapshot.name === 'persistedOnly'); + assertEqual(persisted?.backend, 'browser', 'unhydrated legacy stores must be inspectable from browser storage'); + assertEqual(persisted?.entries.length, 2, 'valid persisted entries must be inspectable'); + assertEqual( + persisted?.entries.find(entry => entry.key === 'binary')?.value, + 'AAf/gP8=', + 'binary values must remain in their established persisted representation', + ); + assert( + snapshot.stores.find(storeSnapshot => storeSnapshot.name === 'corruptSnapshot')?.error !== undefined, + 'corrupt persisted stores must be represented as bounded error metadata', + ); + assert(JSON.stringify(snapshot).includes('persistedOnly'), 'snapshot output must be safely serializable'); + + const current = makeStore('current'); + await store(current, 'live', 'memory is authoritative'); + const currentSnapshot = getPersistentStoreSnapshot(); + assertEqual( + currentSnapshot.stores.filter(storeSnapshot => storeSnapshot.name === 'current').length, + 1, + 'current stores must be deduplicated from their persisted copy', + ); + assertEqual( + currentSnapshot.stores.find(storeSnapshot => storeSnapshot.name === 'current')?.backend, + 'memory', + 'the current in-memory value must be the inspected source', + ); +} + +async function verifySnapshotBounds(): Promise { + const boundedStorage = installFakeLocalStorage(new FakeStorage()); + __resetInMemoryForTest(); + + const expiredEntries: Record = Object.create(null); + for (let index = 0; index < 210; index++) { + expiredEntries[`expired-${index}`] = { s: 'ignored', e: 0 }; + } + boundedStorage.setItem('valdi.PersistentStore.inspectedEntries', JSON.stringify(expiredEntries)); + const inspectedSnapshot = getPersistentStoreSnapshot(); + const inspectedStore = inspectedSnapshot.stores.find(storeSnapshot => storeSnapshot.name === 'inspectedEntries'); + assertEqual( + inspectedStore?.inspectedEntries, + inspectedSnapshot.limits.maxInspectedEntriesPerStore, + 'per-store iteration must stop at the inspected-entry ceiling even when no entries are returned', + ); + assert(inspectedStore?.inspectionTruncated, 'inspected-entry truncation must be explicit'); + assertEqual(inspectedStore?.entries.length, 0, 'expired entries must remain absent from the debug result'); + + boundedStorage.clear(); + __resetInMemoryForTest(); + const validEntries: Record = Object.create(null); + for (let index = 0; index < 105; index++) { + validEntries[`entry-${index}`] = { s: 'value' }; + } + boundedStorage.setItem('valdi.PersistentStore.entryCap', JSON.stringify(validEntries)); + const entryCapSnapshot = getPersistentStoreSnapshot(); + const entryCapStore = entryCapSnapshot.stores[0]; + assertEqual( + entryCapStore?.entries.length, + entryCapSnapshot.limits.maxEntriesPerStore, + 'returned entries must stop at the explicit per-store ceiling', + ); + assert(entryCapStore?.entriesTruncated, 'returned-entry truncation must be explicit'); + + boundedStorage.clear(); + __resetInMemoryForTest(); + for (let index = 0; index < 105; index++) { + boundedStorage.setItem(`valdi.PersistentStore.store-${index}`, '{}'); + } + const storeCapSnapshot = getPersistentStoreSnapshot(); + assertEqual( + storeCapSnapshot.stores.length, + storeCapSnapshot.limits.maxStores, + 'returned stores must stop at the explicit store ceiling', + ); + assert(storeCapSnapshot.truncated, 'store-count truncation must be explicit'); + + boundedStorage.clear(); + __resetInMemoryForTest(); + for (let index = 0; index < 1050; index++) { + boundedStorage.setItem(`unrelated-${index}`, '{}'); + } + boundedStorage.setItem('valdi.PersistentStore.afterFlood', '{"key":{"s":"value"}}'); + const keyCallsBefore = boundedStorage.keyCalls; + const storageFloodSnapshot = getPersistentStoreSnapshot(); + assertEqual( + storageFloodSnapshot.inspectedStorageKeys, + storageFloodSnapshot.limits.maxInspectedStorageKeys, + 'browser-key enumeration must stop at its explicit ceiling', + ); + assertEqual( + boundedStorage.keyCalls - keyCallsBefore, + storageFloodSnapshot.limits.maxInspectedStorageKeys, + 'the reported browser-key ceiling must be operational', + ); + assert(storageFloodSnapshot.storageInspectionTruncated, 'browser-key scan truncation must be explicit'); + assert( + storageFloodSnapshot.stores.every(storeSnapshot => storeSnapshot.name !== 'afterFlood'), + 'browser keys beyond the inspection ceiling must not be visited', + ); + + boundedStorage.clear(); + __resetInMemoryForTest(); + const limits = getPersistentStoreSnapshot().limits; + const oversizedName = 'n'.repeat(limits.maxNameCharacters + 20); + const oversizedKey = 'k'.repeat(limits.maxKeyCharacters + 20); + const oversizedValue = 'v'.repeat(limits.maxValueCharacters + 20); + await store(makeStore(oversizedName), oversizedKey, oversizedValue); + const oversizedSnapshot = getPersistentStoreSnapshot(); + const oversizedStore = oversizedSnapshot.stores[0]; + const oversizedEntry = oversizedStore?.entries[0]; + assertEqual(oversizedStore?.name.length, limits.maxNameCharacters, 'store names must be bounded'); + assert(oversizedStore?.nameTruncated, 'store-name truncation must be explicit'); + assertEqual(oversizedEntry?.key.length, limits.maxKeyCharacters, 'entry keys must be bounded'); + assert(oversizedEntry?.keyTruncated, 'entry-key truncation must be explicit'); + assertEqual(oversizedEntry?.value.length, limits.maxValueCharacters, 'entry values must be bounded'); + assert(oversizedEntry?.valueTruncated, 'entry-value truncation must be explicit'); + + boundedStorage.clear(); + __resetInMemoryForTest(); + boundedStorage.setItem( + 'valdi.PersistentStore.oversizedSerialized', + 'x'.repeat(limits.maxSerializedStoreCharacters + 1), + ); + const serializedSnapshot = getPersistentStoreSnapshot(); + assertEqual( + serializedSnapshot.stores[0]?.error, + 'serialized-store-too-large', + 'oversized persisted stores must not be parsed', + ); + + boundedStorage.clear(); + __resetInMemoryForTest(); + const aggregateValue = '\nšŸ˜€"\\'.repeat(3000); + for (let storeIndex = 0; storeIndex < 20; storeIndex++) { + const entries: Record = Object.create(null); + for (let entryIndex = 0; entryIndex < 4; entryIndex++) { + entries[`entry-${entryIndex}`] = { s: aggregateValue }; + } + boundedStorage.setItem(`valdi.PersistentStore.aggregate-${storeIndex}`, JSON.stringify(entries)); + } + const aggregateSnapshot = getPersistentStoreSnapshot(); + const aggregateJson = JSON.stringify(aggregateSnapshot); + assert(aggregateSnapshot.truncated, 'aggregate budget exhaustion must be explicit'); + assert( + aggregateSnapshot.stores.some(storeSnapshot => storeSnapshot.entriesTruncated === true), + 'the store cut short by the aggregate budget must report entry truncation', + ); + assert( + aggregateSnapshot.usage.characters <= aggregateSnapshot.limits.maxTotalCharacters, + 'reported character usage must remain bounded', + ); + assert( + aggregateSnapshot.usage.bytes <= aggregateSnapshot.limits.maxTotalBytes, + 'reported UTF-8 usage must remain bounded', + ); + assert( + aggregateJson.length <= aggregateSnapshot.limits.maxTotalCharacters, + 'serialized snapshot characters must remain bounded', + ); + assert( + new TextEncoder().encode(aggregateJson).length <= aggregateSnapshot.limits.maxTotalBytes, + 'serialized snapshot bytes must remain bounded', + ); +} + +function verifyHostileStorageGetter(): void { + __resetInMemoryForTest(); + const snapshotForLengthThrow = (thrown: unknown) => { + (globalThis as any).localStorage = { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined, + get length() { + throw thrown; + }, + }; + return getPersistentStoreSnapshot(); + }; + + const primitiveSnapshot = snapshotForLengthThrow('primitive storage failure'); + assertEqual( + primitiveSnapshot.storageError, + 'primitive storage failure', + 'primitive string throws may be retained as bounded diagnostics', + ); + + const symbolMessageError = new Error('ignored'); + (symbolMessageError as any).message = Symbol('attacker-controlled'); + const symbolMessageSnapshot = snapshotForLengthThrow(symbolMessageError); + assertEqual( + symbolMessageSnapshot.storageError, + 'Storage inspection failed.', + 'objects with symbol messages must use the constant diagnostic fallback', + ); + + let messageReads = 0; + const hostileMessage = Object.create(null); + Object.defineProperty(hostileMessage, 'message', { + get: () => { + messageReads++; + throw new Error('message getter must not run'); + }, + }); + const hostileMessageSnapshot = snapshotForLengthThrow(hostileMessage); + assertEqual(messageReads, 0, 'snapshot errors must not read attacker-controlled message properties'); + assertEqual( + hostileMessageSnapshot.storageError, + 'Storage inspection failed.', + 'hostile message getters must use the constant diagnostic fallback', + ); + + let coercions = 0; + const hostileCoercion = { + toString: () => { + coercions++; + throw new Error('toString must not run'); + }, + }; + const hostileCoercionSnapshot = snapshotForLengthThrow(hostileCoercion); + assertEqual(coercions, 0, 'snapshot errors must not coerce attacker-controlled thrown objects'); + assertEqual( + hostileCoercionSnapshot.storageError, + 'Storage inspection failed.', + 'hostile coercion must use the constant diagnostic fallback', + ); + assert( + JSON.stringify([primitiveSnapshot, symbolMessageSnapshot, hostileMessageSnapshot, hostileCoercionSnapshot]) + .includes('storageError'), + 'all hostile thrown-value snapshots must remain serializable', + ); + + (globalThis as any).localStorage = { + getItem: () => { + throw 'primitive getItem failure'; + }, + setItem: () => undefined, + removeItem: () => undefined, + key: () => 'valdi.PersistentStore.hostile', + length: 1, + }; + const getItemSnapshot = getPersistentStoreSnapshot(); + assertEqual( + getItemSnapshot.stores[0]?.error, + 'primitive getItem failure', + 'primitive per-store getter failures must become bounded metadata', + ); + assert(JSON.stringify(getItemSnapshot).includes('hostile'), 'per-store getter failures must remain serializable'); + + const hugeStorageKey = 'valdi.PersistentStore.' + 'x'.repeat(3 * 1024 * 1024); + let hugeKeyCalls = 0; + let hugeGetItemCalls = 0; + (globalThis as any).localStorage = { + getItem: () => { + hugeGetItemCalls++; + return '{}'; + }, + setItem: () => undefined, + removeItem: () => undefined, + key: () => { + hugeKeyCalls++; + return hugeStorageKey; + }, + length: 1, + }; + const hugeKeySnapshot = getPersistentStoreSnapshot(); + assert( + hugeStorageKey.length > hugeKeySnapshot.limits.maxStorageKeyCharacters, + 'the hostile key fixture must exceed the accepted raw storage-key ceiling', + ); + assertEqual(hugeKeySnapshot.rejectedStorageKeys, 1, 'oversized raw storage keys must be reported'); + assertEqual(hugeKeyCalls, 1, 'oversized raw storage keys must be read only once'); + assertEqual(hugeGetItemCalls, 0, 'oversized raw storage keys must be rejected before getItem lookup'); + assertEqual(hugeKeySnapshot.stores.length, 0, 'oversized raw storage keys must not enter store retention'); + assert(hugeKeySnapshot.truncated, 'oversized raw storage-key rejection must mark the snapshot'); + assert( + JSON.stringify(hugeKeySnapshot).length < 4096, + 'multi-megabyte raw storage keys must not inflate the serialized snapshot', + ); +} + +async function main(): Promise { + const backing = installFakeLocalStorage(); + await verifyFoundationCompatibility(backing); + await verifyReadOnlySnapshot(backing); + await verifySnapshotBounds(); + verifyHostileStorageGetter(); // eslint-disable-next-line no-console - console.log('PersistentStore web durability: all checks passed'); + console.log('PersistentStore web compatibility and debugger snapshot: all checks passed'); } -main().catch(err => { +main().catch(error => { // eslint-disable-next-line no-console - console.error(err); + console.error(error); process.exit(1); });