diff --git a/apps/electron-backend/src/app/api/main.preload.spec-data.ts b/apps/electron-backend/src/app/api/main.preload.spec-data.ts index effaa9c1d..77c7760fb 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec-data.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec-data.ts @@ -2,9 +2,9 @@ import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces'; type ElectronBridgeMethodName = Extract< { - [K in keyof ElectronBridgeApi]: ElectronBridgeApi[K] extends ( - ...args: never[] - ) => unknown + [K in keyof ElectronBridgeApi]: NonNullable< + ElectronBridgeApi[K] + > extends (...args: never[]) => unknown ? K : never; }[keyof ElectronBridgeApi], @@ -51,6 +51,105 @@ const tmdbCacheEntry = { payload: '{"id":603}', }; +export const stalkerSessionPreloadCases: PreloadInvokeCase[] = [ + { + method: 'stalkerSessionOpen', + args: [ + { + descriptor: { + connectionMode: 'provisional', + macAddress: '00:1A:79:00:00:01', + playlistRef: 'playlist-1', + profilePreset: { + id: 'mag250-public-5_1-minimal-v1', + version: 1, + }, + provisionalReason: 'import', + sourceUrl: 'https://portal.example/c/', + }, + }, + ], + channel: 'STALKER_SESSION_OPEN', + forwardedArgs: [ + { + descriptor: { + connectionMode: 'provisional', + macAddress: '00:1A:79:00:00:01', + playlistRef: 'playlist-1', + profilePreset: { + id: 'mag250-public-5_1-minimal-v1', + version: 1, + }, + provisionalReason: 'import', + sourceUrl: 'https://portal.example/c/', + }, + }, + ], + }, + { + method: 'stalkerSessionContinue', + args: [ + { + challengeRef: 'opaque-challenge', + response: { + approved: true, + kind: 'origin-approval', + }, + }, + ], + channel: 'STALKER_SESSION_CONTINUE', + forwardedArgs: [ + { + challengeRef: 'opaque-challenge', + response: { + approved: true, + kind: 'origin-approval', + }, + }, + ], + }, + { + method: 'stalkerSessionRequest', + args: [ + { + leaseRef: 'opaque-lease', + operation: 'get-ordered-list', + parameters: { + category: '10', + page: 1, + }, + }, + ], + channel: 'STALKER_SESSION_REQUEST', + forwardedArgs: [ + { + leaseRef: 'opaque-lease', + operation: 'get-ordered-list', + parameters: { + category: '10', + page: 1, + }, + }, + ], + }, + { + method: 'stalkerSessionControl', + args: [ + { + action: 'activate', + leaseRef: 'opaque-lease', + }, + ], + channel: 'STALKER_SESSION_CONTROL', + forwardedArgs: [ + { + action: 'activate', + leaseRef: 'opaque-lease', + }, + ], + }, +]; + export const dbPreloadCases: PreloadInvokeCase[] = [ { method: 'dbCreatePlaylist', diff --git a/apps/electron-backend/src/app/api/main.preload.spec.ts b/apps/electron-backend/src/app/api/main.preload.spec.ts index 54d2ddc9f..b9923399b 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec.ts @@ -15,6 +15,7 @@ import { dbPreloadCases, epgPreloadCases, operationId, + stalkerSessionPreloadCases, } from './main.preload.spec-data'; type ExposedElectronApi = ElectronBridgeApi & @@ -121,6 +122,49 @@ describe('main preload DB IPC contract', () => { } ); + it.each(stalkerSessionPreloadCases)( + '$method invokes $channel with the expected arguments', + async ({ method, args, channel, forwardedArgs }) => { + const api = getExposedApi(); + + await callExposedApiMethod(api, method, args); + + expect(mockIpcRenderer.invoke).toHaveBeenLastCalledWith( + channel, + ...forwardedArgs + ); + } + ); + + it('keeps Stalker session IPC narrow while preserving the legacy request bridge', async () => { + const api = getExposedApi(); + const legacyPayload = { + macAddress: '00:1A:79:00:00:01', + params: { + action: 'get_profile', + }, + url: 'https://portal.example/server/load.php', + }; + + expect( + Object.keys(api) + .filter((method) => method.startsWith('stalkerSession')) + .sort() + ).toEqual([ + 'stalkerSessionContinue', + 'stalkerSessionControl', + 'stalkerSessionOpen', + 'stalkerSessionRequest', + ]); + + await api.stalkerRequest(legacyPayload); + + expect(mockIpcRenderer.invoke).toHaveBeenLastCalledWith( + 'STALKER_REQUEST', + legacyPayload + ); + }); + it('forwards scoped user-agent override arguments', async () => { const api = getExposedApi(); diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index bd078b223..64abda071 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -6,6 +6,10 @@ import { APP_UPDATE_GET_STATUS, APP_UPDATE_INSTALL, APP_UPDATE_STATUS_CHANGED, + STALKER_SESSION_CONTINUE, + STALKER_SESSION_CONTROL, + STALKER_SESSION_OPEN, + STALKER_SESSION_REQUEST, } from '@iptvnator/shared/interfaces/ipc-commands'; import { attachEmbeddedMpvFrameView, @@ -551,6 +555,14 @@ const electronApi: ElectronBridgeApi = { serialNumber?: string; requestId?: string; }) => ipcRenderer.invoke('STALKER_REQUEST', payload), + stalkerSessionOpen: (request) => + ipcRenderer.invoke(STALKER_SESSION_OPEN, request), + stalkerSessionContinue: (request) => + ipcRenderer.invoke(STALKER_SESSION_CONTINUE, request), + stalkerSessionRequest: (request) => + ipcRenderer.invoke(STALKER_SESSION_REQUEST, request), + stalkerSessionControl: (request) => + ipcRenderer.invoke(STALKER_SESSION_CONTROL, request), xtreamRequest: (payload: { url: string; params: Record; diff --git a/apps/electron-backend/src/app/events/stalker-session.events.spec.ts b/apps/electron-backend/src/app/events/stalker-session.events.spec.ts new file mode 100644 index 000000000..b11e93a71 --- /dev/null +++ b/apps/electron-backend/src/app/events/stalker-session.events.spec.ts @@ -0,0 +1,397 @@ +import { + STALKER_SESSION_APPLICATION_OPERATIONS, + STALKER_SESSION_CONTINUE, + STALKER_SESSION_CONTROL, + STALKER_SESSION_OPEN, + STALKER_SESSION_REQUEST, + type StalkerSessionConnectionOutcome, + type StalkerSessionControlOutcome, + type StalkerSessionRequestOutcome, +} from '@iptvnator/shared/interfaces'; +import type { StalkerSessionManagerPort } from './stalker-session.events'; + +const mockHandlers = new Map< + string, + (event: MockInvokeEvent, request: unknown) => Promise +>(); + +jest.mock('electron', () => ({ + ipcMain: { + handle: jest.fn( + ( + channel: string, + handler: ( + event: MockInvokeEvent, + request: unknown + ) => Promise + ) => { + mockHandlers.set(channel, handler); + } + ), + }, +})); + +type MockSender = { + id: number; + once: jest.Mock; +}; + +type MockInvokeEvent = { + sender: MockSender; +}; + +const OPEN_REQUEST = { + descriptor: { + connectionMode: 'provisional', + macAddress: '00-1a-79-00-00-01', + playlistRef: 'playlist-1', + profilePreset: { + id: 'mag250-public-5_1-minimal-v1', + version: 1, + }, + provisionalReason: 'import', + sourceUrl: 'https://portal.example/c/#fragment', + }, +} as const; + +const CONNECTION_OUTCOME: StalkerSessionConnectionOutcome = { + kind: 'failure', + reason: 'request-timeout', + requestId: 'request-open', + retryable: true, + stage: 'handshaking', +}; + +function createSender(id = 42): MockSender { + return { + id, + once: jest.fn(), + }; +} + +function createManager(): StalkerSessionManagerPort { + return { + cleanupSender: jest.fn(), + continue: jest.fn().mockResolvedValue(CONNECTION_OUTCOME), + control: jest.fn().mockResolvedValue({ + action: 'activate', + kind: 'success', + requestId: 'request-control', + } satisfies StalkerSessionControlOutcome), + open: jest.fn().mockResolvedValue(CONNECTION_OUTCOME), + request: jest.fn().mockResolvedValue({ + kind: 'success', + operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems, + payload: { + items: [], + }, + requestId: 'request-catalog', + } satisfies StalkerSessionRequestOutcome), + }; +} + +function invoke( + channel: string, + sender: MockSender, + request: unknown +): Promise { + const handler = mockHandlers.get(channel); + if (!handler) { + throw new Error(`Missing test IPC handler: ${channel}`); + } + return handler({ sender }, request); +} + +describe('StalkerSessionEvents', () => { + beforeEach(async () => { + jest.resetModules(); + mockHandlers.clear(); + }); + + it('registers exactly the four typed channels and binds normalized input to the sender', async () => { + const manager = createManager(); + const sender = createSender(); + const { default: StalkerSessionEvents } = + await import('./stalker-session.events'); + + StalkerSessionEvents.bootstrapStalkerSessionEvents(manager); + + expect([...mockHandlers.keys()].sort()).toEqual( + [ + STALKER_SESSION_OPEN, + STALKER_SESSION_CONTINUE, + STALKER_SESSION_REQUEST, + STALKER_SESSION_CONTROL, + ].sort() + ); + + await expect( + invoke(STALKER_SESSION_OPEN, sender, OPEN_REQUEST) + ).resolves.toBe(CONNECTION_OUTCOME); + expect(manager.open).toHaveBeenCalledWith(42, { + descriptor: { + ...OPEN_REQUEST.descriptor, + macAddress: '00:1A:79:00:00:01', + sourceUrl: 'https://portal.example/c/', + }, + }); + expect(sender.once).toHaveBeenCalledTimes(1); + expect(sender.once).toHaveBeenCalledWith( + 'destroyed', + expect.any(Function) + ); + }); + + it('forwards continue, application request, and control through their typed manager methods', async () => { + const manager = createManager(); + const sender = createSender(73); + const { default: StalkerSessionEvents } = + await import('./stalker-session.events'); + const continueRequest = { + challengeRef: 'opaque-challenge', + response: { + approved: true, + kind: 'origin-approval', + }, + } as const; + const operationRequest = { + leaseRef: 'opaque-lease', + operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems, + parameters: { + category: '10', + page: 1, + }, + } as const; + const controlRequest = { + action: 'activate', + leaseRef: 'opaque-lease', + } as const; + + StalkerSessionEvents.bootstrapStalkerSessionEvents(manager); + + await invoke(STALKER_SESSION_CONTINUE, sender, continueRequest); + await invoke(STALKER_SESSION_REQUEST, sender, operationRequest); + await invoke(STALKER_SESSION_CONTROL, sender, controlRequest); + + expect(manager.continue).toHaveBeenCalledWith(73, continueRequest); + expect(manager.request).toHaveBeenCalledWith(73, operationRequest); + expect(manager.control).toHaveBeenCalledWith(73, controlRequest); + expect(sender.once).toHaveBeenCalledTimes(1); + }); + + it.each([ + { + channel: STALKER_SESSION_OPEN, + request: { + ...OPEN_REQUEST, + token: 'raw-top-level-token', + }, + }, + { + channel: STALKER_SESSION_OPEN, + request: { + descriptor: { + ...OPEN_REQUEST.descriptor, + password: 'raw-descriptor-password', + }, + }, + }, + { + channel: STALKER_SESSION_CONTINUE, + request: { + challengeRef: 'opaque-challenge', + response: { + approved: 'yes', + kind: 'origin-approval', + }, + }, + }, + { + channel: STALKER_SESSION_CONTINUE, + request: { + challengeRef: 'opaque-challenge', + response: { + kind: 'credentials', + password: 'allowed-challenge-password', + token: 'raw-continuation-token', + username: 'allowed-challenge-user', + }, + }, + }, + { + channel: STALKER_SESSION_REQUEST, + request: { + leaseRef: 'opaque-lease', + operation: 'handshake', + parameters: {}, + }, + }, + { + channel: STALKER_SESSION_REQUEST, + request: { + leaseRef: 'opaque-lease', + operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems, + parameters: { + token: 'raw-parameter-token', + }, + }, + }, + { + channel: STALKER_SESSION_REQUEST, + request: { + leaseRef: 'opaque-lease', + operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems, + parameters: { + query: { + credentials: { + password: 'raw-nested-secret', + }, + }, + }, + }, + }, + { + channel: STALKER_SESSION_REQUEST, + request: { + headers: { + Authorization: 'Bearer raw-authorization', + }, + leaseRef: 'opaque-lease', + operation: STALKER_SESSION_APPLICATION_OPERATIONS.CatalogItems, + parameters: {}, + }, + }, + { + channel: STALKER_SESSION_CONTROL, + request: { + action: 'commit', + leaseRef: 'wrong-reference-kind', + }, + }, + { + channel: STALKER_SESSION_CONTROL, + request: { + action: 'delete-everything', + leaseRef: 'opaque-lease', + }, + }, + ])( + 'rejects malformed or secret-bearing $channel input before the manager', + async ({ channel, request }) => { + const manager = createManager(); + const sender = createSender(); + const { default: StalkerSessionEvents } = + await import('./stalker-session.events'); + + StalkerSessionEvents.bootstrapStalkerSessionEvents(manager); + + const result = invoke(channel, sender, request); + + await expect(result).rejects.toThrow( + 'stalker-session-invalid-request' + ); + await result.catch((error: unknown) => { + expect(String(error)).not.toMatch( + /raw-(?:top-level-token|descriptor-password|continuation-token|parameter-token|authorization|nested-secret)/ + ); + }); + expect(manager.open).not.toHaveBeenCalled(); + expect(manager.continue).not.toHaveBeenCalled(); + expect(manager.request).not.toHaveBeenCalled(); + expect(manager.control).not.toHaveBeenCalled(); + } + ); + + it.each(['get_profile', 'do_auth'])( + 'rejects raw full-auth operation %s', + async (operation) => { + const manager = createManager(); + const sender = createSender(); + const { default: StalkerSessionEvents } = + await import('./stalker-session.events'); + + StalkerSessionEvents.bootstrapStalkerSessionEvents(manager); + + await expect( + invoke(STALKER_SESSION_REQUEST, sender, { + leaseRef: 'opaque-lease', + operation, + parameters: {}, + }) + ).rejects.toThrow('stalker-session-invalid-request'); + expect(manager.request).not.toHaveBeenCalled(); + } + ); + + it('preserves sender binding for foreign or expired refs and returns the manager outcome unchanged', async () => { + const manager = createManager(); + const sender = createSender(91); + const failure: StalkerSessionRequestOutcome = { + kind: 'failure', + reason: 'invalid-identity-input', + requestId: 'manager-owned-request-id', + retryable: false, + stage: 'ready', + }; + (manager.request as jest.Mock).mockResolvedValueOnce(failure); + const { default: StalkerSessionEvents } = + await import('./stalker-session.events'); + const request = { + leaseRef: 'foreign-or-expired-ref', + operation: STALKER_SESSION_APPLICATION_OPERATIONS.MainInfo, + parameters: {}, + } as const; + + StalkerSessionEvents.bootstrapStalkerSessionEvents(manager); + + await expect( + invoke(STALKER_SESSION_REQUEST, sender, request) + ).resolves.toBe(failure); + expect(manager.request).toHaveBeenCalledWith(91, request); + }); + + it('attaches one destroyed listener per sender and cleans that sender exactly once', async () => { + const manager = createManager(); + const sender = createSender(64); + const { default: StalkerSessionEvents } = + await import('./stalker-session.events'); + + StalkerSessionEvents.bootstrapStalkerSessionEvents(manager); + await invoke(STALKER_SESSION_OPEN, sender, OPEN_REQUEST); + await invoke(STALKER_SESSION_CONTROL, sender, { + action: 'activate', + leaseRef: 'opaque-lease', + }); + + expect(sender.once).toHaveBeenCalledTimes(1); + const destroyed = sender.once.mock.calls[0][1] as () => void; + + destroyed(); + destroyed(); + + expect(manager.cleanupSender).toHaveBeenCalledTimes(1); + expect(manager.cleanupSender).toHaveBeenCalledWith(64); + }); + + it('does not expose manager error details to the renderer', async () => { + const manager = createManager(); + const sender = createSender(); + const secret = 'raw-manager-token'; + (manager.open as jest.Mock).mockRejectedValueOnce( + new Error(`portal failed with ${secret}`) + ); + const { default: StalkerSessionEvents } = + await import('./stalker-session.events'); + + StalkerSessionEvents.bootstrapStalkerSessionEvents(manager); + + const result = invoke(STALKER_SESSION_OPEN, sender, OPEN_REQUEST); + + await expect(result).rejects.toThrow( + 'stalker-session-operation-failed' + ); + await result.catch((error: unknown) => { + expect(String(error)).not.toContain(secret); + }); + }); +}); diff --git a/apps/electron-backend/src/app/events/stalker-session.events.ts b/apps/electron-backend/src/app/events/stalker-session.events.ts new file mode 100644 index 000000000..0ad378a94 --- /dev/null +++ b/apps/electron-backend/src/app/events/stalker-session.events.ts @@ -0,0 +1,356 @@ +import { ipcMain } from 'electron'; +import { + STALKER_MANAGED_REQUEST_HEADERS, + STALKER_RESERVED_REQUEST_PARAMETERS, + validateStalkerApplicationRequest, +} from '@iptvnator/portal/stalker/protocol'; +import { + STALKER_SESSION_CONTINUE, + STALKER_SESSION_CONTROL, + STALKER_SESSION_OPEN, + STALKER_SESSION_REQUEST, +} from '@iptvnator/shared/interfaces'; +import type { + StalkerSessionApplicationOperation, + StalkerSessionConnectionOutcome, + StalkerSessionContinueRequest, + StalkerSessionControlOutcome, + StalkerSessionControlRequest, + StalkerSessionOpenRequest, + StalkerSessionRequest, + StalkerSessionRequestOutcome, +} from '@iptvnator/shared/interfaces'; +import { validateStalkerRuntimeInput } from '../services/stalker-session/stalker-runtime-validation'; + +const INVALID_REQUEST_CODE = 'stalker-session-invalid-request'; +const OPERATION_FAILED_CODE = 'stalker-session-operation-failed'; +const REFERENCE_MAX_BYTES = 512; +const CREDENTIAL_MAX_BYTES = 4096; +const PARAMETER_NODE_LIMIT = 256; +const FORBIDDEN_PARAMETER_KEYS = new Set([ + ...STALKER_MANAGED_REQUEST_HEADERS, + ...STALKER_RESERVED_REQUEST_PARAMETERS, + 'credential', + 'credentials', + 'headers', +]); + +type StalkerSessionSender = Pick; +type StalkerSessionInvokeEvent = { + sender: StalkerSessionSender; +}; + +export interface StalkerSessionManagerPort { + open( + senderId: number, + request: StalkerSessionOpenRequest + ): Promise; + continue( + senderId: number, + request: StalkerSessionContinueRequest + ): Promise; + request( + senderId: number, + request: StalkerSessionRequest + ): Promise; + control( + senderId: number, + request: StalkerSessionControlRequest + ): Promise; + cleanupSender(senderId: number): void | Promise; +} + +class StalkerSessionIpcError extends Error { + constructor( + code: typeof INVALID_REQUEST_CODE | typeof OPERATION_FAILED_CODE + ) { + super(code); + this.name = 'StalkerSessionIpcError'; + } +} + +export default class StalkerSessionEvents { + static bootstrapStalkerSessionEvents( + manager: StalkerSessionManagerPort + ): Electron.IpcMain { + const cleanupAttached = new WeakSet(); + + const bindSender = (event: StalkerSessionInvokeEvent): number => { + const sender = event.sender; + if ( + !sender || + !Number.isSafeInteger(sender.id) || + sender.id < 0 || + typeof sender.once !== 'function' + ) { + invalidRequest(); + } + + if (!cleanupAttached.has(sender)) { + cleanupAttached.add(sender); + let cleaned = false; + const senderId = sender.id; + sender.once('destroyed', () => { + if (cleaned) { + return; + } + cleaned = true; + void Promise.resolve(manager.cleanupSender(senderId)).catch( + () => undefined + ); + }); + } + + return sender.id; + }; + + ipcMain.handle(STALKER_SESSION_OPEN, async (event, input: unknown) => { + const senderId = bindSender(event); + return callManager(() => + manager.open(senderId, validateOpenRequest(input)) + ); + }); + ipcMain.handle( + STALKER_SESSION_CONTINUE, + async (event, input: unknown) => { + const senderId = bindSender(event); + return callManager(() => + manager.continue(senderId, validateContinueRequest(input)) + ); + } + ); + ipcMain.handle( + STALKER_SESSION_REQUEST, + async (event, input: unknown) => { + const senderId = bindSender(event); + return callManager(() => + manager.request(senderId, validateApplicationRequest(input)) + ); + } + ); + ipcMain.handle( + STALKER_SESSION_CONTROL, + async (event, input: unknown) => { + const senderId = bindSender(event); + return callManager(() => + manager.control(senderId, validateControlRequest(input)) + ); + } + ); + + return ipcMain; + } +} + +async function callManager( + operation: () => Promise +): Promise { + try { + return await operation(); + } catch (error) { + if (error instanceof StalkerSessionIpcError) { + throw error; + } + throw new StalkerSessionIpcError(OPERATION_FAILED_CODE); + } +} + +function validateOpenRequest(input: unknown): StalkerSessionOpenRequest { + const request = exactRecord(input, ['descriptor']); + try { + return { + descriptor: validateStalkerRuntimeInput(request['descriptor']) + .descriptor, + }; + } catch { + return invalidRequest(); + } +} + +function validateContinueRequest( + input: unknown +): StalkerSessionContinueRequest { + const request = exactRecord(input, ['challengeRef', 'response']); + const challengeRef = opaqueRef(request['challengeRef']); + const response = request['response']; + + if (!isRecord(response) || typeof response['kind'] !== 'string') { + return invalidRequest(); + } + + if (response['kind'] === 'origin-approval') { + const approval = exactRecord(response, ['approved', 'kind']); + if (typeof approval['approved'] !== 'boolean') { + return invalidRequest(); + } + return { + challengeRef, + response: { + approved: approval['approved'], + kind: 'origin-approval', + }, + }; + } + + if (response['kind'] === 'credentials') { + const credentials = exactRecord(response, [ + 'kind', + 'password', + 'username', + ]); + if ( + !boundedString(credentials['username'], CREDENTIAL_MAX_BYTES) || + !boundedString(credentials['password'], CREDENTIAL_MAX_BYTES) + ) { + return invalidRequest(); + } + return { + challengeRef, + response: { + kind: 'credentials', + password: credentials['password'], + username: credentials['username'], + }, + }; + } + + return invalidRequest(); +} + +function validateApplicationRequest(input: unknown): StalkerSessionRequest { + const request = exactRecord(input, ['leaseRef', 'operation', 'parameters']); + const operation = request['operation']; + const parameters = request['parameters']; + + if ( + typeof operation !== 'string' || + !isRecord(parameters) || + containsForbiddenParameter(parameters) || + validateStalkerApplicationRequest({ + operation, + parameters, + }).kind !== 'accepted' + ) { + return invalidRequest(); + } + + const typedOperation = operation as StalkerSessionApplicationOperation; + return { + leaseRef: opaqueRef(request['leaseRef']), + operation: typedOperation, + parameters, + } as StalkerSessionRequest; +} + +function validateControlRequest(input: unknown): StalkerSessionControlRequest { + if (!isRecord(input) || typeof input['action'] !== 'string') { + return invalidRequest(); + } + + switch (input['action']) { + case 'activate': + case 'deactivate': + case 'close': + case 'force-redetect': { + const request = exactRecord(input, ['action', 'leaseRef']); + return { + action: input['action'], + leaseRef: opaqueRef(request['leaseRef']), + }; + } + case 'commit': + case 'discard': { + const request = exactRecord(input, ['action', 'attemptRef']); + return { + action: input['action'], + attemptRef: opaqueRef(request['attemptRef']), + }; + } + default: + return invalidRequest(); + } +} + +function containsForbiddenParameter(value: unknown): boolean { + const pending = [value]; + const visited = new WeakSet(); + let nodes = 0; + + while (pending.length > 0) { + const current = pending.pop(); + if (typeof current !== 'object' || current === null) { + continue; + } + if (visited.has(current)) { + continue; + } + visited.add(current); + + const entries = Object.entries(current); + nodes += entries.length; + if (nodes > PARAMETER_NODE_LIMIT) { + return true; + } + for (const [key, child] of entries) { + if (FORBIDDEN_PARAMETER_KEYS.has(key.toLowerCase())) { + return true; + } + pending.push(child); + } + } + + return false; +} + +function exactRecord( + input: unknown, + expectedKeys: readonly string[] +): Readonly> { + if (!isRecord(input)) { + return invalidRequest(); + } + const keys = Object.keys(input); + if ( + keys.length !== expectedKeys.length || + expectedKeys.some( + (key) => !Object.prototype.hasOwnProperty.call(input, key) + ) || + keys.some((key) => !expectedKeys.includes(key)) + ) { + return invalidRequest(); + } + return input; +} + +function opaqueRef(value: unknown): string { + if (!nonEmptyBoundedString(value, REFERENCE_MAX_BYTES)) { + return invalidRequest(); + } + return value; +} + +function isRecord(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function boundedString(value: unknown, maxBytes: number): value is string { + return ( + typeof value === 'string' && + Buffer.byteLength(value, 'utf8') <= maxBytes + ); +} + +function nonEmptyBoundedString( + value: unknown, + maxBytes: number +): value is string { + return boundedString(value, maxBytes) && value.trim().length > 0; +} + +function invalidRequest(): never { + throw new StalkerSessionIpcError(INVALID_REQUEST_CODE); +}