fix(stalker): reject replaced late edit targets

This commit is contained in:
4gray committed 2026-08-09 18:23:34 +02:00
1 parent e4bee6da40
commit fabffcc902
10 files changed
+102 -17

No files matched your search

@@ -669,6 +669,10 @@ describe('PlaylistInfoComponent', () => {
stalkerDeviceId2: 'STORED-DEVICE-2',
stalkerSignature1: 'STORED-SIGNATURE-1',
stalkerSignature2: 'STORED-SIGNATURE-2',
}),
expect.objectContaining({
portalUrl: 'https://portal.example.com/c',
stalkerSerialNumber: 'STORED-SERIAL',
})
);
});
@@ -413,7 +413,8 @@ export class PlaylistInfoComponent {
) {
const result =
await this.stalkerConnectionEditor.resolveConnection(
normalizedPlaylist
normalizedPlaylist,
this.playlist
);
if (
result.status !==
@@ -41,7 +41,8 @@ export interface StalkerResolvedConnectionApplyOptions {
export interface StalkerPlaylistConnectionEditor {
resolveConnection(
playlist: PlaylistMeta
playlist: PlaylistMeta,
sourcePlaylist?: PlaylistMeta
): Promise<StalkerPlaylistConnectionResult>;
/** Atomically persists a resolved edit, then synchronizes in-run state. */
applyResolvedConnection(
@@ -341,7 +341,10 @@ describe('Stalker edited-session coordination', () => {
stalkerWatchdogTimeout: 90,
stalkerTimeslot: 5,
} as Playlist;
const fence = await service.beginEditDiscovery(editedPlaylist);
const fence = await service.beginEditDiscovery(
editedPlaylist,
oldPlaylist
);
const persisted = await service.replaceSessionAfterEdit(
editedPlaylist,
@@ -367,6 +370,35 @@ describe('Stalker edited-session coordination', () => {
);
});
it('rejects a late merge after another connection replaces the playlist ID', async () => {
const replacementPlaylist = {
...oldPlaylist,
portalUrl: 'https://restored.example.com/portal.php',
macAddress: '00:1A:79:11:22:33',
} as Playlist;
transformPlaylistMeta.mockImplementationOnce((_id, transform) =>
of(transform(replacementPlaylist))
);
const editedPlaylist = {
...oldPlaylist,
portalUrl: 'https://new.example.com/server/load.php',
stalkerToken: 'NEW_TOKEN',
} as Playlist;
const fence = await service.beginEditDiscovery(
editedPlaylist,
oldPlaylist
);
await expect(
service.replaceSessionAfterEdit(editedPlaylist, fence, {
preserveCurrentMetadata: true,
})
).rejects.toThrow(/could not be persisted/i);
expect(service.getCachedToken(oldPlaylist._id)).toBeNull();
expect(updatePlaylistMeta).not.toHaveBeenCalled();
});
it('does not adopt a resolved full session when its atomic write fails', async () => {
service.adoptDiscoveredSimplePortal(oldPlaylist);
updatePlaylistMeta.mockReturnValueOnce(
@@ -18,6 +18,7 @@ export interface StalkerEditFence {
interface PendingEdit {
readonly owner: symbol;
readonly configurationFingerprint: string;
readonly sourceConfigurationFingerprint: string;
}
/** Serializes authoritative Edit results against pre-edit authentication. */
@@ -87,8 +88,14 @@ export class StalkerEditedSessionCoordinator {
}
}
async beginEdit(playlist: Playlist): Promise<StalkerEditFence> {
async beginEdit(
playlist: Playlist,
sourcePlaylist: Playlist = playlist
): Promise<StalkerEditFence> {
const playlistId = playlist._id;
if (sourcePlaylist._id !== playlistId) {
throw new Error('Stale Stalker playlist configuration');
}
if (this.pendingEdits.has(playlistId)) {
throw new Error('Stalker playlist edit already in progress');
}
@@ -98,6 +105,8 @@ export class StalkerEditedSessionCoordinator {
this.pendingEdits.set(playlistId, {
owner: fence.owner,
configurationFingerprint,
sourceConfigurationFingerprint:
stalkerConfigurationFingerprint(sourcePlaylist),
});
try {
@@ -132,6 +141,7 @@ export class StalkerEditedSessionCoordinator {
sessionFingerprint
);
const owner = fence?.owner ?? Symbol('stalker-edit');
const pending = this.pendingEdits.get(playlistId);
if (
fence &&
(fence.playlistId !== playlistId ||
@@ -141,11 +151,15 @@ export class StalkerEditedSessionCoordinator {
new Error('Stale Stalker playlist configuration')
);
}
const sourceConfigurationFingerprint =
pending?.sourceConfigurationFingerprint ??
stalkerConfigurationFingerprint(playlist);
// Keep the same owner while discovery replaces its input-shaped
// fingerprint with the resolved endpoint/mode fingerprint.
this.pendingEdits.set(playlistId, {
owner,
configurationFingerprint,
sourceConfigurationFingerprint,
});
const previous = this.replacements.get(playlistId) ?? Promise.resolve();
const replacement = previous
@@ -156,6 +170,7 @@ export class StalkerEditedSessionCoordinator {
sessionFingerprint,
configurationFingerprint,
owner,
sourceConfigurationFingerprint,
options
)
);
@@ -178,6 +193,7 @@ export class StalkerEditedSessionCoordinator {
sessionFingerprint: string,
configurationFingerprint: string,
owner: symbol,
sourceConfigurationFingerprint: string,
options: { preserveCurrentMetadata?: boolean }
): Promise<Playlist> {
const playlistId = playlist._id;
@@ -215,11 +231,14 @@ export class StalkerEditedSessionCoordinator {
const persistedPlaylist = await firstValueFrom(
options.preserveCurrentMetadata
? playlists.transformPlaylistMeta(playlistId, (current) =>
mergeResolvedStalkerConnection(
current,
playlist,
sessionPatch
)
stalkerConfigurationFingerprint(current) ===
sourceConfigurationFingerprint
? mergeResolvedStalkerConnection(
current,
playlist,
sessionPatch
)
: null
)
: playlists.updatePlaylistMeta({
...playlist,
@@ -253,8 +253,11 @@ export class StalkerSessionService {
* before discovery. The opaque fence keeps new authentication out until
* the result is either cancelled or atomically persisted.
*/
beginEditDiscovery(playlist: Playlist): Promise<StalkerEditFence> {
return this.editedSessions.beginEdit(playlist);
beginEditDiscovery(
playlist: Playlist,
sourcePlaylist: Playlist = playlist
): Promise<StalkerEditFence> {
return this.editedSessions.beginEdit(playlist, sourcePlaylist);
}
/**