From 5336a5d0c72256cc888e80cea7d53e3994910b55 Mon Sep 17 00:00:00 2001 From: rory Date: Fri, 21 Aug 2026 17:45:15 -0700 Subject: [PATCH 1/3] fix(storage): guard getErrorParts against unsafe throws JSON.stringify circular objects and BigInt before classifyError so storage error normalization cannot throw. --- lib/storage/errors.ts | 30 +++++++++++++++++-- tests/unit/storage/getErrorPartsTest.ts | 39 +++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 tests/unit/storage/getErrorPartsTest.ts diff --git a/lib/storage/errors.ts b/lib/storage/errors.ts index 631b01535..40a3a2025 100644 --- a/lib/storage/errors.ts +++ b/lib/storage/errors.ts @@ -28,15 +28,41 @@ const StorageErrorClass = { UNKNOWN: 'unknown', } as const; +/** + * Serializes non-Error thrown values for classifier matching. JSON.stringify is preferred for plain + * objects, but it throws on circular structures and returns undefined for BigInt/function/symbol, so + * fall back to String() to keep normalization from throwing before classifyError runs. + */ +function serializeThrownValue(error: unknown): string { + try { + const serialized = JSON.stringify(error); + if (serialized !== undefined) { + return serialized; + } + } catch { + // fall through to String() + } + return String(error); +} + /** * Normalizes any thrown value into a lowercased `{name, message}` pair for matching. Shared by every * provider's classifier so they all extract the error the same way. */ function getErrorParts(error: unknown): {name: string; message: string} { if (error instanceof Error || (typeof DOMException !== 'undefined' && error instanceof DOMException)) { - return {name: (error.name ?? '').toLowerCase(), message: (error.message ?? '').toLowerCase()}; + return { + name: (error.name ?? '').toLowerCase(), + message: (error.message ?? '').toLowerCase(), + }; + } + if (typeof error === 'string') { + return {name: '', message: error.toLowerCase()}; + } + if (error === null || error === undefined) { + return {name: '', message: ''}; } - return {name: '', message: String(error ?? '').toLowerCase()}; + return {name: '', message: serializeThrownValue(error).toLowerCase()}; } export {StorageErrorClass, getErrorParts}; diff --git a/tests/unit/storage/getErrorPartsTest.ts b/tests/unit/storage/getErrorPartsTest.ts new file mode 100644 index 000000000..f56a38133 --- /dev/null +++ b/tests/unit/storage/getErrorPartsTest.ts @@ -0,0 +1,39 @@ +import {getErrorParts} from '../../../lib/storage/errors'; + +describe('getErrorParts', () => { + it('should extract name and message from Error instances', () => { + expect(getErrorParts(new TypeError('Could not be cloned'))).toEqual({ + name: 'typeerror', + message: 'could not be cloned', + }); + }); + + it('should lowercase string throws', () => { + expect(getErrorParts('Quota exceeded')).toEqual({name: '', message: 'quota exceeded'}); + }); + + it('should treat null and undefined as empty', () => { + expect(getErrorParts(null)).toEqual({name: '', message: ''}); + expect(getErrorParts(undefined)).toEqual({name: '', message: ''}); + }); + + it('should serialize plain objects so classifiers can match fields', () => { + expect(getErrorParts({message: 'QuotaExceededError'})).toEqual({ + name: '', + message: '{"message":"quotaexceedederror"}', + }); + }); + + it('should not throw on circular objects', () => { + const circular: {self?: unknown} = {}; + circular.self = circular; + + expect(() => getErrorParts(circular)).not.toThrow(); + expect(getErrorParts(circular)).toEqual({name: '', message: '[object object]'}); + }); + + it('should not throw on BigInt', () => { + expect(() => getErrorParts(1n)).not.toThrow(); + expect(getErrorParts(1n)).toEqual({name: '', message: '1'}); + }); +}); From 95e73aeef555a47e3460ad62c8b4648089c0edfc Mon Sep 17 00:00:00 2001 From: rory Date: Fri, 21 Aug 2026 22:01:48 -0700 Subject: [PATCH 2/3] fix: avoid BigInt literals in getErrorParts tests tsc targets below ES2020, so use BigInt(1) instead of 1n. --- tests/unit/storage/getErrorPartsTest.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/storage/getErrorPartsTest.ts b/tests/unit/storage/getErrorPartsTest.ts index f56a38133..54fb7a64f 100644 --- a/tests/unit/storage/getErrorPartsTest.ts +++ b/tests/unit/storage/getErrorPartsTest.ts @@ -33,7 +33,8 @@ describe('getErrorParts', () => { }); it('should not throw on BigInt', () => { - expect(() => getErrorParts(1n)).not.toThrow(); - expect(getErrorParts(1n)).toEqual({name: '', message: '1'}); + const value = BigInt(1); + expect(() => getErrorParts(value)).not.toThrow(); + expect(getErrorParts(value)).toEqual({name: '', message: '1'}); }); }); From 953364834c0ce21ffe46bf6bb8c993e8b49a8ee8 Mon Sep 17 00:00:00 2001 From: rory Date: Fri, 21 Aug 2026 22:11:30 -0700 Subject: [PATCH 3/3] refactor: return String(error) from serialize catch Keep the undefined-stringify fallback via ?? so BigInt still works. --- lib/storage/errors.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/storage/errors.ts b/lib/storage/errors.ts index 40a3a2025..1a03b4eeb 100644 --- a/lib/storage/errors.ts +++ b/lib/storage/errors.ts @@ -35,14 +35,10 @@ const StorageErrorClass = { */ function serializeThrownValue(error: unknown): string { try { - const serialized = JSON.stringify(error); - if (serialized !== undefined) { - return serialized; - } + return JSON.stringify(error) ?? String(error); } catch { - // fall through to String() + return String(error); } - return String(error); } /**