feat(stalker): validate portal transport inputs

This commit is contained in:
4gray committed 2026-07-27 09:48:19 +02:00
1 parent d0cf96caf8
commit 13f05d0a7e
5 files changed
+927

No files matched your search

@@ -0,0 +1,229 @@
import {
STALKER_RUNTIME_LIMITS,
STALKER_TRANSPORT_DEFAULTS,
} from './stalker-session.types';
import {
StalkerRuntimeValidationError,
validateStalkerRuntimeInput,
} from './stalker-runtime-validation';
const BASE_DESCRIPTOR = {
connectionMode: 'persisted-open',
macAddress: '00-1a-79-00-00-01',
playlistRef: 'playlist-1',
profilePreset: {
id: 'mag250-public-5_1-minimal-v1',
version: 1,
},
sourceUrl: 'https://portal.example/c/#discarded-fragment',
} as const;
function validationError(
descriptor: unknown,
transport: unknown = STALKER_TRANSPORT_DEFAULTS
): StalkerRuntimeValidationError {
try {
validateStalkerRuntimeInput(descriptor, transport);
} catch (error) {
if (error instanceof StalkerRuntimeValidationError) {
return error;
}
throw error;
}
throw new Error('Expected runtime validation to fail.');
}
describe('validateStalkerRuntimeInput', () => {
it('normalizes URLs and MAC while preserving bounded explicit profile values', () => {
const result = validateStalkerRuntimeInput(
{
...BASE_DESCRIPTOR,
identityOverrides: {
deviceId1: 'Device-One',
serialNumber: 'Serial-AbC',
},
learnedEndpointHint:
'https://portal.example/server/load.php#old',
transportConfiguration: {
locale: 'de-DE',
timezone: 'Europe/Berlin',
userAgent: 'Synthetic Client/1.0',
},
},
{
maxResponseBytes: 1_048_576,
timeoutMs: 12_000,
}
);
expect(result).toEqual({
descriptor: {
...BASE_DESCRIPTOR,
identityOverrides: {
deviceId1: 'Device-One',
serialNumber: 'Serial-AbC',
},
learnedEndpointHint:
'https://portal.example/server/load.php',
macAddress: '00:1A:79:00:00:01',
sourceUrl: 'https://portal.example/c/',
transportConfiguration: {
locale: 'de-DE',
timezone: 'Europe/Berlin',
userAgent: 'Synthetic Client/1.0',
},
},
transport: {
maxResponseBytes: 1_048_576,
timeoutMs: 12_000,
},
});
});
it('uses finite safe transport defaults and omits blank optional strings', () => {
const result = validateStalkerRuntimeInput({
...BASE_DESCRIPTOR,
identityOverrides: {
deviceId1: ' ',
serialNumber: '',
},
transportConfiguration: {
locale: '',
timezone: ' ',
},
});
expect(result.transport).toEqual(STALKER_TRANSPORT_DEFAULTS);
expect(result.descriptor).not.toHaveProperty('identityOverrides');
expect(result.descriptor).not.toHaveProperty(
'transportConfiguration'
);
});
it.each([
'https://user:private-password@portal.example/c/',
'https://portal.example/c/?username=private-account',
'https://portal.example/c/?token=private-token',
'https://portal.example/c/?mac=00%3A1A%3A79%3A00%3A00%3A01',
'ftp://portal.example/c/',
])('rejects unsafe source URLs without echoing their value', (sourceUrl) => {
const error = validationError({ ...BASE_DESCRIPTOR, sourceUrl });
expect(error).toMatchObject({
message: 'invalid-url',
reason: 'invalid-url',
});
expect(JSON.stringify(error)).not.toContain(sourceUrl);
expect(error.message).not.toContain(sourceUrl);
});
it('applies the same URL rules to a learned endpoint hint', () => {
const learnedEndpointHint =
'https://portal.example/server/load.php?password=private-password';
const error = validationError({
...BASE_DESCRIPTOR,
learnedEndpointHint,
});
expect(error.reason).toBe('invalid-url');
expect(error.message).toBe('invalid-url');
expect(JSON.stringify(error)).not.toContain('private-password');
});
it.each([
'001A79000001',
'00:1A:79:00:00',
'GG:1A:79:00:00:01',
])('rejects invalid MAC input without echoing it', (macAddress) => {
const error = validationError({ ...BASE_DESCRIPTOR, macAddress });
expect(error).toMatchObject({
message: 'invalid-identity-input',
reason: 'invalid-identity-input',
});
expect(error.message).not.toContain(macAddress);
expect(JSON.stringify(error)).not.toContain(macAddress);
});
it.each([
['playlistRef', 'x'.repeat(STALKER_RUNTIME_LIMITS.playlistRefBytes + 1)],
[
'sourceUrl',
`https://portal.example/${'x'.repeat(
STALKER_RUNTIME_LIMITS.urlBytes
)}`,
],
[
'learnedEndpointHint',
`https://portal.example/${'x'.repeat(
STALKER_RUNTIME_LIMITS.urlBytes
)}`,
],
])('rejects an oversized %s', (field, value) => {
const error = validationError({
...BASE_DESCRIPTOR,
[field]: value,
});
expect(error.reason).toBe(
field === 'sourceUrl' || field === 'learnedEndpointHint'
? 'invalid-url'
: 'invalid-identity-input'
);
expect(error.message).not.toContain(value);
});
it('bounds profile and transport strings and rejects unknown secret fields', () => {
const profileValue = 'p'.repeat(
STALKER_RUNTIME_LIMITS.profileStringBytes + 1
);
const transportValue = 't'.repeat(
STALKER_RUNTIME_LIMITS.transportStringBytes + 1
);
expect(
validationError({
...BASE_DESCRIPTOR,
identityOverrides: { serialNumber: profileValue },
}).reason
).toBe('invalid-identity-input');
expect(
validationError({
...BASE_DESCRIPTOR,
transportConfiguration: { userAgent: transportValue },
}).reason
).toBe('invalid-identity-input');
const secret = 'private-password';
const unknownFieldError = validationError({
...BASE_DESCRIPTOR,
password: secret,
});
expect(unknownFieldError.reason).toBe('invalid-identity-input');
expect(JSON.stringify(unknownFieldError)).not.toContain(secret);
});
it.each([
['timeoutMs', 0],
['timeoutMs', -1],
['timeoutMs', Number.NaN],
['timeoutMs', Number.POSITIVE_INFINITY],
['timeoutMs', STALKER_RUNTIME_LIMITS.timeoutMs + 1],
['maxResponseBytes', 0],
['maxResponseBytes', -1],
['maxResponseBytes', Number.NaN],
['maxResponseBytes', Number.POSITIVE_INFINITY],
[
'maxResponseBytes',
STALKER_RUNTIME_LIMITS.maxResponseBytes + 1,
],
])('rejects invalid transport setting %s=%s', (field, value) => {
const error = validationError(BASE_DESCRIPTOR, {
...STALKER_TRANSPORT_DEFAULTS,
[field]: value,
});
expect(error.reason).toBe('invalid-identity-input');
expect(error.message).toBe('invalid-identity-input');
});
});
@@ -0,0 +1,321 @@
import {
STALKER_FAILURE_REASONS,
normalizeStalkerMacAddress,
normalizeStalkerSourceUrl,
} from '@iptvnator/portal/stalker/protocol';
import {
STALKER_SESSION_CONNECTION_MODES,
STALKER_SESSION_PROVISIONAL_REASONS,
} from '@iptvnator/shared/interfaces';
import type {
StalkerSessionConnectionDescriptor,
StalkerSessionIdentityOverrides,
StalkerSessionProfilePreset,
StalkerSessionProvisionalReason,
StalkerSessionTransportConfiguration,
} from '@iptvnator/shared/interfaces';
import {
STALKER_RUNTIME_LIMITS,
STALKER_TRANSPORT_DEFAULTS,
} from './stalker-session.types';
import type {
StalkerTransportConfig,
StalkerValidatedRuntimeInput,
} from './stalker-session.types';
const DESCRIPTOR_KEYS = new Set([
'connectionMode',
'identityOverrides',
'learnedEndpointHint',
'macAddress',
'playlistRef',
'profilePreset',
'provisionalReason',
'sourceUrl',
'transportConfiguration',
]);
const PROFILE_PRESET_KEYS = new Set(['id', 'version']);
const IDENTITY_OVERRIDE_KEYS = [
'apiSignature',
'deviceId1',
'deviceId2',
'firmwareVersion',
'hardwareVersion',
'hardwareVersion2',
'imageVersion',
'numberOfBanks',
'prehash',
'serialNumber',
'signature1',
'signature2',
'videoOutput',
] as const satisfies readonly (keyof StalkerSessionIdentityOverrides)[];
const TRANSPORT_CONFIGURATION_KEYS = [
'language',
'locale',
'origin',
'originPolicy',
'referer',
'refererPolicy',
'timezone',
'userAgent',
'xUserAgent',
] as const satisfies readonly (keyof StalkerSessionTransportConfiguration)[];
const TRANSPORT_KEYS = new Set(['maxResponseBytes', 'timeoutMs']);
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
export type StalkerRuntimeValidationFailureReason =
| typeof STALKER_FAILURE_REASONS.InvalidIdentityInput
| typeof STALKER_FAILURE_REASONS.InvalidUrl;
export class StalkerRuntimeValidationError extends Error {
constructor(readonly reason: StalkerRuntimeValidationFailureReason) {
super(reason);
this.name = 'StalkerRuntimeValidationError';
}
}
function fail(reason: StalkerRuntimeValidationFailureReason): never {
throw new StalkerRuntimeValidationError(reason);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function hasOnlyKeys(
value: Readonly<Record<string, unknown>>,
allowedKeys: ReadonlySet<string>
): boolean {
return Object.keys(value).every((key) => allowedKeys.has(key));
}
function isBoundedString(value: string, maxBytes: number): boolean {
return (
Buffer.byteLength(value, 'utf8') <= maxBytes &&
!CONTROL_CHARACTERS.test(value)
);
}
function requiredString(value: unknown, maxBytes: number): string {
if (
typeof value !== 'string' ||
value.trim().length === 0 ||
!isBoundedString(value, maxBytes)
) {
return fail(STALKER_FAILURE_REASONS.InvalidIdentityInput);
}
return value;
}
function normalizeUrl(value: unknown, optional: boolean): string | undefined {
if (optional && (value === undefined || value === null || value === '')) {
return undefined;
}
if (
typeof value !== 'string' ||
value.trim().length === 0 ||
!isBoundedString(value, STALKER_RUNTIME_LIMITS.urlBytes)
) {
return fail(STALKER_FAILURE_REASONS.InvalidUrl);
}
try {
const normalized = normalizeStalkerSourceUrl(value);
if (!isBoundedString(normalized, STALKER_RUNTIME_LIMITS.urlBytes)) {
return fail(STALKER_FAILURE_REASONS.InvalidUrl);
}
return normalized;
} catch {
return fail(STALKER_FAILURE_REASONS.InvalidUrl);
}
}
function normalizeMacAddress(value: unknown): string {
if (typeof value !== 'string' || value.length > 32) {
return fail(STALKER_FAILURE_REASONS.InvalidIdentityInput);
}
try {
return normalizeStalkerMacAddress(value.trim());
} catch {
return fail(STALKER_FAILURE_REASONS.InvalidIdentityInput);
}
}
function normalizeProfilePreset(value: unknown): StalkerSessionProfilePreset {
if (
!isRecord(value) ||
!hasOnlyKeys(value, PROFILE_PRESET_KEYS) ||
value['id'] !== 'mag250-public-5_1-minimal-v1' ||
value['version'] !== 1
) {
return fail(STALKER_FAILURE_REASONS.InvalidIdentityInput);
}
return {
id: 'mag250-public-5_1-minimal-v1',
version: 1,
};
}
function normalizeOptionalStringRecord<Key extends string>(
value: unknown,
keys: readonly Key[],
maxBytes: number
): Partial<Record<Key, string>> | undefined {
if (value === undefined) {
return undefined;
}
if (!isRecord(value) || !hasOnlyKeys(value, new Set(keys))) {
return fail(STALKER_FAILURE_REASONS.InvalidIdentityInput);
}
const normalized: Partial<Record<Key, string>> = {};
for (const key of keys) {
const field = value[key];
if (field === undefined) {
continue;
}
if (typeof field !== 'string') {
return fail(STALKER_FAILURE_REASONS.InvalidIdentityInput);
}
if (field.trim().length === 0) {
continue;
}
if (!isBoundedString(field, maxBytes)) {
return fail(STALKER_FAILURE_REASONS.InvalidIdentityInput);
}
normalized[key] = field;
}
return Object.keys(normalized).length === 0 ? undefined : normalized;
}
function normalizeIdentityOverrides(
value: unknown
): StalkerSessionIdentityOverrides | undefined {
return normalizeOptionalStringRecord(
value,
IDENTITY_OVERRIDE_KEYS,
STALKER_RUNTIME_LIMITS.profileStringBytes
);
}
function normalizeTransportConfiguration(
value: unknown
): StalkerSessionTransportConfiguration | undefined {
return normalizeOptionalStringRecord(
value,
TRANSPORT_CONFIGURATION_KEYS,
STALKER_RUNTIME_LIMITS.transportStringBytes
);
}
function normalizeProvisionalReason(
connectionMode: unknown,
provisionalReason: unknown
): StalkerSessionProvisionalReason | undefined {
if (connectionMode === STALKER_SESSION_CONNECTION_MODES.PersistedOpen) {
if (provisionalReason !== undefined) {
return fail(STALKER_FAILURE_REASONS.InvalidIdentityInput);
}
return undefined;
}
if (connectionMode !== STALKER_SESSION_CONNECTION_MODES.Provisional) {
return fail(STALKER_FAILURE_REASONS.InvalidIdentityInput);
}
const allowedReasons = Object.values(
STALKER_SESSION_PROVISIONAL_REASONS
) as readonly unknown[];
if (!allowedReasons.includes(provisionalReason)) {
return fail(STALKER_FAILURE_REASONS.InvalidIdentityInput);
}
return provisionalReason as StalkerSessionProvisionalReason;
}
function normalizeDescriptor(
value: unknown
): StalkerSessionConnectionDescriptor {
if (
!isRecord(value) ||
!hasOnlyKeys(value, DESCRIPTOR_KEYS)
) {
return fail(STALKER_FAILURE_REASONS.InvalidIdentityInput);
}
const provisionalReason = normalizeProvisionalReason(
value['connectionMode'],
value['provisionalReason']
);
const identityOverrides = normalizeIdentityOverrides(
value['identityOverrides']
);
const transportConfiguration = normalizeTransportConfiguration(
value['transportConfiguration']
);
const learnedEndpointHint = normalizeUrl(
value['learnedEndpointHint'],
true
);
const base = {
connectionMode: value['connectionMode'],
macAddress: normalizeMacAddress(value['macAddress']),
playlistRef: requiredString(
value['playlistRef'],
STALKER_RUNTIME_LIMITS.playlistRefBytes
).trim(),
profilePreset: normalizeProfilePreset(value['profilePreset']),
sourceUrl: normalizeUrl(value['sourceUrl'], false) as string,
...(learnedEndpointHint === undefined
? {}
: { learnedEndpointHint }),
...(identityOverrides === undefined ? {} : { identityOverrides }),
...(transportConfiguration === undefined
? {}
: { transportConfiguration }),
};
return provisionalReason === undefined
? {
...base,
connectionMode: STALKER_SESSION_CONNECTION_MODES.PersistedOpen,
}
: {
...base,
connectionMode: STALKER_SESSION_CONNECTION_MODES.Provisional,
provisionalReason,
};
}
function normalizeTransport(value: unknown): StalkerTransportConfig {
const source = value ?? STALKER_TRANSPORT_DEFAULTS;
if (!isRecord(source) || !hasOnlyKeys(source, TRANSPORT_KEYS)) {
return fail(STALKER_FAILURE_REASONS.InvalidIdentityInput);
}
const timeoutMs = source['timeoutMs'];
const maxResponseBytes = source['maxResponseBytes'];
if (
typeof timeoutMs !== 'number' ||
!Number.isFinite(timeoutMs) ||
timeoutMs <= 0 ||
timeoutMs > STALKER_RUNTIME_LIMITS.timeoutMs ||
typeof maxResponseBytes !== 'number' ||
!Number.isFinite(maxResponseBytes) ||
maxResponseBytes <= 0 ||
maxResponseBytes > STALKER_RUNTIME_LIMITS.maxResponseBytes
) {
return fail(STALKER_FAILURE_REASONS.InvalidIdentityInput);
}
return { maxResponseBytes, timeoutMs };
}
export function validateStalkerRuntimeInput(
descriptor: unknown,
transport?: unknown
): StalkerValidatedRuntimeInput {
return {
descriptor: normalizeDescriptor(descriptor),
transport: normalizeTransport(transport),
};
}
@@ -0,0 +1,54 @@
import { STALKER_FAILURE_REASONS } from '@iptvnator/portal/stalker/protocol';
import type {
StalkerSessionConnectionDescriptor,
StalkerSessionFailureReason,
} from '@iptvnator/shared/interfaces';
export const STALKER_RUNTIME_LIMITS = {
maxResponseBytes: 16 * 1024 * 1024,
playlistRefBytes: 256,
profileStringBytes: 2048,
timeoutMs: 120_000,
transportStringBytes: 4096,
urlBytes: 4096,
} as const;
export interface StalkerTransportConfig {
readonly maxResponseBytes: number;
readonly timeoutMs: number;
}
export const STALKER_TRANSPORT_DEFAULTS = {
maxResponseBytes: 4 * 1024 * 1024,
timeoutMs: 15_000,
} as const satisfies StalkerTransportConfig;
export interface StalkerValidatedRuntimeInput {
readonly descriptor: StalkerSessionConnectionDescriptor;
readonly transport: StalkerTransportConfig;
}
export interface StalkerTransportResult<T> {
readonly body: T;
readonly finalUrl: string;
readonly status: number;
}
export const STALKER_TRANSPORT_FAILURE_REASONS = {
DnsFailure: STALKER_FAILURE_REASONS.DnsFailure,
InvalidIdentityInput: STALKER_FAILURE_REASONS.InvalidIdentityInput,
InvalidUrl: STALKER_FAILURE_REASONS.InvalidUrl,
NetworkUnreachable: STALKER_FAILURE_REASONS.NetworkUnreachable,
RedirectLoop: STALKER_FAILURE_REASONS.RedirectLoop,
RequestTimeout: STALKER_FAILURE_REASONS.RequestTimeout,
ResponseTooLarge: STALKER_FAILURE_REASONS.ResponseTooLarge,
TlsFailure: STALKER_FAILURE_REASONS.TlsFailure,
} as const satisfies Readonly<Record<string, StalkerSessionFailureReason>>;
export type StalkerTransportFailureReason =
(typeof STALKER_TRANSPORT_FAILURE_REASONS)[keyof typeof STALKER_TRANSPORT_FAILURE_REASONS];
export interface StalkerTransportError {
readonly reason: StalkerTransportFailureReason;
readonly retryable: boolean;
}
@@ -0,0 +1,164 @@
import { StalkerProtocolInputError } from '@iptvnator/portal/stalker/protocol';
import { UnsafeUrlError } from '../../events/url-safety';
import { StalkerRuntimeValidationError } from './stalker-runtime-validation';
import {
STALKER_TRANSPORT_CAUSE_LIMIT,
mapStalkerTransportError,
} from './stalker-transport-errors';
function errorWithCode(code: string, message = 'synthetic failure'): Error {
return Object.assign(new Error(message), { code });
}
function axiosError(input: {
code?: string;
message?: string;
response?: { status: number };
}): Error {
return Object.assign(new Error(input.message ?? 'synthetic Axios failure'), {
code: input.code,
config: {
headers: { authorization: 'private-bearer' },
url: 'https://portal.example/c/?token=private-token',
},
isAxiosError: true,
response: input.response,
});
}
describe('mapStalkerTransportError', () => {
it.each([
['ENOTFOUND', 'dns-failure', true],
['EAI_AGAIN', 'dns-failure', true],
['ENODATA', 'dns-failure', true],
['ECONNABORTED', 'request-timeout', true],
['ETIMEDOUT', 'request-timeout', true],
['UND_ERR_CONNECT_TIMEOUT', 'request-timeout', true],
['ECONNREFUSED', 'network-unreachable', true],
['ECONNRESET', 'network-unreachable', true],
['ENETUNREACH', 'network-unreachable', true],
['EHOSTUNREACH', 'network-unreachable', true],
['EPIPE', 'network-unreachable', true],
['EADDRNOTAVAIL', 'network-unreachable', true],
])('maps %s to %s', (code, reason, retryable) => {
expect(mapStalkerTransportError(errorWithCode(code))).toEqual({
reason,
retryable,
});
});
it.each([
errorWithCode('CERT_HAS_EXPIRED'),
errorWithCode('ERR_TLS_CERT_ALTNAME_INVALID'),
errorWithCode('ERR_TLS_HANDSHAKE_TIMEOUT'),
errorWithCode('ERR_SSL_PROTOCOL_ERROR'),
new Error('self-signed certificate in certificate chain'),
])('maps TLS evidence without returning its raw details', (error) => {
expect(mapStalkerTransportError(error)).toEqual({
reason: 'tls-failure',
retryable: false,
});
});
it('maps an Axios maxContentLength failure before generic no-response handling', () => {
expect(
mapStalkerTransportError(
axiosError({
code: 'ERR_BAD_RESPONSE',
message:
'maxContentLength size of private-limit exceeded',
})
)
).toEqual({
reason: 'response-too-large',
retryable: false,
});
});
it('maps an Axios network error only when no HTTP response exists', () => {
expect(
mapStalkerTransportError(axiosError({ code: 'ERR_NETWORK' }))
).toEqual({
reason: 'network-unreachable',
retryable: true,
});
expect(
mapStalkerTransportError(
axiosError({
code: 'ERR_BAD_RESPONSE',
response: { status: 503 },
})
)
).toBeUndefined();
});
it.each([
new UnsafeUrlError('Redirect loop detected', 502),
new UnsafeUrlError('Too many redirects', 502),
])('maps redirect loop and ceiling failures', (error) => {
expect(mapStalkerTransportError(error)).toEqual({
reason: 'redirect-loop',
retryable: false,
});
});
it('maps other URL/input failures without echoing input evidence', () => {
const secret = 'private-password';
const errors = [
new UnsafeUrlError(`Invalid URL ${secret}`),
new StalkerProtocolInputError('invalid-url'),
new StalkerRuntimeValidationError('invalid-url'),
new StalkerRuntimeValidationError('invalid-identity-input'),
];
const mapped = errors.map((error) =>
mapStalkerTransportError(error)
);
expect(mapped).toEqual([
{ reason: 'invalid-url', retryable: false },
{ reason: 'invalid-url', retryable: false },
{ reason: 'invalid-url', retryable: false },
{ reason: 'invalid-identity-input', retryable: false },
]);
expect(JSON.stringify(mapped)).not.toContain(secret);
});
it('walks a bounded cause chain and handles cycles', () => {
expect(
mapStalkerTransportError({
cause: { cause: errorWithCode('ENOTFOUND') },
})
).toEqual({
reason: 'dns-failure',
retryable: true,
});
let tooDeep: unknown = errorWithCode('ENOTFOUND');
for (let index = 0; index < STALKER_TRANSPORT_CAUSE_LIMIT; index += 1) {
tooDeep = { cause: tooDeep };
}
expect(mapStalkerTransportError(tooDeep)).toBeUndefined();
const cyclic: { cause?: unknown } = {};
cyclic.cause = cyclic;
expect(mapStalkerTransportError(cyclic)).toBeUndefined();
});
it('returns only stable reason/retryable fields and never Axios secrets', () => {
const mapped = mapStalkerTransportError(
axiosError({
code: 'ENOTFOUND',
message: 'private-password private-token',
})
);
expect(Object.keys(mapped ?? {}).sort()).toEqual([
'reason',
'retryable',
]);
expect(JSON.stringify(mapped)).not.toMatch(
/private-password|private-token|authorization|headers|config|url/i
);
});
});
@@ -0,0 +1,159 @@
import axios from 'axios';
import {
STALKER_FAILURE_REASONS,
StalkerProtocolInputError,
} from '@iptvnator/portal/stalker/protocol';
import { UnsafeUrlError } from '../../events/url-safety';
import { isInvalidTlsCertificateError } from '../../util/security-errors';
import { StalkerRuntimeValidationError } from './stalker-runtime-validation';
import type { StalkerTransportError } from './stalker-session.types';
export const STALKER_TRANSPORT_CAUSE_LIMIT = 5;
const DNS_ERROR_CODES = new Set(['EAI_AGAIN', 'ENODATA', 'ENOTFOUND']);
const TIMEOUT_ERROR_CODES = new Set([
'ECONNABORTED',
'ETIMEDOUT',
'UND_ERR_CONNECT_TIMEOUT',
]);
const NETWORK_ERROR_CODES = new Set([
'EADDRNOTAVAIL',
'ECONNREFUSED',
'ECONNRESET',
'EHOSTUNREACH',
'ENETUNREACH',
'EPIPE',
'HOSTUNREACH',
'NETUNREACH',
]);
const RESPONSE_TOO_LARGE =
/maxcontentlength|content length[^]*exceed|size[^]*exceed/i;
const REDIRECT_LOOP = /redirect loop|too many redirects/i;
function readProperty(value: unknown, property: string): unknown {
return typeof value === 'object' && value !== null
? Reflect.get(value, property)
: undefined;
}
function causeChain(error: unknown): readonly unknown[] {
const chain: unknown[] = [];
const seen = new Set<object>();
let current: unknown = error;
while (chain.length < STALKER_TRANSPORT_CAUSE_LIMIT) {
chain.push(current);
if (typeof current !== 'object' || current === null) {
break;
}
if (seen.has(current)) {
break;
}
seen.add(current);
const cause = readProperty(current, 'cause');
if (cause === undefined) {
break;
}
current = cause;
}
return chain;
}
function normalizedCode(value: unknown): string | undefined {
const code = readProperty(value, 'code');
return typeof code === 'string' ? code.toUpperCase() : undefined;
}
function isTlsCode(code: string | undefined): boolean {
return (
code !== undefined &&
(code.includes('CERT') ||
code.startsWith('ERR_SSL_') ||
code.startsWith('ERR_TLS_'))
);
}
function transportError(
reason: StalkerTransportError['reason'],
retryable: boolean
): StalkerTransportError {
return { reason, retryable };
}
export function mapStalkerTransportError(
error: unknown
): StalkerTransportError | undefined {
if (
axios.isAxiosError(error) &&
error.response !== undefined &&
!(
error.code === 'ERR_BAD_RESPONSE' &&
RESPONSE_TOO_LARGE.test(error.message)
)
) {
return undefined;
}
const chain = causeChain(error);
for (const current of chain) {
if (current instanceof StalkerRuntimeValidationError) {
return transportError(current.reason, false);
}
if (current instanceof StalkerProtocolInputError) {
return transportError(STALKER_FAILURE_REASONS.InvalidUrl, false);
}
if (current instanceof UnsafeUrlError) {
return transportError(
REDIRECT_LOOP.test(current.message)
? STALKER_FAILURE_REASONS.RedirectLoop
: STALKER_FAILURE_REASONS.InvalidUrl,
false
);
}
}
if (
axios.isAxiosError(error) &&
error.code === 'ERR_BAD_RESPONSE' &&
RESPONSE_TOO_LARGE.test(error.message)
) {
return transportError(
STALKER_FAILURE_REASONS.ResponseTooLarge,
false
);
}
for (const current of chain) {
const code = normalizedCode(current);
if (DNS_ERROR_CODES.has(code ?? '')) {
return transportError(STALKER_FAILURE_REASONS.DnsFailure, true);
}
if (TIMEOUT_ERROR_CODES.has(code ?? '')) {
return transportError(
STALKER_FAILURE_REASONS.RequestTimeout,
true
);
}
if (
isTlsCode(code) ||
(current instanceof Error &&
isInvalidTlsCertificateError(current))
) {
return transportError(STALKER_FAILURE_REASONS.TlsFailure, false);
}
if (NETWORK_ERROR_CODES.has(code ?? '')) {
return transportError(
STALKER_FAILURE_REASONS.NetworkUnreachable,
true
);
}
}
if (axios.isAxiosError(error) && error.response === undefined) {
return transportError(
STALKER_FAILURE_REASONS.NetworkUnreachable,
true
);
}
return undefined;
}