From 6720fe9d6d1529fbf34300217be4c7275f523cd4 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Tue, 4 Aug 2026 13:14:09 -0400 Subject: [PATCH] fix(zip): complete ZIP64 header validation --- docs/modules/zip/formats/zip.md | 6 + modules/zip/src/parse-zip/cd-file-header.ts | 105 +++++----------- .../zip/src/parse-zip/local-file-header.ts | 41 ++++--- .../zip/src/parse-zip/zip64-extra-field.ts | 84 +++++++++++++ .../test/filesystems/zip-filesystem.spec.ts | 69 +++++++++++ .../zip/test/zip-utils/cd-file-header.spec.ts | 54 ++++++-- .../test/zip-utils/local-file-header.spec.ts | 115 ++++++++++++++++++ 7 files changed, 372 insertions(+), 102 deletions(-) create mode 100644 modules/zip/src/parse-zip/zip64-extra-field.ts diff --git a/docs/modules/zip/formats/zip.md b/docs/modules/zip/formats/zip.md index 75f2961642..fa1f5bb01e 100644 --- a/docs/modules/zip/formats/zip.md +++ b/docs/modules/zip/formats/zip.md @@ -3,3 +3,9 @@ > The [`loaders.gl/zip`](/docs/modules/zip) module provides support for working with Zip Archives. [ZIP Archive]() + +## ZIP64 validation + +The random-access ZIP header parsers validate required ZIP64 extended information records before +using 64-bit sizes and offsets. Missing, truncated, or incorrectly sized ZIP64 records are rejected +with an `Invalid ZIP archive` error instead of exposing low-level `DataView` range errors. diff --git a/modules/zip/src/parse-zip/cd-file-header.ts b/modules/zip/src/parse-zip/cd-file-header.ts index 3579039bf8..ff407a6e30 100644 --- a/modules/zip/src/parse-zip/cd-file-header.ts +++ b/modules/zip/src/parse-zip/cd-file-header.ts @@ -7,6 +7,12 @@ import type {ReadableFile} from '@loaders.gl/loader-utils'; import {parseEoCDRecord} from './end-of-central-directory'; import {ZipSignature} from './search-from-the-end'; import {createZip64Info, setFieldToNumber} from './zip64-info-generation'; +import { + parseZip64ExtraField, + ZIP64_UINT16_SENTINEL, + ZIP64_UINT32_SENTINEL, + type Zip64ExtraFieldDescription +} from './zip64-extra-field'; import { DataViewReadableFile, getReadableFileSize, @@ -33,12 +39,14 @@ export type ZipCDFileHeader = { extraOffset: bigint; /** Relative offset of local file header */ localHeaderOffset: bigint; + /** Number of the disk where the file starts */ + startDisk: bigint; }; /** - * Data that might be in Zip64 notation inside extra data + * Data that might be in ZIP64 notation inside extra data */ -type Zip64Data = { +type Zip64CentralDirectoryData = { /** Uncompressed size */ uncompressedSize: bigint; /** Compressed size */ @@ -54,17 +62,16 @@ const CD_COMPRESSED_SIZE_OFFSET = 20; const CD_UNCOMPRESSED_SIZE_OFFSET = 24; const CD_FILE_NAME_LENGTH_OFFSET = 28; const CD_EXTRA_FIELD_LENGTH_OFFSET = 30; -const CD_START_DISK_OFFSET = 32; +const CD_START_DISK_OFFSET = 34; const CD_LOCAL_HEADER_OFFSET_OFFSET = 42; const CD_FILE_NAME_OFFSET = 46n; -const ZIP64_EXTRA_FIELD_ID = 0x0001; export const signature: ZipSignature = new Uint8Array([0x50, 0x4b, 0x01, 0x02]); /** * Parses central directory file header of zip file * @param headerOffset - offset in the archive where header starts - * @param buffer - buffer containing whole array + * @param file - readable file containing the archive * @returns Info from the header */ export const parseZipCDFileHeader = async ( @@ -106,18 +113,32 @@ export const parseZipCDFileHeader = async ( ); // looking for info that might be also be in zip64 extra field - const zip64data: Zip64Data = { + const zip64Data: Zip64CentralDirectoryData = { uncompressedSize, compressedSize, localHeaderOffset, startDisk }; - const res = findZip64DataInExtra(zip64data, extraField); + const expectedZip64Fields: Zip64ExtraFieldDescription[] = []; + if (zip64Data.uncompressedSize === ZIP64_UINT32_SENTINEL) { + expectedZip64Fields.push({name: 'uncompressedSize', byteLength: 8}); + } + if (zip64Data.compressedSize === ZIP64_UINT32_SENTINEL) { + expectedZip64Fields.push({name: 'compressedSize', byteLength: 8}); + } + if (zip64Data.localHeaderOffset === ZIP64_UINT32_SENTINEL) { + expectedZip64Fields.push({name: 'localHeaderOffset', byteLength: 8}); + } + if (zip64Data.startDisk === ZIP64_UINT16_SENTINEL) { + expectedZip64Fields.push({name: 'startDisk', byteLength: 4}); + } + + const zip64Values = parseZip64ExtraField(extraField, expectedZip64Fields); return { - ...zip64data, - ...res, + ...zip64Data, + ...zip64Values, extraFieldLength, fileNameLength, fileName, @@ -146,72 +167,6 @@ export async function* makeZipCDHeaderIterator( } } -/** - * reads all nesessary data from zip64 record in the extra data - * @param zip64data values that might be in zip64 record - * @param extraField full extra data - * @returns data read from zip64 - */ - -const findZip64DataInExtra = (zip64data: Zip64Data, extraField: DataView): Partial => { - const zip64dataList = findExpectedData(zip64data); - - const zip64DataRes: Partial = {}; - if (zip64dataList.length > 0) { - // total length of data in zip64 notation in bytes - const zip64chunkSize = zip64dataList.reduce((sum, curr) => sum + curr.length, 0); - let offset = 0; - while (offset + 4 <= extraField.byteLength) { - const headerId = extraField.getUint16(offset, true); - const dataSize = extraField.getUint16(offset + 2, true); - const payloadStart = offset + 4; - if (payloadStart + dataSize > extraField.byteLength) { - break; - } - if (headerId === ZIP64_EXTRA_FIELD_ID && dataSize === zip64chunkSize) { - let bytesRead = 0; - for (const note of zip64dataList) { - const fieldOffset = payloadStart + bytesRead; - if (fieldOffset + 8 > payloadStart + dataSize) { - break; - } - zip64DataRes[note.name] = extraField.getBigUint64(fieldOffset, true); - bytesRead += note.length; - } - break; - } - offset = payloadStart + dataSize; - } - } - - return zip64DataRes; -}; - -/** - * frind data that's expected to be in zip64 - * @param zip64data values that might be in zip64 record - * @returns zip64 data description - */ - -const findExpectedData = (zip64data: Zip64Data): {length: number; name: string}[] => { - // We define fields that should be in zip64 data - const zip64dataList: {length: number; name: string}[] = []; - if (zip64data.uncompressedSize === BigInt(0xffffffff)) { - zip64dataList.push({name: 'uncompressedSize', length: 8}); - } - if (zip64data.compressedSize === BigInt(0xffffffff)) { - zip64dataList.push({name: 'compressedSize', length: 8}); - } - if (zip64data.localHeaderOffset === BigInt(0xffffffff)) { - zip64dataList.push({name: 'localHeaderOffset', length: 8}); - } - if (zip64data.startDisk === BigInt(0xffffffff)) { - zip64dataList.push({name: 'startDisk', length: 4}); - } - - return zip64dataList; -}; - /** info that can be placed into cd header */ type GenerateCDOptions = { /** CRC-32 of uncompressed data */ diff --git a/modules/zip/src/parse-zip/local-file-header.ts b/modules/zip/src/parse-zip/local-file-header.ts index 8431b8267e..e0fe855cfe 100644 --- a/modules/zip/src/parse-zip/local-file-header.ts +++ b/modules/zip/src/parse-zip/local-file-header.ts @@ -6,6 +6,11 @@ import {compareArrayBuffers, concatenateArrayBuffers} from '@loaders.gl/loader-u import type {ReadableFile} from '@loaders.gl/loader-utils'; import {ZipSignature} from './search-from-the-end'; import {createZip64Info, setFieldToNumber} from './zip64-info-generation'; +import { + parseZip64ExtraField, + ZIP64_UINT32_SENTINEL, + type Zip64ExtraFieldDescription +} from './zip64-extra-field'; import {readDataView, readRange} from './readable-file-utils'; /** @@ -35,12 +40,20 @@ const FILE_NAME_LENGTH_OFFSET = 26; const EXTRA_FIELD_LENGTH_OFFSET = 28; const FILE_NAME_OFFSET = 30n; +/** ZIP64 size values that local file headers store together. */ +type Zip64LocalSizeData = { + /** Uncompressed file size. */ + uncompressedSize: bigint; + /** Compressed file size. */ + compressedSize: bigint; +}; + export const signature: ZipSignature = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); /** * Parses local file header of zip file * @param headerOffset - offset in the archive where header starts - * @param buffer - buffer containing whole array + * @param file - readable file containing the archive * @returns Info from the header */ export const parseZipLocalFileHeader = async ( @@ -72,26 +85,26 @@ export const parseZipLocalFileHeader = async ( const fileName = new TextDecoder().decode(fileNameBuffer).split('\\').join('/'); - let fileDataOffset = headerOffset + FILE_NAME_OFFSET + BigInt(fileNameLength + extraFieldLength); + const fileDataOffset = + headerOffset + FILE_NAME_OFFSET + BigInt(fileNameLength + extraFieldLength); const compressionMethod = mainHeader.getUint16(COMPRESSION_METHOD_OFFSET, true); - let compressedSize = BigInt(mainHeader.getUint32(COMPRESSED_SIZE_OFFSET, true)); // add zip 64 logic + let compressedSize = BigInt(mainHeader.getUint32(COMPRESSED_SIZE_OFFSET, true)); - let uncompressedSize = BigInt(mainHeader.getUint32(UNCOMPRESSED_SIZE_OFFSET, true)); // add zip 64 logic + const uncompressedSize = BigInt(mainHeader.getUint32(UNCOMPRESSED_SIZE_OFFSET, true)); - let offsetInZip64Data = 4; - // looking for info that might be also be in zip64 extra field - if (uncompressedSize === BigInt(0xffffffff)) { - uncompressedSize = extraDataBuffer.getBigUint64(offsetInZip64Data, true); - offsetInZip64Data += 8; + const expectedZip64Fields: Zip64ExtraFieldDescription[] = []; + if (uncompressedSize === ZIP64_UINT32_SENTINEL) { + expectedZip64Fields.push({name: 'uncompressedSize', byteLength: 8}); } - if (compressedSize === BigInt(0xffffffff)) { - compressedSize = extraDataBuffer.getBigUint64(offsetInZip64Data, true); - offsetInZip64Data += 8; + if (compressedSize === ZIP64_UINT32_SENTINEL) { + expectedZip64Fields.push({name: 'compressedSize', byteLength: 8}); } - if (fileDataOffset === BigInt(0xffffffff)) { - fileDataOffset = extraDataBuffer.getBigUint64(offsetInZip64Data, true); // setting it to the one from zip64 + + const zip64Sizes = parseZip64ExtraField(extraDataBuffer, expectedZip64Fields); + if (zip64Sizes.compressedSize !== undefined) { + compressedSize = zip64Sizes.compressedSize; } return { diff --git a/modules/zip/src/parse-zip/zip64-extra-field.ts b/modules/zip/src/parse-zip/zip64-extra-field.ts new file mode 100644 index 0000000000..5d1f01d88a --- /dev/null +++ b/modules/zip/src/parse-zip/zip64-extra-field.ts @@ -0,0 +1,84 @@ +// loaders.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +/** ZIP64 extra-field header identifier. */ +const ZIP64_EXTRA_FIELD_ID = 0x0001; + +/** Sentinel indicating that a 16-bit ZIP header value is stored in ZIP64 data. */ +export const ZIP64_UINT16_SENTINEL = 0xffffn; + +/** Sentinel indicating that a 32-bit ZIP header value is stored in ZIP64 data. */ +export const ZIP64_UINT32_SENTINEL = 0xffffffffn; + +/** Description of a value stored in a ZIP64 extended information extra field. */ +export type Zip64ExtraFieldDescription = { + /** Name used for the decoded value. */ + name: FieldName; + /** Encoded value width in bytes. */ + byteLength: 4 | 8; +}; + +/** + * Finds and decodes the ZIP64 record in a sequence of ZIP extra-field records. + * @param extraField complete extra-field data from a local or central-directory header + * @param expectedFields ZIP64 values required by sentinel fields in the legacy header + * @returns decoded ZIP64 values keyed by the supplied field names + * @throws If required ZIP64 data is missing, truncated, or has an unexpected size + */ +export function parseZip64ExtraField( + extraField: DataView, + expectedFields: readonly Zip64ExtraFieldDescription[] +): Partial> { + const values: Partial> = {}; + if (expectedFields.length === 0) { + return values; + } + + const expectedPayloadLength = expectedFields.reduce( + (totalByteLength, field) => totalByteLength + field.byteLength, + 0 + ); + let recordOffset = 0; + + while (recordOffset < extraField.byteLength) { + if (recordOffset + 4 > extraField.byteLength) { + throw new Error( + 'Invalid ZIP archive: truncated extra-field record header while reading ZIP64 data' + ); + } + + const headerId = extraField.getUint16(recordOffset, true); + const payloadLength = extraField.getUint16(recordOffset + 2, true); + const payloadOffset = recordOffset + 4; + const nextRecordOffset = payloadOffset + payloadLength; + + if (nextRecordOffset > extraField.byteLength) { + throw new Error( + 'Invalid ZIP archive: truncated extra-field record payload while reading ZIP64 data' + ); + } + + if (headerId === ZIP64_EXTRA_FIELD_ID) { + if (payloadLength !== expectedPayloadLength) { + throw new Error( + 'Invalid ZIP archive: ZIP64 extended information has an unexpected payload size' + ); + } + + let fieldOffset = payloadOffset; + for (const field of expectedFields) { + values[field.name] = + field.byteLength === 8 + ? extraField.getBigUint64(fieldOffset, true) + : BigInt(extraField.getUint32(fieldOffset, true)); + fieldOffset += field.byteLength; + } + return values; + } + + recordOffset = nextRecordOffset; + } + + throw new Error('Invalid ZIP archive: required ZIP64 extended information is missing'); +} diff --git a/modules/zip/test/filesystems/zip-filesystem.spec.ts b/modules/zip/test/filesystems/zip-filesystem.spec.ts index 5c18eaa5a1..c41a99a21a 100644 --- a/modules/zip/test/filesystems/zip-filesystem.spec.ts +++ b/modules/zip/test/filesystems/zip-filesystem.spec.ts @@ -5,12 +5,16 @@ import test from 'tape-promise/tape'; import {isBrowser} from '@loaders.gl/core'; +import {concatenateArrayBuffers} from '@loaders.gl/loader-utils'; import { createReadableFileFromBuffer, createReadableFileFromPath, loadArrayBufferFromFile } from 'test/utils/readable-files'; import {ZipFileSystem} from '../../src/filesystems/zip-filesystem'; +import {generateCDHeader} from '../../src/parse-zip/cd-file-header'; +import {generateEoCD} from '../../src/parse-zip/end-of-central-directory'; +import {generateLocalHeader} from '../../src/parse-zip/local-file-header'; const ZIP_FILE_PATH = '@loaders.gl/zip/test/data/test-store.zip'; @@ -101,3 +105,68 @@ test('zip#ZipFileSystem - buffer-backed readable file', async t => { await fileSystem.destroy(); t.end(); }); + +test('zip#ZipFileSystem - malformed ZIP64 metadata produces controlled errors', async t => { + const centralDirectoryArchive = createMalformedZip64Archive('central-directory'); + const centralDirectoryFileSystem = new ZipFileSystem(centralDirectoryArchive); + await t.rejects( + () => centralDirectoryFileSystem.readdir(), + /Invalid ZIP archive:.*ZIP64/, + 'readdir rejects malformed central-directory ZIP64 data' + ); + await t.rejects( + () => centralDirectoryFileSystem.stat('test.json'), + /Invalid ZIP archive:.*ZIP64/, + 'stat rejects malformed central-directory ZIP64 data' + ); + await centralDirectoryFileSystem.destroy(); + + const localHeaderArchive = createMalformedZip64Archive('local-header'); + const localHeaderFileSystem = new ZipFileSystem(localHeaderArchive); + await t.rejects( + () => localHeaderFileSystem.fetch('test.json'), + /Invalid ZIP archive:.*ZIP64/, + 'fetch rejects malformed local-header ZIP64 data' + ); + await localHeaderFileSystem.destroy(); + t.end(); +}); + +/** + * Creates an in-memory ZIP whose selected header requires missing ZIP64 size data. + * @param malformedHeader header to mark with ZIP64 sentinels + * @returns malformed ZIP archive bytes + */ +function createMalformedZip64Archive( + malformedHeader: 'central-directory' | 'local-header' +): ArrayBuffer { + const fileName = 'test.json'; + const localHeader = generateLocalHeader({crc32: 0, fileName, length: 0}); + const centralDirectoryHeader = generateCDHeader({ + crc32: 0, + fileName, + length: 0, + offset: 0n + }); + + const header = + malformedHeader === 'local-header' + ? new DataView(localHeader) + : new DataView(centralDirectoryHeader); + const compressedSizeOffset = malformedHeader === 'local-header' ? 18 : 20; + const uncompressedSizeOffset = malformedHeader === 'local-header' ? 22 : 24; + header.setUint32(compressedSizeOffset, 0xffffffff, true); + header.setUint32(uncompressedSizeOffset, 0xffffffff, true); + + const centralDirectoryOffset = BigInt(localHeader.byteLength); + const endOfCentralDirectoryOffset = + centralDirectoryOffset + BigInt(centralDirectoryHeader.byteLength); + const endOfCentralDirectory = generateEoCD({ + recordsNumber: 1, + cdSize: centralDirectoryHeader.byteLength, + cdOffset: centralDirectoryOffset, + eoCDStart: endOfCentralDirectoryOffset + }); + + return concatenateArrayBuffers(localHeader, centralDirectoryHeader, endOfCentralDirectory); +} diff --git a/modules/zip/test/zip-utils/cd-file-header.spec.ts b/modules/zip/test/zip-utils/cd-file-header.spec.ts index 1d42fd8615..3dfedf70ba 100644 --- a/modules/zip/test/zip-utils/cd-file-header.spec.ts +++ b/modules/zip/test/zip-utils/cd-file-header.spec.ts @@ -40,20 +40,19 @@ test('SLPKLoader#zip64 info generation', async t => { t.end(); }); -test('SLPKLoader#central directory file header parse with zip64 sentinel and empty extra field', async t => { +test('SLPKLoader#central directory file header rejects missing zip64 extra field', async t => { const header = generateCDHeader({crc32: 0, fileName: 'test.json', length: 0, offset: 0n}); const view = new DataView(header); view.setUint32(20, 0xffffffff, true); view.setUint32(24, 0xffffffff, true); - const cdFileHeader = await parseZipCDFileHeader(0n, new DataViewReadableFile(view)); - t.equal(cdFileHeader?.fileName, 'test.json'); - t.equal(cdFileHeader?.uncompressedSize, BigInt(0xffffffff)); - t.equal(cdFileHeader?.compressedSize, BigInt(0xffffffff)); - t.equal(cdFileHeader?.extraFieldLength, 0); + await t.rejects( + parseZipCDFileHeader(0n, new DataViewReadableFile(view)), + /Invalid ZIP archive:.*ZIP64/ + ); t.end(); }); -test('SLPKLoader#central directory file header parse with truncated zip64 extra field', async t => { +test('SLPKLoader#central directory file header rejects truncated zip64 extra field', async t => { const header = generateCDHeader({crc32: 0, fileName: 'test.json', length: 0, offset: 0n}); const view = new DataView(header); view.setUint32(20, 0xffffffff, true); @@ -63,13 +62,10 @@ test('SLPKLoader#central directory file header parse with truncated zip64 extra const buffer = new Uint8Array(header.byteLength + extra.byteLength); buffer.set(new Uint8Array(header), 0); buffer.set(extra, header.byteLength); - const cdFileHeader = await parseZipCDFileHeader( - 0n, - new DataViewReadableFile(new DataView(buffer.buffer)) + await t.rejects( + parseZipCDFileHeader(0n, new DataViewReadableFile(new DataView(buffer.buffer))), + /Invalid ZIP archive:.*ZIP64/ ); - t.equal(cdFileHeader?.uncompressedSize, BigInt(0xffffffff)); - t.equal(cdFileHeader?.fileName, 'test.json'); - t.equal(cdFileHeader?.extraFieldLength, 8); t.end(); }); @@ -91,6 +87,38 @@ test('SLPKLoader#central directory file header parse with valid zip64 extra fiel t.end(); }); +test('SLPKLoader#central directory file header parses all zip64 field widths', async t => { + const header = generateCDHeader({crc32: 0, fileName: 'test.json', length: 0, offset: 0n}); + const view = new DataView(header); + view.setUint32(20, 0xffffffff, true); + view.setUint32(24, 0xffffffff, true); + view.setUint16(30, 32, true); + view.setUint16(34, 0xffff, true); + view.setUint32(42, 0xffffffff, true); + + const extra = new DataView(new ArrayBuffer(32)); + extra.setUint16(0, 0x0001, true); + extra.setUint16(2, 28, true); + extra.setBigUint64(4, 0x112233445566n, true); + extra.setBigUint64(12, 0x223344556677n, true); + extra.setBigUint64(20, 0x334455667788n, true); + extra.setUint32(28, 0x12345678, true); + + const buffer = new Uint8Array(header.byteLength + extra.byteLength); + buffer.set(new Uint8Array(header), 0); + buffer.set(new Uint8Array(extra.buffer), header.byteLength); + const cdFileHeader = await parseZipCDFileHeader( + 0n, + new DataViewReadableFile(new DataView(buffer.buffer)) + ); + + t.equal(cdFileHeader?.uncompressedSize, 0x112233445566n); + t.equal(cdFileHeader?.compressedSize, 0x223344556677n); + t.equal(cdFileHeader?.localHeaderOffset, 0x334455667788n); + t.equal(cdFileHeader?.startDisk, 0x12345678n); + t.end(); +}); + test('SLPKLoader#central directory file header parse with zip64 extra field after unrelated record', async t => { const header = generateCDHeader({crc32: 0, fileName: 'test.json', length: 0, offset: 0n}); const view = new DataView(header); diff --git a/modules/zip/test/zip-utils/local-file-header.spec.ts b/modules/zip/test/zip-utils/local-file-header.spec.ts index fc7900b47e..1b707ed598 100644 --- a/modules/zip/test/zip-utils/local-file-header.spec.ts +++ b/modules/zip/test/zip-utils/local-file-header.spec.ts @@ -4,6 +4,7 @@ import test from 'tape-promise/tape'; import {DATA_ARRAY} from '@loaders.gl/i3s/test/data/test.zip'; +import {concatenateArrayBuffers} from '@loaders.gl/loader-utils'; import {DataViewReadableFile} from '../../src/parse-zip/readable-file-utils'; import {generateLocalHeader, parseZipLocalFileHeader} from '../../src/parse-zip/local-file-header'; @@ -27,3 +28,117 @@ test('SLPKLoader#central directory file header generation', async t => { t.equal(header.byteLength, 56); t.end(); }); + +test('SLPKLoader#local file header rejects missing zip64 extra field', async t => { + const header = generateLocalHeader({crc32: 0, fileName: 'test.json', length: 0}); + const view = new DataView(header); + view.setUint32(18, 0xffffffff, true); + view.setUint32(22, 0xffffffff, true); + + await t.rejects( + parseZipLocalFileHeader(0n, new DataViewReadableFile(view)), + /Invalid ZIP archive:.*ZIP64/ + ); + t.end(); +}); + +test('SLPKLoader#local file header rejects truncated zip64 extra field', async t => { + const header = generateLocalHeader({crc32: 0, fileName: 'test.json', length: 0}); + const view = new DataView(header); + view.setUint32(18, 0xffffffff, true); + view.setUint32(22, 0xffffffff, true); + view.setUint16(28, 8, true); + const extra = new Uint8Array([0x01, 0x00, 0x10, 0x00, 0, 0, 0, 0]); + const buffer = new Uint8Array(header.byteLength + extra.byteLength); + buffer.set(new Uint8Array(header), 0); + buffer.set(extra, header.byteLength); + + await t.rejects( + parseZipLocalFileHeader(0n, new DataViewReadableFile(new DataView(buffer.buffer))), + /Invalid ZIP archive:.*ZIP64/ + ); + t.end(); +}); + +test('SLPKLoader#local file header parses valid zip64 sizes', async t => { + const header = generateLocalHeader({ + crc32: 0, + fileName: 'test.json', + length: 0xffffffffff + }); + const localFileHeader = await parseZipLocalFileHeader( + 0n, + new DataViewReadableFile(new DataView(header)) + ); + + t.equal(localFileHeader?.compressedSize, 0xffffffffffn); + t.equal(localFileHeader?.extraFieldLength, 20); + t.equal(localFileHeader?.fileDataOffset, BigInt(header.byteLength)); + t.end(); +}); + +test('SLPKLoader#local file header parses only sentinel-backed zip64 fields', async t => { + const compressedHeader = generateLocalHeader({crc32: 0, fileName: 'test.json', length: 0}); + const compressedHeaderView = new DataView(compressedHeader); + compressedHeaderView.setUint32(18, 0xffffffff, true); + compressedHeaderView.setUint16(28, 12, true); + const compressedExtraField = new DataView(new ArrayBuffer(12)); + compressedExtraField.setUint16(0, 0x0001, true); + compressedExtraField.setUint16(2, 8, true); + compressedExtraField.setBigUint64(4, 0x112233445566n, true); + + const compressedFileHeader = await parseZipLocalFileHeader( + 0n, + new DataViewReadableFile( + new DataView(concatenateArrayBuffers(compressedHeader, compressedExtraField.buffer)) + ) + ); + t.equal(compressedFileHeader?.compressedSize, 0x112233445566n); + + const uncompressedHeader = generateLocalHeader({crc32: 0, fileName: 'test.json', length: 0}); + const uncompressedHeaderView = new DataView(uncompressedHeader); + uncompressedHeaderView.setUint32(22, 0xffffffff, true); + uncompressedHeaderView.setUint16(28, 12, true); + const uncompressedExtraField = new DataView(new ArrayBuffer(12)); + uncompressedExtraField.setUint16(0, 0x0001, true); + uncompressedExtraField.setUint16(2, 8, true); + uncompressedExtraField.setBigUint64(4, 0x223344556677n, true); + + const uncompressedFileHeader = await parseZipLocalFileHeader( + 0n, + new DataViewReadableFile( + new DataView(concatenateArrayBuffers(uncompressedHeader, uncompressedExtraField.buffer)) + ) + ); + t.equal(uncompressedFileHeader?.compressedSize, 0n); + t.end(); +}); + +test('SLPKLoader#local file header finds zip64 data after an unrelated record', async t => { + const header = generateLocalHeader({ + crc32: 0, + fileName: 'test.json', + length: 0xffffffffff + }); + const headerView = new DataView(header); + const fileNameEndOffset = 30 + headerView.getUint16(26, true); + const headerAndFileName = header.slice(0, fileNameEndOffset); + new DataView(headerAndFileName).setUint16(28, 28, true); + const unrelatedRecord = new Uint8Array([0x99, 0x99, 0x04, 0x00, 0x01, 0x00, 0x10, 0x00]); + const zip64ExtraField = header.slice(fileNameEndOffset); + const combinedHeader = concatenateArrayBuffers( + headerAndFileName, + unrelatedRecord.buffer, + zip64ExtraField + ); + + const localFileHeader = await parseZipLocalFileHeader( + 0n, + new DataViewReadableFile(new DataView(combinedHeader)) + ); + + t.equal(localFileHeader?.compressedSize, 0xffffffffffn); + t.equal(localFileHeader?.extraFieldLength, 28); + t.equal(localFileHeader?.fileDataOffset, BigInt(combinedHeader.byteLength)); + t.end(); +});