fix(settings): require the PIN for lock-bearing backup restores and fail closed during index re-stamps

- A backup carrying lock lists replaces the matching playlists' locks,
  possibly with an emptier set; the import now asks for the PIN (after the
  file was chosen) and aborts when it is refused.
- While a write re-stamps the SQLite index the playlist counts as stale,
  so a relock inside that window reloads fail-closed instead of through
  the old stamps. The internal store write now needs only a readable
  store, so a rollback can still land.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 12:31:17 +02:00
1 parent acabd99107
commit 501e196456
6 files changed
+112 -5

No files matched your search

+8 -2
View File
@@ -382,7 +382,10 @@ by the index alone:
reload on is published only once every touched type is stamped — also
after a rollback — so a reload cannot read a later type through its old
stamps. If the rollback write or its re-stamp fails too, the playlist is
marked stale and re-stamped on the next store access.
marked stale and re-stamped on the next store access. For the whole
re-stamp window — store changed, stamps not yet landed — the playlist
counts as stale, so `readable` is false and a relock inside the window
reloads fail-closed instead of through the old stamps.
- A write that removes a playlist's LAST lock clears the index first and
drops the key afterwards: the launch-time reconcile finds playlists only
through their key, so an interruption must leave store-with-lock and
@@ -422,7 +425,10 @@ never runs against the empty fail-closed snapshot.
## Backup
Locks are restored LAST for each entry, after the Xtream data restore, so a
A backup carrying lock lists replaces the matching playlists' locks,
possibly with an emptier set, so the import asks for the PIN first (after
the file was chosen) and aborts when it is refused. Locks are restored LAST
for each entry, after the Xtream data restore, so a
failed merge leaves the playlist's previous locks in place. M3U group titles
travel verbatim (`normalizeParentalLockGroupTitles`, exact
dedup): locks match `channel.group.title` exactly, so the trimming
@@ -315,6 +315,27 @@ describe('ParentalLockLockStore', () => {
expect(storage.writeLocks).toHaveBeenCalledWith({});
});
it('is not readable while a write is re-stamping the index', async () => {
await store.load();
await store.ensureReadable();
const readableAtStamp: boolean[] = [];
setCategoryLocks.mockImplementation(async () => {
readableAtStamp.push(store.readable());
return true;
});
await expect(
store.setXtreamLocks('pl-1', 'live', [7, 9])
).resolves.toBe(true);
await expect(store.setXtreamLocks('pl-1', 'live', [])).resolves.toBe(
true
);
expect(readableAtStamp.length).toBeGreaterThan(0);
expect(readableAtStamp.every((readable) => !readable)).toBe(true);
expect(store.readable()).toBe(true);
});
it('rolls the store back when the index re-stamp fails', async () => {
await store.load();
setCategoryLocks.mockResolvedValue(false);
@@ -161,6 +161,11 @@ export class ParentalLockLockStore {
this.staleIndexCount.set(this.staleIndexPlaylists.size);
}
private unmarkIndexStale(playlistId: string): void {
this.staleIndexPlaylists.delete(playlistId);
this.staleIndexCount.set(this.staleIndexPlaylists.size);
}
/** Re-stamps every playlist whose index may lag behind the store. */
private async reconcileXtreamIndex(): Promise<void> {
if (!this.runtime.supportsXtreamSqliteDataSource) {
@@ -364,13 +369,15 @@ export class ParentalLockLockStore {
if (!(await this.ensureReadable())) {
return false;
}
this.markStaleWhileStamping(playlistId);
if (
(await this.stampXtreamLocks(
playlistId,
categoryTypes,
normalizedNext
)) &&
(await this.persistPlaylistLocks(playlistId, normalizedNext))
(this.unmarkIndexStale(playlistId),
await this.persistPlaylistLocks(playlistId, normalizedNext))
) {
return true;
}
@@ -392,7 +399,9 @@ export class ParentalLockLockStore {
) {
return false;
}
this.markStaleWhileStamping(playlistId);
if (await this.stampXtreamLocks(playlistId, categoryTypes)) {
this.unmarkIndexStale(playlistId);
this.revisionState.update((value) => value + 1);
return true;
}
@@ -426,10 +435,24 @@ export class ParentalLockLockStore {
if (!restored || !restamped) {
console.error('Failed to roll back the parental lock index.');
this.markIndexStale(playlistId);
} else {
this.unmarkIndexStale(playlistId);
}
this.revisionState.update((value) => value + 1);
}
/**
* From the store change until every stamp has landed the SQLite index
* does not agree with the store: `readable` must be false for that whole
* window, or a relock inside it would reload through the old stamps.
* Only Electron has the index.
*/
private markStaleWhileStamping(playlistId: string): void {
if (this.runtime.supportsXtreamSqliteDataSource) {
this.markIndexStale(playlistId);
}
}
/** Re-stamps the index from `locks`; marks it stale when that fails. */
private async restoreIndex(
playlistId: string,
@@ -439,8 +462,10 @@ export class ParentalLockLockStore {
if (!(await this.stampXtreamLocks(playlistId, categoryTypes, locks))) {
console.error('Failed to restore the parental lock index.');
this.markIndexStale(playlistId);
this.revisionState.update((value) => value + 1);
} else {
this.unmarkIndexStale(playlistId);
}
this.revisionState.update((value) => value + 1);
}
/**
@@ -472,7 +497,11 @@ export class ParentalLockLockStore {
locks: ParentalLockPlaylistLocks,
options: { publish?: boolean } = {}
): Promise<boolean> {
if (!(await this.ensureReadable())) {
// Every public mutation runs `ensureReadable()` before it builds its
// edit. Here only the STORE must be known: the index of the
// playlist being written is deliberately stale while it is
// re-stamped, and a rollback must still be able to write.
if (this.unreadable()) {
return false;
}
const normalized = normalizeParentalLockPlaylistLocks(locks);
@@ -124,6 +124,7 @@ export function createPlaylistBackupService(
initialize: jest.fn().mockResolvedValue(undefined),
locksReadable: jest.fn(() => true),
ensureLocksReadable: jest.fn().mockResolvedValue(true),
requestUnlock: jest.fn().mockResolvedValue(true),
locksFor: jest.fn(() => ({ xtream: [], stalker: [], m3u: [] })),
replacePlaylistLocks: jest.fn().mockResolvedValue(true),
},
@@ -131,6 +131,24 @@ export class PlaylistBackupService {
json: string
): Promise<PlaylistBackupImportSummary> {
const manifest = this.parseManifest(json);
// A backup carrying lock lists REPLACES the matching playlists'
// locks, possibly with an emptier set: that is a lock edit, and lock
// edits sit behind the PIN. Asked here, after the file was chosen.
const carriesLocks = manifest.playlists.some((entry) => {
const state = entry.userState as {
lockedCategories?: unknown;
lockedGroupTitles?: unknown;
};
return (
state?.lockedCategories !== undefined ||
state?.lockedGroupTitles !== undefined
);
});
if (carriesLocks && !(await this.parentalLock.requestUnlock())) {
throw new PlaylistBackupError(
'Enter the parental PIN to restore a backup that carries parental locks.'
);
}
const existingPlaylists = await firstValueFrom(
this.playlistsService.getAllData()
);
@@ -113,6 +113,7 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => {
initialize: jest.fn().mockResolvedValue(undefined),
locksReadable: jest.fn(() => true),
ensureLocksReadable: jest.fn().mockResolvedValue(true),
requestUnlock: jest.fn().mockResolvedValue(true),
locksFor: jest.fn(() => ({ xtream: [], stalker: [], m3u: [] })),
replacePlaylistLocks,
},
@@ -140,6 +141,7 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => {
initialize: jest.fn().mockResolvedValue(undefined),
locksReadable: jest.fn(() => true),
ensureLocksReadable: jest.fn().mockResolvedValue(true),
requestUnlock: jest.fn().mockResolvedValue(true),
// A failed cleanup left locks under the id the restore reuses.
locksFor: jest.fn(() => ({
xtream: [{ categoryType: 'live', xtreamId: 1 }],
@@ -163,6 +165,36 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => {
});
});
it('asks for the PIN before restoring lock lists and aborts when it is refused', async () => {
const collaborators = createRestoreCollaborators();
const requestUnlock = jest.fn().mockResolvedValue(false);
const replacePlaylistLocks = jest.fn().mockResolvedValue(true);
const service = createPlaylistBackupService({
...collaborators,
parentalLock: {
initialize: jest.fn().mockResolvedValue(undefined),
locksReadable: jest.fn(() => true),
ensureLocksReadable: jest.fn().mockResolvedValue(true),
requestUnlock,
locksFor: jest.fn(() => ({ xtream: [], stalker: [], m3u: [] })),
replacePlaylistLocks,
},
});
const manifest = createXtreamManifest([]);
(
manifest.playlists[0].userState as { lockedCategories?: unknown }
).lockedCategories = [];
await expect(
service.importBackup(JSON.stringify(manifest))
).rejects.toThrow(/parental PIN/);
expect(requestUnlock).toHaveBeenCalled();
expect(replacePlaylistLocks).not.toHaveBeenCalled();
expect(
collaborators.playlistsService.addPlaylist
).not.toHaveBeenCalled();
});
it('rejects a damaged parental lock list instead of erasing the persisted locks', async () => {
const collaborators = createRestoreCollaborators();
const service = createPlaylistBackupService(collaborators);