/* * 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; const FLAG_RESERVE_PRESENT = 0x0004; const ATTRIBUTE_NAME_IS_UTF8 = 0x0080; const MAX_CAB_BLOCK_OUTPUT = 32 * 1024; const MAX_MSZIP_BLOCK_INPUT = 32 * 1024 + 12; interface CabDataBlock { headerOffset: number; checksum: number; compressedSize: number; uncompressedSize: number; reserve: Uint8Array; dataOffset: number; } interface CabFolderInternal { index: number; firstDataBlockOffset: number; dataBlockCount: number; compression: CabCompressionMethod; blocks: CabDataBlock[]; compressedSize: number; uncompressedSize: number; dataEnd: number; } interface ParsedCabinet { bytes: Uint8Array; limits: OnePkgLimits; folders: CabFolderInternal[]; result: CabEnumerationResult; } function verifyDataBlockChecksum(bytes: Uint8Array, block: CabDataBlock): 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 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', } ); } function parseCabinet( bytes: Uint8Array, options: CabOpenOptions ): ParsedCabinet { 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 ((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 folderReserveSize = 0; let dataReserveSize = 0; if ((flags & FLAG_RESERVE_PRESENT) !== 0) { const headerReserveSize = reader.readU16('CFHEADER.cbCFHeader'); folderReserveSize = reader.readU8('CFHEADER.cbCFFolder'); dataReserveSize = reader.readU8('CFHEADER.cbCFData'); reader.skip(headerReserveSize, 'CFHEADER.abReserve'); } 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 ); reader.skip(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, 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 diagnostics = []; const duplicatePaths = new Set(); 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 folderIndex = fileReader.readU16('CFFILE.iFolder'); fileReader.readU16('CFFILE.date'); fileReader.readU16('CFFILE.time'); const attributes = fileReader.readU16('CFFILE.attribs'); const nameOffset = fileReader.offset; const nameBytes = fileReader.readNullTerminatedBytes( limits.maxNameBytes, 'CFFILE.szName' ); if (folderIndex >= 0xfffd) { fail( 'cab-multipart-file-unsupported', 'CAB file spans another cabinet; multi-cabinet files are unsupported.', { offset: entryOffset + 8, structure: 'CFFILE.iFolder' } ); } 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' } ); } 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); entries.push({ index, path: normalized.path, normalizedPath: normalized.normalizedPath, folderIndex, uncompressedOffset, uncompressedSize, attributes, compression: folder.compression, }); } const metadataEnd = fileReader.offset; let totalFolderOutput = 0; let totalLzxOutput = 0; let totalQuantumOutput = 0; for (const folder of folders) { 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 === 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 ( 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' && 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, }); } folder.dataEnd = blockReader.offset; if ( 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', }); } } 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, folders, 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 parseCabinet(bytes, options).result; } export async function extractCabinet( bytes: Uint8Array, options: CabOpenOptions = {} ): Promise { const parsed = parseCabinet(bytes, options); const byFolder = new Map(); 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]!; verifyDataBlockChecksum(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) { verifyDataBlockChecksum(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 }; }