956 lines
30 KiB
TypeScript
956 lines
30 KiB
TypeScript
/*
|
|
* SPDX-License-Identifier: MIT
|
|
*
|
|
* CAB structure parsing and extraction translated from rust-cab 0.6.0.
|
|
* rust-cab commit c5839f5fdfa4c4e7cc9b22f570c79c96af0560e2
|
|
* Copyright (c) 2017 Matthew D. Steele, MIT License.
|
|
* Modified for immutable browser Uint8Array input, strict archive-path handling,
|
|
* explicit resource limits, cancellation, and whole-folder extraction.
|
|
*/
|
|
|
|
import { BinaryReader } from './binary-reader.js';
|
|
import { checkCancellation, yieldForCancellation } from './cancellation.js';
|
|
import { calculateDataBlockChecksum } from './checksum.js';
|
|
import { fail, warning } from './errors.js';
|
|
import { LzxDecoder } from './lzx/decoder.js';
|
|
import { MsZipDecoder } from './mszip/decoder.js';
|
|
import { decodeCabFileName, normalizeCabPath } from './path.js';
|
|
import { QuantumDecoder } from './quantum/decoder.js';
|
|
import {
|
|
resolveLimits,
|
|
type CabCompressionMethod,
|
|
type CabEnumerationResult,
|
|
type CabExtractionResult,
|
|
type CabFileEntry,
|
|
type CabOpenOptions,
|
|
type OnePkgLimits,
|
|
} from './types.js';
|
|
|
|
const CAB_SIGNATURE = 0x4643_534d;
|
|
const FLAG_PREVIOUS_CABINET = 0x0001;
|
|
const FLAG_NEXT_CABINET = 0x0002;
|
|
export const FLAG_RESERVE_PRESENT = 0x0004;
|
|
const ATTRIBUTE_NAME_IS_UTF8 = 0x0080;
|
|
const MAX_CAB_BLOCK_OUTPUT = 32 * 1024;
|
|
const MAX_CAB_BLOCK_INPUT = MAX_CAB_BLOCK_OUTPUT + 6 * 1024;
|
|
const MAX_MSZIP_BLOCK_INPUT = 32 * 1024 + 12;
|
|
export const FOLDER_CONTINUED_FROM_PREVIOUS = 0xfffd;
|
|
export const FOLDER_CONTINUED_TO_NEXT = 0xfffe;
|
|
export const FOLDER_CONTINUED_PREVIOUS_AND_NEXT = 0xffff;
|
|
const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true });
|
|
|
|
export interface CabDataBlockInternal {
|
|
headerOffset: number;
|
|
checksum: number;
|
|
compressedSize: number;
|
|
uncompressedSize: number;
|
|
reserve: Uint8Array;
|
|
dataOffset: number;
|
|
sourceBytes: Uint8Array;
|
|
}
|
|
|
|
export interface CabFolderInternal {
|
|
index: number;
|
|
firstDataBlockOffset: number;
|
|
dataBlockCount: number;
|
|
compression: CabCompressionMethod;
|
|
reserve: Uint8Array;
|
|
blocks: CabDataBlockInternal[];
|
|
compressedSize: number;
|
|
uncompressedSize: number;
|
|
dataEnd: number;
|
|
}
|
|
|
|
export interface CabFileRecordInternal {
|
|
entry: CabFileEntry;
|
|
rawFolderIndex: number;
|
|
duplicateKey: string;
|
|
date: number;
|
|
time: number;
|
|
entryOffset: number;
|
|
}
|
|
|
|
export interface ParsedCabinetInternal {
|
|
bytes: Uint8Array;
|
|
limits: OnePkgLimits;
|
|
flags: number;
|
|
previousCabinetName?: string;
|
|
previousDiskName?: string;
|
|
nextCabinetName?: string;
|
|
nextDiskName?: string;
|
|
headerReserveSize: number;
|
|
folderReserveSize: number;
|
|
dataReserveSize: number;
|
|
headerReserve: Uint8Array;
|
|
folders: CabFolderInternal[];
|
|
fileRecords: CabFileRecordInternal[];
|
|
result: CabEnumerationResult;
|
|
}
|
|
|
|
export function verifyDataBlockChecksumInternal(
|
|
bytes: Uint8Array,
|
|
block: CabDataBlockInternal
|
|
): void {
|
|
if (block.checksum === 0) return;
|
|
const compressed = bytes.subarray(
|
|
block.dataOffset,
|
|
block.dataOffset + block.compressedSize
|
|
);
|
|
const actual = calculateDataBlockChecksum(
|
|
block.reserve,
|
|
compressed,
|
|
block.compressedSize,
|
|
block.uncompressedSize
|
|
);
|
|
if (actual !== block.checksum) {
|
|
fail(
|
|
'cab-checksum-mismatch',
|
|
`CAB data block checksum mismatch: expected 0x${block.checksum.toString(16).padStart(8, '0')}, got 0x${actual.toString(16).padStart(8, '0')}.`,
|
|
{ offset: block.headerOffset, structure: 'CFDATA.csum' }
|
|
);
|
|
}
|
|
}
|
|
|
|
function checkedAdd(
|
|
left: number,
|
|
right: number,
|
|
code: string,
|
|
structure: string
|
|
): number {
|
|
const sum = left + right;
|
|
if (!Number.isSafeInteger(sum)) {
|
|
fail(code, `Integer overflow while reading ${structure}.`, { structure });
|
|
}
|
|
return sum;
|
|
}
|
|
|
|
function readLinkedCabinetString(
|
|
reader: BinaryReader,
|
|
limits: OnePkgLimits,
|
|
structure: string,
|
|
allowEmpty: boolean
|
|
): string {
|
|
const offset = reader.offset;
|
|
const bytes = reader.readNullTerminatedBytes(limits.maxNameBytes, structure);
|
|
if (!allowEmpty && bytes.length === 0) {
|
|
fail('cab-linked-name-empty', 'Linked cabinet name cannot be empty.', {
|
|
offset,
|
|
structure,
|
|
});
|
|
}
|
|
try {
|
|
return STRICT_UTF8.decode(bytes).normalize('NFC');
|
|
} catch (error) {
|
|
fail(
|
|
'cab-linked-name-invalid-utf8',
|
|
`Linked cabinet metadata is not valid UTF-8${error instanceof Error ? `: ${error.message}` : '.'}`,
|
|
{ offset, structure }
|
|
);
|
|
}
|
|
}
|
|
|
|
function parseCompression(
|
|
raw: number,
|
|
limits: OnePkgLimits,
|
|
offset: number
|
|
): CabCompressionMethod {
|
|
const kind = raw & 0x000f;
|
|
if (kind === 0) {
|
|
if (raw !== 0) {
|
|
fail(
|
|
'cab-invalid-compression',
|
|
`Invalid uncompressed type 0x${raw.toString(16)}.`,
|
|
{
|
|
offset,
|
|
structure: 'CFFOLDER.typeCompress',
|
|
}
|
|
);
|
|
}
|
|
return { kind: 'none', raw, label: 'None' };
|
|
}
|
|
if (kind === 1) {
|
|
if (raw !== 1) {
|
|
fail(
|
|
'cab-invalid-compression',
|
|
`Invalid MSZIP type 0x${raw.toString(16)}.`,
|
|
{
|
|
offset,
|
|
structure: 'CFFOLDER.typeCompress',
|
|
}
|
|
);
|
|
}
|
|
return { kind: 'mszip', raw, label: 'MSZIP' };
|
|
}
|
|
if (kind === 2) {
|
|
const level = (raw & 0x00f0) >>> 4;
|
|
const memoryBits = (raw & 0x1f00) >>> 8;
|
|
if (
|
|
(raw & ~0x1ff2) !== 0 ||
|
|
level < 1 ||
|
|
level > 7 ||
|
|
memoryBits < 10 ||
|
|
memoryBits > 21
|
|
) {
|
|
fail(
|
|
'cab-invalid-compression',
|
|
`Invalid Quantum type 0x${raw.toString(16)}.`,
|
|
{
|
|
offset,
|
|
structure: 'CFFOLDER.typeCompress',
|
|
}
|
|
);
|
|
}
|
|
const windowBytes = 2 ** memoryBits;
|
|
if (windowBytes > limits.maxQuantumWindowBytes) {
|
|
fail(
|
|
'cab-quantum-window-limit',
|
|
`Quantum window ${windowBytes} exceeds the configured ${limits.maxQuantumWindowBytes}-byte limit.`,
|
|
{ offset, structure: 'CFFOLDER.typeCompress' }
|
|
);
|
|
}
|
|
return {
|
|
kind: 'quantum',
|
|
raw,
|
|
label: 'Quantum',
|
|
level,
|
|
memoryBits,
|
|
windowBytes,
|
|
};
|
|
}
|
|
if (kind === 3) {
|
|
if ((raw & ~0x1f0f) !== 0) {
|
|
fail(
|
|
'cab-invalid-compression',
|
|
`Invalid LZX type 0x${raw.toString(16)}.`,
|
|
{
|
|
offset,
|
|
structure: 'CFFOLDER.typeCompress',
|
|
}
|
|
);
|
|
}
|
|
const windowBits = (raw & 0x1f00) >>> 8;
|
|
if (windowBits < 15 || windowBits > 25) {
|
|
fail('cab-invalid-lzx-window', `Invalid LZX window 2^${windowBits}.`, {
|
|
offset,
|
|
structure: 'CFFOLDER.typeCompress',
|
|
});
|
|
}
|
|
const windowBytes = 2 ** windowBits;
|
|
if (windowBytes > limits.maxLzxWindowBytes) {
|
|
fail(
|
|
'cab-lzx-window-limit',
|
|
`LZX window ${windowBytes} exceeds the configured ${limits.maxLzxWindowBytes}-byte limit.`,
|
|
{ offset, structure: 'CFFOLDER.typeCompress' }
|
|
);
|
|
}
|
|
return { kind: 'lzx', raw, label: 'LZX', windowBits, windowBytes };
|
|
}
|
|
fail(
|
|
'cab-invalid-compression',
|
|
`Unknown CAB compression type 0x${raw.toString(16)}.`,
|
|
{
|
|
offset,
|
|
structure: 'CFFOLDER.typeCompress',
|
|
}
|
|
);
|
|
}
|
|
|
|
export function parseCabinetInternal(
|
|
bytes: Uint8Array,
|
|
options: CabOpenOptions,
|
|
allowMultipart = false
|
|
): ParsedCabinetInternal {
|
|
if (!(bytes instanceof Uint8Array)) {
|
|
throw new TypeError('CAB input must be a Uint8Array');
|
|
}
|
|
const limits = resolveLimits(options.limits);
|
|
if (bytes.byteLength > limits.maxCabinetBytes) {
|
|
fail(
|
|
'cab-input-limit',
|
|
`Cabinet is ${bytes.byteLength} bytes; configured limit is ${limits.maxCabinetBytes}.`,
|
|
{ structure: 'CFHEADER' }
|
|
);
|
|
}
|
|
checkCancellation(options.cancellation);
|
|
|
|
const header = new BinaryReader(bytes);
|
|
if (header.readU32('CFHEADER.signature') !== CAB_SIGNATURE) {
|
|
fail(
|
|
'cab-invalid-signature',
|
|
'Input does not begin with the MSCF CAB signature.',
|
|
{
|
|
offset: 0,
|
|
structure: 'CFHEADER.signature',
|
|
}
|
|
);
|
|
}
|
|
const reserved1 = header.readU32('CFHEADER.reserved1');
|
|
const declaredSize = header.readU32('CFHEADER.cbCabinet');
|
|
const reserved2 = header.readU32('CFHEADER.reserved2');
|
|
const firstFileOffset = header.readU32('CFHEADER.coffFiles');
|
|
const reserved3 = header.readU32('CFHEADER.reserved3');
|
|
const minorVersion = header.readU8('CFHEADER.versionMinor');
|
|
const majorVersion = header.readU8('CFHEADER.versionMajor');
|
|
const folderCount = header.readU16('CFHEADER.cFolders');
|
|
const fileCount = header.readU16('CFHEADER.cFiles');
|
|
const flags = header.readU16('CFHEADER.flags');
|
|
const cabinetSetId = header.readU16('CFHEADER.setID');
|
|
const cabinetSetIndex = header.readU16('CFHEADER.iCabinet');
|
|
|
|
if (declaredSize < 36 || declaredSize > bytes.byteLength) {
|
|
fail(
|
|
'cab-invalid-size',
|
|
`Declared cabinet size ${declaredSize} is outside the available ${bytes.byteLength} bytes.`,
|
|
{ offset: 8, structure: 'CFHEADER.cbCabinet' }
|
|
);
|
|
}
|
|
if (declaredSize > limits.maxCabinetBytes) {
|
|
fail(
|
|
'cab-input-limit',
|
|
'Declared cabinet size exceeds the configured limit.',
|
|
{
|
|
offset: 8,
|
|
structure: 'CFHEADER.cbCabinet',
|
|
}
|
|
);
|
|
}
|
|
if (majorVersion > 1 || (majorVersion === 1 && minorVersion > 3)) {
|
|
fail(
|
|
'cab-version-unsupported',
|
|
`CAB version ${majorVersion}.${minorVersion} is not supported.`,
|
|
{ offset: 24, structure: 'CFHEADER.version' }
|
|
);
|
|
}
|
|
if ((flags & ~0x0007) !== 0) {
|
|
fail(
|
|
'cab-invalid-flags',
|
|
`Unknown CAB header flags 0x${flags.toString(16)}.`,
|
|
{
|
|
offset: 30,
|
|
structure: 'CFHEADER.flags',
|
|
}
|
|
);
|
|
}
|
|
if (
|
|
!allowMultipart &&
|
|
(flags & (FLAG_PREVIOUS_CABINET | FLAG_NEXT_CABINET)) !== 0
|
|
) {
|
|
fail(
|
|
'cab-multipart-unsupported',
|
|
'Multi-cabinet archives are not supported for local .onepkg files.',
|
|
{ offset: 30, structure: 'CFHEADER.flags' }
|
|
);
|
|
}
|
|
if (folderCount > limits.maxFolders || fileCount > limits.maxFiles) {
|
|
fail(
|
|
'cab-entry-count-limit',
|
|
`Cabinet contains ${folderCount} folders and ${fileCount} files, exceeding configured limits.`,
|
|
{ structure: 'CFHEADER' }
|
|
);
|
|
}
|
|
|
|
// Re-bind reads to the trusted cbCabinet boundary before parsing variable data.
|
|
const reader = new BinaryReader(bytes, header.offset, declaredSize);
|
|
let headerReserveSize = 0;
|
|
let folderReserveSize = 0;
|
|
let dataReserveSize = 0;
|
|
let headerReserve: Uint8Array = new Uint8Array(0);
|
|
if ((flags & FLAG_RESERVE_PRESENT) !== 0) {
|
|
headerReserveSize = reader.readU16('CFHEADER.cbCFHeader');
|
|
folderReserveSize = reader.readU8('CFHEADER.cbCFFolder');
|
|
dataReserveSize = reader.readU8('CFHEADER.cbCFData');
|
|
if (headerReserveSize > 60_000) {
|
|
fail(
|
|
'cab-header-reserve-limit',
|
|
'CAB header reserve area exceeds the 60,000-byte format limit.',
|
|
{ offset: header.offset, structure: 'CFHEADER.cbCFHeader' }
|
|
);
|
|
}
|
|
headerReserve = reader.readBytes(headerReserveSize, 'CFHEADER.abReserve');
|
|
}
|
|
|
|
const previousCabinetName =
|
|
(flags & FLAG_PREVIOUS_CABINET) !== 0
|
|
? readLinkedCabinetString(reader, limits, 'CFHEADER.szCabinetPrev', false)
|
|
: undefined;
|
|
const previousDiskName =
|
|
(flags & FLAG_PREVIOUS_CABINET) !== 0
|
|
? readLinkedCabinetString(reader, limits, 'CFHEADER.szDiskPrev', true)
|
|
: undefined;
|
|
const nextCabinetName =
|
|
(flags & FLAG_NEXT_CABINET) !== 0
|
|
? readLinkedCabinetString(reader, limits, 'CFHEADER.szCabinetNext', false)
|
|
: undefined;
|
|
const nextDiskName =
|
|
(flags & FLAG_NEXT_CABINET) !== 0
|
|
? readLinkedCabinetString(reader, limits, 'CFHEADER.szDiskNext', true)
|
|
: undefined;
|
|
|
|
const folders: CabFolderInternal[] = [];
|
|
let totalDataBlocks = 0;
|
|
for (let index = 0; index < folderCount; index += 1) {
|
|
if ((index & 0xff) === 0) checkCancellation(options.cancellation);
|
|
const folderOffset = reader.offset;
|
|
const firstDataBlockOffset = reader.readU32('CFFOLDER.coffCabStart');
|
|
const dataBlockCount = reader.readU16('CFFOLDER.cCFData');
|
|
const compressionRawOffset = reader.offset;
|
|
const compression = parseCompression(
|
|
reader.readU16('CFFOLDER.typeCompress'),
|
|
limits,
|
|
compressionRawOffset
|
|
);
|
|
const reserve = reader.readBytes(folderReserveSize, 'CFFOLDER.abReserve');
|
|
totalDataBlocks = checkedAdd(
|
|
totalDataBlocks,
|
|
dataBlockCount,
|
|
'cab-data-block-count-overflow',
|
|
'CFFOLDER.cCFData'
|
|
);
|
|
if (totalDataBlocks > limits.maxDataBlocks) {
|
|
fail(
|
|
'cab-data-block-count-limit',
|
|
`Cabinet exceeds the configured ${limits.maxDataBlocks}-block limit.`,
|
|
{ offset: folderOffset, structure: 'CFFOLDER.cCFData' }
|
|
);
|
|
}
|
|
folders.push({
|
|
index,
|
|
firstDataBlockOffset,
|
|
dataBlockCount,
|
|
compression,
|
|
reserve,
|
|
blocks: [],
|
|
compressedSize: 0,
|
|
uncompressedSize: 0,
|
|
dataEnd: firstDataBlockOffset,
|
|
});
|
|
}
|
|
|
|
if (firstFileOffset < reader.offset || firstFileOffset >= declaredSize) {
|
|
fail('cab-invalid-file-table-offset', 'CAB file table offset is invalid.', {
|
|
offset: 16,
|
|
structure: 'CFHEADER.coffFiles',
|
|
});
|
|
}
|
|
const fileReader = new BinaryReader(bytes, firstFileOffset, declaredSize);
|
|
const entries: CabFileEntry[] = [];
|
|
const fileRecords: CabFileRecordInternal[] = [];
|
|
const diagnostics = [];
|
|
const duplicatePaths = new Set<string>();
|
|
let extractedByteTotal = 0;
|
|
for (let index = 0; index < fileCount; index += 1) {
|
|
if ((index & 0xff) === 0) checkCancellation(options.cancellation);
|
|
const entryOffset = fileReader.offset;
|
|
const uncompressedSize = fileReader.readU32('CFFILE.cbFile');
|
|
const uncompressedOffset = fileReader.readU32('CFFILE.uoffFolderStart');
|
|
const rawFolderIndex = fileReader.readU16('CFFILE.iFolder');
|
|
const date = fileReader.readU16('CFFILE.date');
|
|
const time = fileReader.readU16('CFFILE.time');
|
|
const attributes = fileReader.readU16('CFFILE.attribs');
|
|
const nameOffset = fileReader.offset;
|
|
const nameBytes = fileReader.readNullTerminatedBytes(
|
|
limits.maxNameBytes,
|
|
'CFFILE.szName'
|
|
);
|
|
|
|
let folderIndex = rawFolderIndex;
|
|
if (rawFolderIndex >= FOLDER_CONTINUED_FROM_PREVIOUS) {
|
|
if (!allowMultipart) {
|
|
fail(
|
|
'cab-multipart-file-unsupported',
|
|
'CAB file spans another cabinet; multi-cabinet files are unsupported.',
|
|
{ offset: entryOffset + 8, structure: 'CFFILE.iFolder' }
|
|
);
|
|
}
|
|
const continuesFromPrevious =
|
|
rawFolderIndex === FOLDER_CONTINUED_FROM_PREVIOUS ||
|
|
rawFolderIndex === FOLDER_CONTINUED_PREVIOUS_AND_NEXT;
|
|
const continuesToNext =
|
|
rawFolderIndex === FOLDER_CONTINUED_TO_NEXT ||
|
|
rawFolderIndex === FOLDER_CONTINUED_PREVIOUS_AND_NEXT;
|
|
if (
|
|
(continuesFromPrevious && (flags & FLAG_PREVIOUS_CABINET) === 0) ||
|
|
(continuesToNext && (flags & FLAG_NEXT_CABINET) === 0)
|
|
) {
|
|
fail(
|
|
'cab-spanning-file-flag-mismatch',
|
|
'CAB spanning file marker does not match its prev/next header flags.',
|
|
{ offset: entryOffset + 8, structure: 'CFFILE.iFolder' }
|
|
);
|
|
}
|
|
if (
|
|
rawFolderIndex === FOLDER_CONTINUED_PREVIOUS_AND_NEXT &&
|
|
folders.length !== 1
|
|
) {
|
|
fail(
|
|
'cab-spanning-folder-ambiguous',
|
|
'A folder continued from both cabinets must be the only folder in this cabinet.',
|
|
{ offset: entryOffset + 8, structure: 'CFFILE.iFolder' }
|
|
);
|
|
}
|
|
folderIndex = continuesFromPrevious ? 0 : folders.length - 1;
|
|
}
|
|
const folder = folders[folderIndex];
|
|
if (folder === undefined) {
|
|
fail(
|
|
'cab-folder-index-out-of-range',
|
|
'CAB file folder index is out of range.',
|
|
{
|
|
offset: entryOffset + 8,
|
|
structure: 'CFFILE.iFolder',
|
|
}
|
|
);
|
|
}
|
|
if (uncompressedSize > limits.maxFileBytes) {
|
|
fail(
|
|
'cab-file-size-limit',
|
|
`CAB file size ${uncompressedSize} exceeds the configured ${limits.maxFileBytes}-byte limit.`,
|
|
{ offset: entryOffset, structure: 'CFFILE.cbFile' }
|
|
);
|
|
}
|
|
if (!allowMultipart) {
|
|
extractedByteTotal = checkedAdd(
|
|
extractedByteTotal,
|
|
uncompressedSize,
|
|
'cab-extracted-size-overflow',
|
|
'CFFILE.cbFile'
|
|
);
|
|
if (extractedByteTotal > limits.maxExtractedBytes) {
|
|
fail(
|
|
'cab-extracted-size-limit',
|
|
`Extracted files exceed the configured ${limits.maxExtractedBytes}-byte limit.`,
|
|
{ offset: entryOffset, structure: 'CFFILE.cbFile' }
|
|
);
|
|
}
|
|
}
|
|
|
|
const path = decodeCabFileName(
|
|
nameBytes,
|
|
(attributes & ATTRIBUTE_NAME_IS_UTF8) !== 0,
|
|
nameOffset
|
|
);
|
|
if (
|
|
(attributes & ATTRIBUTE_NAME_IS_UTF8) === 0 &&
|
|
nameBytes.some((byte) => byte > 0x7f)
|
|
) {
|
|
diagnostics.push(
|
|
warning(
|
|
'cab-unmarked-utf8-name',
|
|
`Decoded unmarked non-ASCII CAB filename as strict UTF-8: ${path}`,
|
|
{ offset: nameOffset, structure: 'CFFILE.szName' }
|
|
)
|
|
);
|
|
}
|
|
const normalized = normalizeCabPath(path, limits, nameOffset);
|
|
if (duplicatePaths.has(normalized.duplicateKey)) {
|
|
fail(
|
|
'cab-duplicate-path',
|
|
`CAB contains a duplicate normalized path: ${normalized.normalizedPath}`,
|
|
{ offset: nameOffset, structure: 'CFFILE.szName' }
|
|
);
|
|
}
|
|
duplicatePaths.add(normalized.duplicateKey);
|
|
const entry: CabFileEntry = {
|
|
index,
|
|
path: normalized.path,
|
|
normalizedPath: normalized.normalizedPath,
|
|
folderIndex,
|
|
uncompressedOffset,
|
|
uncompressedSize,
|
|
attributes,
|
|
compression: folder.compression,
|
|
};
|
|
entries.push(entry);
|
|
fileRecords.push({
|
|
entry,
|
|
rawFolderIndex,
|
|
duplicateKey: normalized.duplicateKey,
|
|
date,
|
|
time,
|
|
entryOffset,
|
|
});
|
|
}
|
|
const metadataEnd = fileReader.offset;
|
|
|
|
let totalFolderOutput = 0;
|
|
let totalLzxOutput = 0;
|
|
let totalQuantumOutput = 0;
|
|
for (const folder of folders) {
|
|
const folderContinuesToNext = fileRecords.some(
|
|
(record) =>
|
|
record.entry.folderIndex === folder.index &&
|
|
(record.rawFolderIndex === FOLDER_CONTINUED_TO_NEXT ||
|
|
record.rawFolderIndex === FOLDER_CONTINUED_PREVIOUS_AND_NEXT)
|
|
);
|
|
if (folder.firstDataBlockOffset > declaredSize) {
|
|
fail(
|
|
'cab-invalid-data-offset',
|
|
'CAB folder data offset is beyond the declared cabinet.',
|
|
{
|
|
offset: folder.firstDataBlockOffset,
|
|
structure: 'CFFOLDER.coffCabStart',
|
|
}
|
|
);
|
|
}
|
|
if (
|
|
folder.dataBlockCount > 0 &&
|
|
folder.firstDataBlockOffset < metadataEnd
|
|
) {
|
|
fail(
|
|
'cab-data-overlaps-metadata',
|
|
'CAB data blocks overlap the file table.',
|
|
{
|
|
offset: folder.firstDataBlockOffset,
|
|
structure: 'CFFOLDER.coffCabStart',
|
|
}
|
|
);
|
|
}
|
|
const blockReader = new BinaryReader(
|
|
bytes,
|
|
folder.firstDataBlockOffset,
|
|
declaredSize
|
|
);
|
|
for (
|
|
let blockIndex = 0;
|
|
blockIndex < folder.dataBlockCount;
|
|
blockIndex += 1
|
|
) {
|
|
if ((blockIndex & 0xff) === 0) checkCancellation(options.cancellation);
|
|
const headerOffset = blockReader.offset;
|
|
const checksum = blockReader.readU32('CFDATA.csum');
|
|
const compressedSize = blockReader.readU16('CFDATA.cbData');
|
|
const uncompressedSize = blockReader.readU16('CFDATA.cbUncomp');
|
|
const reserve = blockReader.readBytes(
|
|
dataReserveSize,
|
|
'CFDATA.abReserve'
|
|
);
|
|
const dataOffset = blockReader.offset;
|
|
blockReader.skip(compressedSize, 'CFDATA.ab');
|
|
if (compressedSize === 0 || uncompressedSize > MAX_CAB_BLOCK_OUTPUT) {
|
|
fail(
|
|
'cab-invalid-data-block-size',
|
|
`Invalid CAB data block sizes ${compressedSize}/${uncompressedSize}.`,
|
|
{ offset: headerOffset + 4, structure: 'CFDATA' }
|
|
);
|
|
}
|
|
if (
|
|
uncompressedSize === 0 &&
|
|
(!allowMultipart ||
|
|
blockIndex !== folder.dataBlockCount - 1 ||
|
|
!folderContinuesToNext)
|
|
) {
|
|
fail(
|
|
'cab-invalid-split-data-block',
|
|
'A zero-output CFDATA fragment must be the final block of a folder continued in the next cabinet.',
|
|
{ offset: headerOffset + 6, structure: 'CFDATA.cbUncomp' }
|
|
);
|
|
}
|
|
if (compressedSize > MAX_CAB_BLOCK_INPUT) {
|
|
fail(
|
|
'cab-invalid-compressed-block-size',
|
|
`CAB data block size ${compressedSize} exceeds ${MAX_CAB_BLOCK_INPUT} bytes.`,
|
|
{ offset: headerOffset + 4, structure: 'CFDATA.cbData' }
|
|
);
|
|
}
|
|
if (
|
|
folder.compression.kind === 'mszip' &&
|
|
compressedSize > MAX_MSZIP_BLOCK_INPUT
|
|
) {
|
|
fail(
|
|
'cab-invalid-mszip-block-size',
|
|
`MSZIP CAB data block size ${compressedSize} exceeds ${MAX_MSZIP_BLOCK_INPUT} bytes.`,
|
|
{ offset: headerOffset + 4, structure: 'CFDATA.cbData' }
|
|
);
|
|
}
|
|
if (
|
|
folder.compression.kind === 'none' &&
|
|
!allowMultipart &&
|
|
compressedSize !== uncompressedSize
|
|
) {
|
|
fail(
|
|
'cab-uncompressed-size-mismatch',
|
|
'Uncompressed CAB block has different compressed and output sizes.',
|
|
{ offset: headerOffset + 4, structure: 'CFDATA' }
|
|
);
|
|
}
|
|
folder.compressedSize = checkedAdd(
|
|
folder.compressedSize,
|
|
compressedSize,
|
|
'cab-folder-size-overflow',
|
|
'CFDATA.cbData'
|
|
);
|
|
folder.uncompressedSize = checkedAdd(
|
|
folder.uncompressedSize,
|
|
uncompressedSize,
|
|
'cab-folder-size-overflow',
|
|
'CFDATA.cbUncomp'
|
|
);
|
|
if (folder.uncompressedSize > limits.maxFolderUncompressedBytes) {
|
|
fail(
|
|
'cab-folder-size-limit',
|
|
`CAB folder exceeds the configured ${limits.maxFolderUncompressedBytes}-byte output limit.`,
|
|
{ offset: headerOffset + 6, structure: 'CFDATA.cbUncomp' }
|
|
);
|
|
}
|
|
folder.blocks.push({
|
|
headerOffset,
|
|
checksum,
|
|
compressedSize,
|
|
uncompressedSize,
|
|
reserve,
|
|
dataOffset,
|
|
sourceBytes: bytes,
|
|
});
|
|
}
|
|
folder.dataEnd = blockReader.offset;
|
|
if (
|
|
!allowMultipart &&
|
|
folder.uncompressedSize / folder.compressedSize >
|
|
limits.maxCompressionRatio
|
|
) {
|
|
fail(
|
|
'cab-compression-ratio-limit',
|
|
`CAB folder compression ratio exceeds the configured ${limits.maxCompressionRatio}:1 limit.`,
|
|
{ offset: folder.firstDataBlockOffset, structure: 'CFFOLDER/CFDATA' }
|
|
);
|
|
}
|
|
totalFolderOutput = checkedAdd(
|
|
totalFolderOutput,
|
|
folder.uncompressedSize,
|
|
'cab-total-size-overflow',
|
|
'CFDATA.cbUncomp'
|
|
);
|
|
if (folder.compression.kind === 'lzx') {
|
|
totalLzxOutput = checkedAdd(
|
|
totalLzxOutput,
|
|
folder.uncompressedSize,
|
|
'lzx-operation-limit',
|
|
'CFDATA.cbUncomp'
|
|
);
|
|
}
|
|
if (folder.compression.kind === 'quantum') {
|
|
totalQuantumOutput = checkedAdd(
|
|
totalQuantumOutput,
|
|
folder.uncompressedSize,
|
|
'quantum-operation-limit',
|
|
'CFDATA.cbUncomp'
|
|
);
|
|
}
|
|
}
|
|
if (totalFolderOutput > limits.maxTotalUncompressedBytes) {
|
|
fail(
|
|
'cab-total-size-limit',
|
|
`CAB output exceeds the configured ${limits.maxTotalUncompressedBytes}-byte limit.`,
|
|
{ structure: 'CFDATA.cbUncomp' }
|
|
);
|
|
}
|
|
if (totalLzxOutput > limits.maxDecodeOperations) {
|
|
fail(
|
|
'lzx-operation-limit',
|
|
`LZX output exceeds the configured ${limits.maxDecodeOperations}-operation limit.`,
|
|
{ structure: 'CFDATA.cbUncomp' }
|
|
);
|
|
}
|
|
if (totalQuantumOutput > limits.maxDecodeOperations) {
|
|
fail(
|
|
'quantum-operation-limit',
|
|
`Quantum output exceeds the configured ${limits.maxDecodeOperations}-operation limit.`,
|
|
{ structure: 'CFDATA.cbUncomp' }
|
|
);
|
|
}
|
|
|
|
const dataRanges = folders
|
|
.filter((folder) => folder.dataBlockCount > 0)
|
|
.map((folder) => ({
|
|
start: folder.firstDataBlockOffset,
|
|
end: folder.dataEnd,
|
|
}))
|
|
.sort((left, right) => left.start - right.start);
|
|
for (let index = 1; index < dataRanges.length; index += 1) {
|
|
if (dataRanges[index]!.start < dataRanges[index - 1]!.end) {
|
|
fail('cab-overlapping-folders', 'CAB folder data ranges overlap.', {
|
|
offset: dataRanges[index]!.start,
|
|
structure: 'CFFOLDER.coffCabStart',
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!allowMultipart) {
|
|
for (const entry of entries) {
|
|
const folder = folders[entry.folderIndex]!;
|
|
const fileEnd = checkedAdd(
|
|
entry.uncompressedOffset,
|
|
entry.uncompressedSize,
|
|
'cab-file-range-overflow',
|
|
'CFFILE'
|
|
);
|
|
if (fileEnd > folder.uncompressedSize) {
|
|
fail(
|
|
'cab-file-range-out-of-bounds',
|
|
`CAB file ${entry.normalizedPath} extends beyond its folder data.`,
|
|
{ structure: 'CFFILE' }
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (bytes.byteLength > declaredSize) {
|
|
diagnostics.push(
|
|
warning(
|
|
'cab-trailing-data',
|
|
`Ignored ${bytes.byteLength - declaredSize} bytes after the declared cabinet.`,
|
|
{ offset: declaredSize, structure: 'CFHEADER.cbCabinet' }
|
|
)
|
|
);
|
|
}
|
|
if (reserved1 !== 0 || reserved2 !== 0 || reserved3 !== 0) {
|
|
diagnostics.push(
|
|
warning(
|
|
'cab-reserved-header-data',
|
|
'CAB header contains non-zero reserved fields.',
|
|
{ structure: 'CFHEADER' }
|
|
)
|
|
);
|
|
}
|
|
return {
|
|
bytes,
|
|
limits,
|
|
flags,
|
|
previousCabinetName,
|
|
previousDiskName,
|
|
nextCabinetName,
|
|
nextDiskName,
|
|
headerReserveSize,
|
|
folderReserveSize,
|
|
dataReserveSize,
|
|
headerReserve,
|
|
folders,
|
|
fileRecords,
|
|
result: {
|
|
cabinetSetId,
|
|
cabinetSetIndex,
|
|
declaredSize,
|
|
entries,
|
|
folders: folders.map((folder) => ({
|
|
index: folder.index,
|
|
dataBlockCount: folder.dataBlockCount,
|
|
compressedSize: folder.compressedSize,
|
|
uncompressedSize: folder.uncompressedSize,
|
|
compression: folder.compression,
|
|
})),
|
|
diagnostics,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function enumerateCabinet(
|
|
bytes: Uint8Array,
|
|
options: CabOpenOptions = {}
|
|
): CabEnumerationResult {
|
|
return parseCabinetInternal(bytes, options).result;
|
|
}
|
|
|
|
export async function extractCabinet(
|
|
bytes: Uint8Array,
|
|
options: CabOpenOptions = {}
|
|
): Promise<CabExtractionResult> {
|
|
const parsed = parseCabinetInternal(bytes, options);
|
|
const byFolder = new Map<number, CabFileEntry[]>();
|
|
for (const entry of parsed.result.entries) {
|
|
const current = byFolder.get(entry.folderIndex);
|
|
if (current === undefined) byFolder.set(entry.folderIndex, [entry]);
|
|
else current.push(entry);
|
|
}
|
|
|
|
const extractedEntries = [];
|
|
for (const folder of parsed.folders) {
|
|
checkCancellation(options.cancellation);
|
|
const folderOutput = new Uint8Array(folder.uncompressedSize);
|
|
const decoder =
|
|
folder.compression.kind === 'lzx'
|
|
? new LzxDecoder(folder.compression.windowBits, {
|
|
maxOperations: parsed.limits.maxDecodeOperations,
|
|
checkCancellation: () => checkCancellation(options.cancellation),
|
|
})
|
|
: undefined;
|
|
const msZipDecoder =
|
|
folder.compression.kind === 'mszip' ? new MsZipDecoder() : undefined;
|
|
const quantumDecoder =
|
|
folder.compression.kind === 'quantum'
|
|
? new QuantumDecoder(folder.compression.memoryBits, {
|
|
maxOperations: parsed.limits.maxDecodeOperations,
|
|
checkCancellation: () => checkCancellation(options.cancellation),
|
|
})
|
|
: undefined;
|
|
let quantumQueuedBlock = -1;
|
|
let outputOffset = 0;
|
|
for (const [blockIndex, block] of folder.blocks.entries()) {
|
|
checkCancellation(options.cancellation);
|
|
if (quantumDecoder !== undefined) {
|
|
const lookahead = Math.min(blockIndex + 1, folder.blocks.length - 1);
|
|
while (quantumQueuedBlock < lookahead) {
|
|
quantumQueuedBlock += 1;
|
|
const queued = folder.blocks[quantumQueuedBlock]!;
|
|
verifyDataBlockChecksumInternal(parsed.bytes, queued);
|
|
quantumDecoder.appendCabBlock(
|
|
parsed.bytes.subarray(
|
|
queued.dataOffset,
|
|
queued.dataOffset + queued.compressedSize
|
|
),
|
|
quantumQueuedBlock === folder.blocks.length - 1
|
|
);
|
|
}
|
|
}
|
|
const compressed = parsed.bytes.subarray(
|
|
block.dataOffset,
|
|
block.dataOffset + block.compressedSize
|
|
);
|
|
if (quantumDecoder === undefined) {
|
|
verifyDataBlockChecksumInternal(parsed.bytes, block);
|
|
}
|
|
const output =
|
|
decoder !== undefined
|
|
? decoder.decompressNext(compressed, block.uncompressedSize)
|
|
: msZipDecoder !== undefined
|
|
? msZipDecoder.decompressNext(compressed, block.uncompressedSize)
|
|
: quantumDecoder !== undefined
|
|
? quantumDecoder.decompressNext(
|
|
block.uncompressedSize,
|
|
blockIndex === folder.blocks.length - 1
|
|
)
|
|
: compressed;
|
|
if (output.length !== block.uncompressedSize) {
|
|
fail(
|
|
'cab-output-size-mismatch',
|
|
`CAB decoder returned ${output.length} bytes; expected ${block.uncompressedSize}.`,
|
|
{ offset: block.headerOffset + 6, structure: 'CFDATA.cbUncomp' }
|
|
);
|
|
}
|
|
folderOutput.set(output, outputOffset);
|
|
outputOffset += output.length;
|
|
await yieldForCancellation(options.cancellation);
|
|
}
|
|
decoder?.finish();
|
|
if (outputOffset !== folderOutput.length) {
|
|
fail(
|
|
'cab-folder-output-size-mismatch',
|
|
'CAB folder output is incomplete.',
|
|
{
|
|
structure: `CFFOLDER[${folder.index}]`,
|
|
}
|
|
);
|
|
}
|
|
|
|
for (const entry of byFolder.get(folder.index) ?? []) {
|
|
const start = entry.uncompressedOffset;
|
|
extractedEntries.push({
|
|
...entry,
|
|
bytes: folderOutput.slice(start, start + entry.uncompressedSize),
|
|
});
|
|
}
|
|
}
|
|
extractedEntries.sort((left, right) => left.index - right.index);
|
|
|
|
return { ...parsed.result, extractedEntries };
|
|
}
|