Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/modules/zip/formats/zip.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,9 @@
> The [`loaders.gl/zip`](/docs/modules/zip) module provides support for working with Zip Archives.

[ZIP Archive](<https://en.wikipedia.org/wiki/Zip_(file_format)>)

## 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.
105 changes: 30 additions & 75 deletions modules/zip/src/parse-zip/cd-file-header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 */
Expand All @@ -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 (
Expand Down Expand Up @@ -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<keyof Zip64CentralDirectoryData>[] = [];
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,
Expand Down Expand Up @@ -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<Zip64Data> => {
const zip64dataList = findExpectedData(zip64data);

const zip64DataRes: Partial<Zip64Data> = {};
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 */
Expand Down
41 changes: 27 additions & 14 deletions modules/zip/src/parse-zip/local-file-header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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<keyof Zip64LocalSizeData>[] = [];
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});
Comment on lines +98 to +102

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept the mandatory local ZIP64 size pair

When a local header has only the uncompressed legacy size set to 0xffffffff (for example, a highly compressible file whose original size exceeds 4 GiB but whose compressed size still fits in 32 bits), this builds an expected ZIP64 payload containing only uncompressedSize. ZIP64 local-header entries must include both original and compressed size values (PKWARE APPNOTE 4.5.3), so those archives carry a 16-byte ZIP64 payload and now fail fetch() with unexpected payload size even though the central directory can provide the compressed length. For local headers, request both size fields whenever either legacy size uses ZIP64.

Useful? React with 👍 / 👎.

}
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 {
Expand Down
84 changes: 84 additions & 0 deletions modules/zip/src/parse-zip/zip64-extra-field.ts
Original file line number Diff line number Diff line change
@@ -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<FieldName extends string> = {
/** 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<FieldName extends string>(
extraField: DataView,
expectedFields: readonly Zip64ExtraFieldDescription<FieldName>[]
): Partial<Record<FieldName, bigint>> {
const values: Partial<Record<FieldName, bigint>> = {};
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');
}
Loading
Loading