From 559ec5508c779d910bfa911b07de33834080a0f3 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 26 Sep 2026 05:40:21 +0200 Subject: [PATCH] fix(settings): await the enablement mirror, restore partial lock stamps and validate nested lock-store entries - The Electron mirror of the feature switch is awaited; a mirror that cannot be written undoes the settings write, so a reload never starts from a mirror that disagrees with the persisted switch. - A failed multi-type re-stamp rolls the store back AND re-stamps every touched type from it, since earlier types may already carry the new locks; a failed rollback keeps the playlist stale (fail-closed). - A persisted lock store whose nested entries are not what writeLocks produces is a failed read, not an empty store. Co-Authored-By: Claude Fable 5.1 --- docs/architecture/parental-lock.md | 22 +++++--- .../lib/parental-lock/parental-lock-bridge.ts | 21 +++++--- .../parental-lock-lock-store.service.spec.ts | 30 +++++++++++ .../parental-lock-lock-store.service.ts | 22 +++++--- .../parental-lock-storage.spec.ts | 6 +++ .../parental-lock/parental-lock-storage.ts | 3 +- .../parental-lock.service.spec.ts | 14 +++++ .../parental-lock/parental-lock.service.ts | 9 +++- .../src/lib/parental-lock.util.spec.ts | 38 ++++++++++++++ .../interfaces/src/lib/parental-lock.util.ts | 51 +++++++++++++++++++ 10 files changed, 193 insertions(+), 23 deletions(-) diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index beca58093..c35b90832 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -106,8 +106,10 @@ the strength of default settings. The lock store itself fails closed the same way: `ParentalLockStorageService.readLocks()` reports a failed read as `null` (distinct from an absent store, `{}`; Electron reads through `DatabaseService.readAppState`, which keeps a rejected IPC apart from a -missing key, and a stored payload that does not parse or is not an object -is a failed read too — corruption never becomes an empty store), and while the lock is active with the store unreadable +missing key, and a stored payload that does not parse or does not have the +shape `writeLocks` produces — `isWellFormedParentalLockStore`, down to the +nested entries — is a failed read too — corruption never becomes an empty +store), and while the lock is active with the store unreadable `ParentalLockService.withholdsEverything` is true — every `is*Locked` predicate answers true and the set-based filters (PWA Xtream, Stalker content and search, the M3U channel list) receive @@ -122,9 +124,11 @@ The window before the initial read settles is treated the same way (`ParentalLockLockStore.readable` is false until then): settings can report the feature as on before the locks are known. The feature switch itself is persisted through one guarded path (`persistEnabled`): `updateSettings` -patches memory before it writes, so a failed write is undone in memory, the -Electron mirror is left untouched and `setupPin`/`disable` report false — -the toggle never shows a state the next launch will not have. +patches memory before it writes, so a failed write is undone in memory and +`setupPin`/`disable` report false; the Electron mirror write is awaited +next, and a mirror that cannot be written undoes the settings write the +same way — the toggle never shows a state the next launch will not have, +on either side. ### In-memory catalogs @@ -249,9 +253,11 @@ the toggle never shows a state the next launch will not have. re-locks one playlist/type inside ONE transaction, so a failed restamp keeps the previous index instead of leaving every category unlocked. The store commits BEFORE that re-stamp, so a failed re-stamp rolls the store - back to what the index reflects (a category must not be recorded and - shown as locked while Electron reads, which filter by the index alone, - still serve it); if the rollback write fails too, the playlist is + back to the previous locks AND re-stamps every touched type from them (a + backup restore stamps three types, and the ones before the failing type + already carry the new locks; a category must not be recorded and shown + as locked while Electron reads, which filter by the index alone, still + serve it); if the rollback write or its re-stamp fails too, the playlist is re-stamped on the next store access, and every launch re-derives the index from the store for each playlist that has locks — awaited inside the store's `load()`, so `readable` (and with it every catalog read the diff --git a/libs/services/src/lib/parental-lock/parental-lock-bridge.ts b/libs/services/src/lib/parental-lock/parental-lock-bridge.ts index 3fa82ff6c..9b81f199f 100644 --- a/libs/services/src/lib/parental-lock/parental-lock-bridge.ts +++ b/libs/services/src/lib/parental-lock/parental-lock-bridge.ts @@ -16,14 +16,23 @@ export function syncParentalLockStateToMainProcess(active: boolean): void { /** * Mirrors the feature switch into electron-conf so a reloaded renderer and a - * restarted worker start locked while the feature is on. + * restarted worker start locked while the feature is on. Resolves false + * when the main process could not persist it — the caller must then not + * report the switch as changed, or a reload would start from the old + * mirror. True in the PWA, which has no mirror. */ -export function mirrorParentalLockEnabledSetting(enabled: boolean): void { +export async function mirrorParentalLockEnabledSetting( + enabled: boolean +): Promise { const bridge = window.electron; if (typeof bridge?.updateSettings !== 'function') { - return; + return true; + } + try { + await bridge.updateSettings({ parentalLockEnabled: enabled }); + return true; + } catch (error) { + console.error('Failed to mirror the parental lock switch.', error); + return false; } - void bridge - .updateSettings({ parentalLockEnabled: enabled }) - .catch(() => undefined); } diff --git a/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.spec.ts b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.spec.ts index e0274de1d..3f98a65ce 100644 --- a/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.spec.ts +++ b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.spec.ts @@ -108,6 +108,36 @@ describe('ParentalLockLockStore', () => { ); }); + it('re-stamps every touched type from the restored store after a partial multi-type failure', async () => { + await store.load(); + await store.ensureReadable(); + setCategoryLocks.mockClear(); + // live commits, movies fails: the index now carries the NEW live + // locks while the store is rolled back to the old ones. + setCategoryLocks + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) + .mockResolvedValue(true); + + await expect( + store.replacePlaylistLocks('pl-1', { + xtream: [{ categoryType: 'movies', xtreamId: 3 }], + stalker: [], + m3u: [], + }) + ).resolves.toBe(false); + + expect(store.lockedXtreamIds('pl-1', 'live')).toEqual([7]); + // Rollback re-stamps all three types from the restored store. + expect(setCategoryLocks.mock.calls.slice(3)).toEqual([ + ['pl-1', 'live', [7]], + ['pl-1', 'movies', []], + ['pl-1', 'series', []], + ]); + expect(store.readable()).toBe(true); + }); + it('re-stamps on the next access when even the rollback write failed', async () => { await store.load(); await store.ensureReadable(); diff --git a/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts index 71b595689..525a4b3d7 100644 --- a/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts +++ b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts @@ -189,7 +189,7 @@ export class ParentalLockLockStore { if (await this.stampXtreamLocks(playlistId, [categoryType])) { return true; } - await this.rollBack(playlistId, previous); + await this.rollBack(playlistId, previous, [categoryType]); return false; } @@ -230,7 +230,7 @@ export class ParentalLockLockStore { if (await this.stampXtreamLocks(playlistId, XTREAM_CATEGORY_TYPES)) { return true; } - await this.rollBack(playlistId, previous); + await this.rollBack(playlistId, previous, XTREAM_CATEGORY_TYPES); return false; } @@ -238,15 +238,23 @@ export class ParentalLockLockStore { * The store commits before the SQLite index is re-stamped; a failed * re-stamp would otherwise leave a category recorded (and shown) as * locked while Electron reads, which filter by the index alone, still - * serve it. The store goes back to what the index reflects; if even - * that write fails, the playlist is re-stamped on the next access. + * serve it. The store goes back to the previous locks and the touched + * types are re-stamped from it — a multi-type re-stamp may have + * committed some types before the failing one. If either step fails, + * the playlist is marked stale, which keeps the session fail-closed and + * re-stamps it on the next access. */ private async rollBack( playlistId: string, - previous: ParentalLockPlaylistLocks + previous: ParentalLockPlaylistLocks, + categoryTypes: readonly ParentalLockXtreamCategoryType[] ): Promise { - if (!(await this.persistPlaylistLocks(playlistId, previous))) { - console.error('Failed to roll back the parental lock store.'); + const restored = await this.persistPlaylistLocks(playlistId, previous); + const restamped = + restored && + (await this.stampXtreamLocks(playlistId, categoryTypes)); + if (!restored || !restamped) { + console.error('Failed to roll back the parental lock index.'); this.markIndexStale(playlistId); this.revisionState.update((value) => value + 1); } diff --git a/libs/services/src/lib/parental-lock/parental-lock-storage.spec.ts b/libs/services/src/lib/parental-lock/parental-lock-storage.spec.ts index 7abcaa587..798c5b01b 100644 --- a/libs/services/src/lib/parental-lock/parental-lock-storage.spec.ts +++ b/libs/services/src/lib/parental-lock/parental-lock-storage.spec.ts @@ -46,6 +46,12 @@ describe('ParentalLockStorageService.readLocks', () => { ['truncated JSON', '{"p":{"m3u":["Adu'], ['an array payload', '[]'], ['a scalar payload', '"locks"'], + ['a corrupt nested list', '{"p":{"xtream":"corrupt"}}'], + ['a corrupt nested entry', '{"p":{"m3u":[1]}}'], + [ + 'an entry the normalizer would drop', + '{"p":{"xtream":[{"categoryType":"live","xtreamId":"nope"}]}}', + ], ])('treats %s as a failed read, not an empty store', async (_, raw) => { localStorage.setItem(PARENTAL_LOCK_STORE_KEY, raw); await expect(service.readLocks()).resolves.toBeNull(); diff --git a/libs/services/src/lib/parental-lock/parental-lock-storage.ts b/libs/services/src/lib/parental-lock/parental-lock-storage.ts index 3afea5653..361df175c 100644 --- a/libs/services/src/lib/parental-lock/parental-lock-storage.ts +++ b/libs/services/src/lib/parental-lock/parental-lock-storage.ts @@ -1,5 +1,6 @@ import { inject, Injectable } from '@angular/core'; import { + isWellFormedParentalLockStore, normalizeParentalLockStore, PARENTAL_LOCK_PIN_KEY, PARENTAL_LOCK_STORE_KEY, @@ -55,7 +56,7 @@ export class ParentalLockStorageService { } catch { return null; } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + if (!isWellFormedParentalLockStore(parsed)) { return null; } return normalizeParentalLockStore(parsed); diff --git a/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts b/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts index 88dc226d6..e4bbd0b1e 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts @@ -226,6 +226,20 @@ describe('ParentalLockService', () => { expect(updateBridgeSettings).not.toHaveBeenCalled(); }); + it('rolls the switch back when the Electron mirror cannot be written', async () => { + prompt.requestPin.mockResolvedValue('1234'); + updateBridgeSettings.mockRejectedValueOnce(new Error('ipc')); + const service = await createService(); + + await expect(service.setupPin()).resolves.toBe(false); + + expect(service.enabled()).toBe(false); + expect(service.unlocked()).toBe(false); + expect(updateSettings).toHaveBeenLastCalledWith({ + parentalLockEnabled: false, + }); + }); + it('starts locked with the feature on and unlocks through the prompt', async () => { storage.pinHash = await hashParentalLockPin('1234'); parentalLockEnabled.set(true); diff --git a/libs/services/src/lib/parental-lock/parental-lock.service.ts b/libs/services/src/lib/parental-lock/parental-lock.service.ts index 633fc1955..d19c282e1 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.ts @@ -267,7 +267,14 @@ export class ParentalLockService { .catch(() => undefined); return false; } - mirrorParentalLockEnabledSetting(enabled); + // The mirror is what a reloaded renderer and a restarted worker + // start from; a switch persisted on one side only is undone. + if (!(await mirrorParentalLockEnabledSetting(enabled))) { + await this.settingsStore + .updateSettings({ parentalLockEnabled: !enabled }) + .catch(() => undefined); + return false; + } return true; } diff --git a/libs/shared/interfaces/src/lib/parental-lock.util.spec.ts b/libs/shared/interfaces/src/lib/parental-lock.util.spec.ts index 0cf3f7fbb..50b57cc10 100644 --- a/libs/shared/interfaces/src/lib/parental-lock.util.spec.ts +++ b/libs/shared/interfaces/src/lib/parental-lock.util.spec.ts @@ -1,5 +1,6 @@ import { isParentalLockPlaylistLocksEmpty, + isWellFormedParentalLockStore, lockedStalkerCategoryIds, lockedXtreamCategoryIds, normalizeParentalLockPlaylistLocks, @@ -109,3 +110,40 @@ describe('parental-lock.util', () => { }); }); }); + +describe('isWellFormedParentalLockStore', () => { + it('accepts what writeLocks produces, lists omitted or empty', () => { + expect(isWellFormedParentalLockStore({})).toBe(true); + expect( + isWellFormedParentalLockStore({ + p: { + xtream: [{ categoryType: 'live', xtreamId: 7 }], + stalker: [{ categoryType: 'itv', categoryId: '9' }], + m3u: ['Adult'], + }, + q: { m3u: [] }, + }) + ).toBe(true); + }); + + it.each([ + ['a non-object root', []], + ['a non-object playlist entry', { p: 'corrupt' }], + ['a non-list lock field', { p: { xtream: 'corrupt' } }], + ['a non-string group title', { p: { m3u: [1] } }], + [ + 'an unknown category type', + { p: { xtream: [{ categoryType: 'x', xtreamId: 1 }] } }, + ], + [ + 'a non-numeric xtream id', + { p: { xtream: [{ categoryType: 'live', xtreamId: 'nope' }] } }, + ], + [ + 'the Stalker "all" pseudo id', + { p: { stalker: [{ categoryType: 'itv', categoryId: '*' }] } }, + ], + ])('rejects %s', (_, value) => { + expect(isWellFormedParentalLockStore(value)).toBe(false); + }); +}); diff --git a/libs/shared/interfaces/src/lib/parental-lock.util.ts b/libs/shared/interfaces/src/lib/parental-lock.util.ts index 9f220d05b..29595340b 100644 --- a/libs/shared/interfaces/src/lib/parental-lock.util.ts +++ b/libs/shared/interfaces/src/lib/parental-lock.util.ts @@ -152,6 +152,57 @@ export function normalizeParentalLockStalkerCategories( return result; } +function isWellFormedList( + value: unknown, + isWellFormedEntry: (entry: unknown) => boolean +): boolean { + return ( + value === undefined || + (Array.isArray(value) && value.every(isWellFormedEntry)) + ); +} + +/** + * Whether a persisted store has the shape `writeLocks` produces: every + * playlist entry an object whose optional `xtream`/`stalker`/`m3u` lists + * hold only entries the normalizers accept. The normalizers DROP what they + * do not understand, which is right for user-supplied backups but turns a + * corrupted persisted store into "nothing is locked"; a store that fails + * this check is treated as unreadable instead. + */ +export function isWellFormedParentalLockStore(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + return Object.values(value).every( + (locks) => + isRecord(locks) && + isWellFormedList( + locks['xtream'], + (entry) => + isRecord(entry) && + PARENTAL_LOCK_XTREAM_CATEGORY_TYPES.includes( + entry['categoryType'] as ParentalLockXtreamCategoryType + ) && + normalizeNumericId(entry['xtreamId']) !== null + ) && + isWellFormedList( + locks['stalker'], + (entry) => + isRecord(entry) && + PARENTAL_LOCK_STALKER_CATEGORY_TYPES.includes( + entry['categoryType'] as ParentalLockStalkerCategoryType + ) && + ((typeof entry['categoryId'] === 'string' && + entry['categoryId'].trim() !== '' && + entry['categoryId'].trim() !== '*') || + (typeof entry['categoryId'] === 'number' && + Number.isFinite(entry['categoryId']))) + ) && + isWellFormedList(locks['m3u'], (entry) => typeof entry === 'string') + ); +} + export function normalizeParentalLockGroupTitles(value: unknown): string[] { const seen = new Set(); for (const item of toArray(value)) {