From 562e206945c30cb061c505ec0166d756273baec6 Mon Sep 17 00:00:00 2001 From: dormouse-bot <287024035+dormouse-bot@users.noreply.github.com> Date: Fri, 17 Jul 2026 08:36:00 +0000 Subject: [PATCH] fix(themes): guard installed-themes storage against non-array JSON A valid-but-non-array value in localStorage (corruption or external tampering) was returned cast as DormouseTheme[], causing an uncaught TypeError in getAllThemes()'s spread and addInstalledTheme()'s filter. Add an Array.isArray guard and a regression test (the module had none). --- lib/src/lib/themes/store.test.ts | 72 ++++++++++++++++++++++++++++++++ lib/src/lib/themes/store.ts | 8 +++- 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 lib/src/lib/themes/store.test.ts diff --git a/lib/src/lib/themes/store.test.ts b/lib/src/lib/themes/store.test.ts new file mode 100644 index 00000000..89e6c59b --- /dev/null +++ b/lib/src/lib/themes/store.test.ts @@ -0,0 +1,72 @@ +/** + * @vitest-environment jsdom + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import type { DormouseTheme } from './types'; +import { + addInstalledTheme, + getAllThemes, + getBundledThemes, + getInstalledThemes, +} from './store'; + +const INSTALLED_KEY = 'dormouse:installed-themes'; + +function installStorageStub(): void { + const values = new Map(); + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: { + clear: () => values.clear(), + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value), + }, + }); +} + +function makeInstalledTheme(id: string): DormouseTheme { + return { + id, + label: id, + type: 'dark', + swatch: '#000000', + accent: '#ffffff', + vars: {}, + origin: { kind: 'installed', extensionId: 'pub/ext', installedAt: '2026-07-17' }, + }; +} + +describe('theme store', () => { + beforeEach(() => { + installStorageStub(); + }); + + it('returns [] when the installed-themes value is valid JSON but not an array', () => { + // Corrupted or externally tampered storage: parses fine, wrong shape. + localStorage.setItem(INSTALLED_KEY, JSON.stringify({ id: 'oops' })); + + // Regression: before the Array.isArray guard, the object was returned cast + // as DormouseTheme[], and getAllThemes()'s spread / addInstalledTheme()'s + // filter threw an uncaught TypeError. + expect(getInstalledThemes()).toEqual([]); + expect(() => getAllThemes()).not.toThrow(); + expect(getAllThemes()).toEqual(getBundledThemes()); + expect(() => addInstalledTheme(makeInstalledTheme('recover'))).not.toThrow(); + expect(getInstalledThemes().map((t) => t.id)).toEqual(['recover']); + }); + + it('returns [] for non-JSON garbage in storage', () => { + localStorage.setItem(INSTALLED_KEY, 'not json at all'); + expect(getInstalledThemes()).toEqual([]); + }); + + it('installs a theme and dedupes by id on reinstall', () => { + addInstalledTheme(makeInstalledTheme('a')); + addInstalledTheme(makeInstalledTheme('b')); + expect(getInstalledThemes().map((t) => t.id)).toEqual(['a', 'b']); + + addInstalledTheme(makeInstalledTheme('a')); + expect(getInstalledThemes().map((t) => t.id)).toEqual(['b', 'a']); + }); +}); diff --git a/lib/src/lib/themes/store.ts b/lib/src/lib/themes/store.ts index b627d958..ca55a0b2 100644 --- a/lib/src/lib/themes/store.ts +++ b/lib/src/lib/themes/store.ts @@ -27,7 +27,13 @@ export function getInstalledThemes(): DormouseTheme[] { if (!storage) return []; try { const raw = storage.getItem(INSTALLED_KEY); - return raw ? (JSON.parse(raw) as DormouseTheme[]) : []; + if (!raw) return []; + // Guard against valid-but-wrong-shaped JSON (corrupted or externally + // tampered storage): a non-array value would otherwise be returned cast + // as DormouseTheme[], and the later `.filter`/spread callers would throw + // an uncaught TypeError that breaks theme listing and installation. + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as DormouseTheme[]) : []; } catch { return []; }