fix(epg): harden compressed stream decoding

This commit is contained in:
4gray committed 2026-06-20 23:41:53 +02:00
1 parent 9c144e1a03
commit 1520f76f46
4 files changed
+93 -34

No files matched your search

@@ -107,4 +107,12 @@ describe('getEpgResponseContentEncoding', () => {
})
).toBe(null);
});
it('returns the outermost supported transfer encoding first', () => {
expect(
getEpgResponseContentEncoding({
'content-encoding': 'gzip, br',
})
).toBe('br');
});
});
@@ -6,6 +6,12 @@ export type HeaderReader =
export type EpgResponseContentEncoding = 'br' | 'gzip' | 'deflate';
const SUPPORTED_CONTENT_ENCODINGS: readonly EpgResponseContentEncoding[] = [
'br',
'gzip',
'deflate',
];
function getHeaderValue(headers: HeaderReader, name: string): string | null {
const value =
'get' in headers && typeof headers.get === 'function'
@@ -32,17 +38,28 @@ function hasGzipPath(url: string | null | undefined): boolean {
export function getEpgResponseContentEncoding(
headers: HeaderReader
): EpgResponseContentEncoding | null {
const contentEncoding = getHeaderValue(headers, 'content-encoding')
?.toLowerCase()
.split(',')
.map((encoding) => encoding.trim())
.find((encoding): encoding is EpgResponseContentEncoding =>
['br', 'gzip', 'deflate'].includes(encoding)
);
const contentEncoding = getEpgResponseContentEncodings(headers).at(0);
return contentEncoding ?? null;
}
export function getEpgResponseContentEncodings(
headers: HeaderReader
): EpgResponseContentEncoding[] {
return (
getHeaderValue(headers, 'content-encoding')
?.toLowerCase()
.split(',')
.map((encoding) => encoding.trim())
.filter((encoding): encoding is EpgResponseContentEncoding =>
SUPPORTED_CONTENT_ENCODINGS.includes(
encoding as EpgResponseContentEncoding
)
)
.reverse() ?? []
);
}
/**
* Detect whether an EPG response should be gunzipped.
* Some providers redirect plain-looking URLs to a `.gz` payload.
@@ -1,4 +1,4 @@
import { Readable } from 'stream';
import { PassThrough, Readable } from 'stream';
import { brotliCompressSync, gzipSync } from 'zlib';
import { createDecodedEpgStream } from './epg-stream-decoder';
import { StreamingEpgParser } from './epg-streaming-parser';
@@ -69,11 +69,26 @@ describe('createDecodedEpgStream', () => {
});
it('decodes gzip XML payloads after transfer decoding', async () => {
const transferEncodedPayload = gzipSync(gzipSync(xmltvFixture));
const transferEncodedPayload = brotliCompressSync(
gzipSync(xmltvFixture)
);
const decodedText = await collectDecodedText(
createDecodedEpgStream(
Readable.from([transferEncodedPayload]),
{ 'content-encoding': 'br' },
true
)
);
expect(decodedText.startsWith('<?xml')).toBe(true);
expect(parseChannelCount(decodedText)).toBe(1);
});
it('does not double-gunzip mislabelled gzip XML payloads', async () => {
const decodedText = await collectDecodedText(
createDecodedEpgStream(
Readable.from([gzipSync(xmltvFixture)]),
{ 'content-encoding': 'gzip' },
true
)
@@ -82,4 +97,34 @@ describe('createDecodedEpgStream', () => {
expect(decodedText.startsWith('<?xml')).toBe(true);
expect(parseChannelCount(decodedText)).toBe(1);
});
it('decodes multi-value content-encoding in reverse order', async () => {
const encodedPayload = brotliCompressSync(gzipSync(xmltvFixture));
const decodedText = await collectDecodedText(
createDecodedEpgStream(
Readable.from([encodedPayload]),
{ 'content-encoding': 'gzip, br' },
false
)
);
expect(decodedText.startsWith('<?xml')).toBe(true);
expect(parseChannelCount(decodedText)).toBe(1);
});
it('destroys the source stream when a decoder fails', async () => {
const source = new PassThrough();
const decodedStream = createDecodedEpgStream(
source,
{ 'content-encoding': 'gzip' },
false
);
const readPromise = collectDecodedText(decodedStream);
source.end(Buffer.from('not gzip'));
await expect(readPromise).rejects.toThrow();
expect(source.destroyed).toBe(true);
});
});
@@ -1,17 +1,18 @@
import { Readable, Transform } from 'stream';
import { PassThrough, Readable, Transform, pipeline } from 'stream';
import {
createBrotliDecompress,
createGunzip,
createInflate,
} from 'zlib';
import {
getEpgResponseContentEncoding,
EpgResponseContentEncoding,
getEpgResponseContentEncodings,
HeaderReader,
} from './epg-response-utils';
function createContentEncodingDecoder(
contentEncoding: ReturnType<typeof getEpgResponseContentEncoding>
): Transform | null {
contentEncoding: EpgResponseContentEncoding
): Transform {
switch (contentEncoding) {
case 'br':
return createBrotliDecompress();
@@ -19,8 +20,6 @@ function createContentEncodingDecoder(
return createGunzip();
case 'deflate':
return createInflate();
default:
return null;
}
}
@@ -29,16 +28,10 @@ export function createDecodedEpgStream(
headers: HeaderReader,
shouldGunzipPayload: boolean
): Readable {
const transforms: Transform[] = [];
const contentEncoding = getEpgResponseContentEncoding(headers);
const contentEncodingDecoder =
createContentEncodingDecoder(contentEncoding);
const contentEncodings = getEpgResponseContentEncodings(headers);
const transforms = contentEncodings.map(createContentEncodingDecoder);
if (contentEncodingDecoder) {
transforms.push(contentEncodingDecoder);
}
if (shouldGunzipPayload) {
if (shouldGunzipPayload && !contentEncodings.includes('gzip')) {
transforms.push(createGunzip());
}
@@ -46,16 +39,12 @@ export function createDecodedEpgStream(
return source;
}
const decodedStream = transforms.reduce<Readable>(
(stream, transform) => stream.pipe(transform),
source
);
for (const stream of [source, ...transforms]) {
if (stream !== decodedStream) {
stream.on('error', (error) => decodedStream.destroy(error));
const output = new PassThrough();
pipeline([source, ...transforms, output], (error) => {
if (error) {
output.destroy(error);
}
}
});
return decodedStream;
return output;
}