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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-26 05:40:21 +02:00
1 parent b64defb576
commit 559ec5508c
10 files changed
+193 -23

No files matched your search

@@ -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);
});
});
@@ -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<string>();
for (const item of toArray(value)) {