@zip.js/zip.js 2.8.29 → 2.8.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/zip-core.js CHANGED
@@ -239,18 +239,30 @@
239
239
  EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
240
240
  */
241
241
 
242
- const table = [];
243
- for (let i = 0; i < 256; i++) {
244
- let t = i;
242
+ // Slicing-by-8 CRC-32 (Intel / zlib). The eight 256-entry tables let the inner loop
243
+ // consume 8 bytes per iteration with a shorter dependency chain, ~4x the byte-at-a-time
244
+ // rate (measured ~320 -> ~1400 MB/s on 64KB chunks).
245
+ //
246
+ // Every table MUST stay a PACKED_SMI array: build with array literals (not `new Array(n)`,
247
+ // which is HOLEY) and store the signed int32 XOR result (no `>>> 0`). An unsigned or holey
248
+ // table becomes a V8 FixedDoubleArray whose every hot-loop lookup unboxes a double (~1.6x
249
+ // slower). Signedness is irrelevant to the result — the reads mask/shift it and the final
250
+ // `~crc` normalizes it. Do NOT reintroduce `>>> 0` here or switch to `new Array(256)`.
251
+ const T = [[], [], [], [], [], [], [], []];
252
+ for (let n = 0; n < 256; n++) {
253
+ let t = n;
245
254
  for (let j = 0; j < 8; j++) {
246
- if (t & 1) {
247
- t = (t >>> 1) ^ 0xEDB88320;
248
- } else {
249
- t = t >>> 1;
250
- }
255
+ t = (t & 1) ? (t >>> 1) ^ 0xEDB88320 : t >>> 1;
251
256
  }
252
- table[i] = t;
257
+ T[0][n] = t;
253
258
  }
259
+ for (let n = 0; n < 256; n++) {
260
+ for (let k = 1; k < 8; k++) {
261
+ const previous = T[k - 1][n];
262
+ T[k][n] = (previous >>> 8) ^ T[0][previous & 0xFF];
263
+ }
264
+ }
265
+ const [T0, T1, T2, T3, T4, T5, T6, T7] = T;
254
266
 
255
267
  class Crc32 {
256
268
 
@@ -260,8 +272,24 @@
260
272
 
261
273
  append(data) {
262
274
  let crc = this.crc | 0;
263
- for (let offset = 0, length = data.length | 0; offset < length; offset++) {
264
- crc = (crc >>> 8) ^ table[(crc ^ data[offset]) & 0xFF];
275
+ const length = data.length | 0;
276
+ let offset = 0;
277
+ // Process 8 bytes per iteration over the typed-array body. DataView.getInt32(le)
278
+ // reads an unaligned little-endian word as a signed int32 (no double boxing), so no
279
+ // alignment or endianness handling is needed; data.buffer guards non-typed inputs.
280
+ if (length >= 8 && data.buffer) {
281
+ const view = new DataView(data.buffer, data.byteOffset, length);
282
+ const end = length - 8;
283
+ for (; offset <= end; offset += 8) {
284
+ const a = crc ^ view.getInt32(offset, true);
285
+ const b = view.getInt32(offset + 4, true);
286
+ crc = T7[a & 0xFF] ^ T6[(a >>> 8) & 0xFF] ^ T5[(a >>> 16) & 0xFF] ^ T4[(a >>> 24) & 0xFF] ^
287
+ T3[b & 0xFF] ^ T2[(b >>> 8) & 0xFF] ^ T1[(b >>> 16) & 0xFF] ^ T0[(b >>> 24) & 0xFF];
288
+ }
289
+ }
290
+ // Remaining tail (and non-typed inputs) byte-at-a-time with the base table.
291
+ for (; offset < length; offset++) {
292
+ crc = (crc >>> 8) ^ T0[(crc ^ data[offset]) & 0xFF];
265
293
  }
266
294
  this.crc = crc;
267
295
  }
@@ -1695,21 +1723,40 @@
1695
1723
  const ERR_INVALID_COMPRESSED_DATA = "Invalid compressed data";
1696
1724
  const FORMAT_DEFLATE_RAW = "deflate-raw";
1697
1725
  const FORMAT_DEFLATE64_RAW = "deflate64-raw";
1726
+ const FORMAT_GZIP = "gzip";
1727
+ const GZIP_HEADER_LENGTH = 10;
1728
+ const GZIP_TRAILER_LENGTH = 8;
1698
1729
 
1699
1730
  class DeflateStream extends TransformStream {
1700
1731
 
1701
1732
  constructor(options, { chunkSize, CompressionStreamZlib, CompressionStream }) {
1702
1733
  super({});
1703
- const { compressed, encrypted, useCompressionStream, zipCrypto, signed, level } = options;
1734
+ const { compressed, encrypted, useCompressionStream, zipCrypto, signed, level, deflate64 } = options;
1704
1735
  const stream = this;
1705
- let crc32Stream, encryptionStream;
1736
+ let crc32Stream, encryptionStream, gzipCrc32Stream;
1706
1737
  let readable = super.readable;
1707
- if ((!encrypted || zipCrypto) && signed) {
1738
+ // The gzip trailer carries a CRC-32 of the uncompressed data (same polynomial as zip),
1739
+ // computed in native code for free during compression. On the native CompressionStream,
1740
+ // harvest it instead of running a separate CRC pass: compress as gzip, then strip the
1741
+ // fixed 10-byte header and 8-byte trailer to recover the exact raw-deflate payload
1742
+ // (byte-identical to a deflate-raw compression) while capturing the CRC. The CRC becomes
1743
+ // available at end-of-stream, exactly like Crc32Stream, so nothing downstream (data
1744
+ // descriptor, central directory, ZipCrypto) needs it any earlier. Not applied for the
1745
+ // pure-JS/WASM ports (a separate slice-by-8 CRC pass is as fast or faster there).
1746
+ const useGzipCrc32 = signed && compressed && !deflate64 && (!encrypted || zipCrypto) &&
1747
+ Boolean(useCompressionStream && CompressionStream);
1748
+ if ((!encrypted || zipCrypto) && signed && !useGzipCrc32) {
1708
1749
  crc32Stream = new Crc32Stream();
1709
1750
  readable = pipeThrough(readable, crc32Stream);
1710
1751
  }
1711
1752
  if (compressed) {
1712
- readable = pipeThroughCommpressionStream(readable, useCompressionStream, { level, chunkSize }, CompressionStream, CompressionStreamZlib, CompressionStream);
1753
+ if (useGzipCrc32) {
1754
+ gzipCrc32Stream = new GzipToRawDeflateStream();
1755
+ readable = pipeThroughBackpressured(readable, new CompressionStream(FORMAT_GZIP));
1756
+ readable = pipeThrough(readable, gzipCrc32Stream);
1757
+ } else {
1758
+ readable = pipeThroughCommpressionStream(readable, useCompressionStream, { level, chunkSize }, CompressionStream, CompressionStreamZlib, CompressionStream);
1759
+ }
1713
1760
  }
1714
1761
  if (encrypted) {
1715
1762
  if (zipCrypto) {
@@ -1725,13 +1772,74 @@
1725
1772
  signature = encryptionStream.signature;
1726
1773
  }
1727
1774
  if ((!encrypted || zipCrypto) && signed) {
1728
- signature = new DataView(crc32Stream.value.buffer).getUint32(0);
1775
+ signature = useGzipCrc32 ? gzipCrc32Stream.signature : new DataView(crc32Stream.value.buffer).getUint32(0);
1729
1776
  }
1730
1777
  stream.signature = signature;
1731
1778
  });
1732
1779
  }
1733
1780
  }
1734
1781
 
1782
+ // Converts a gzip stream into its raw-deflate payload while capturing the CRC-32 from the gzip
1783
+ // trailer. The CompressionStream gzip header is always the fixed 10-byte form (FLG=0, no optional
1784
+ // fields) per the Compression Streams spec, so it is stripped by length; the trailer is the last
1785
+ // 8 bytes (CRC-32 LE, then ISIZE LE). Bounded memory: at most GZIP_TRAILER_LENGTH bytes are held
1786
+ // back across chunks. The signature is read big-endian-agnostically as a number, matching the
1787
+ // value Crc32Stream produces, so the writer path is unchanged.
1788
+ class GzipToRawDeflateStream extends TransformStream {
1789
+
1790
+ constructor() {
1791
+ // deno-lint-ignore prefer-const
1792
+ let stream;
1793
+ let headerLeft = GZIP_HEADER_LENGTH;
1794
+ let tail = new Uint8Array(0);
1795
+ super({
1796
+ transform(chunk, controller) {
1797
+ if (headerLeft) {
1798
+ const dropped = Math.min(headerLeft, chunk.length);
1799
+ headerLeft -= dropped;
1800
+ chunk = chunk.subarray(dropped);
1801
+ if (!chunk.length) {
1802
+ return;
1803
+ }
1804
+ }
1805
+ const available = tail.length + chunk.length;
1806
+ if (available <= GZIP_TRAILER_LENGTH) {
1807
+ const pending = new Uint8Array(available);
1808
+ pending.set(tail);
1809
+ pending.set(chunk, tail.length);
1810
+ tail = pending;
1811
+ return;
1812
+ }
1813
+ // Emit everything except the trailing GZIP_TRAILER_LENGTH bytes as a standalone,
1814
+ // right-sized Uint8Array. Consumers may read chunk.buffer directly (e.g. custom
1815
+ // writers), so an aliased subarray of a larger buffer would leak the held-back
1816
+ // trailer bytes. Bytes are copied exactly once, into `output` or `tail`.
1817
+ const emitLength = available - GZIP_TRAILER_LENGTH;
1818
+ const output = new Uint8Array(emitLength);
1819
+ const fromTail = Math.min(emitLength, tail.length);
1820
+ output.set(tail.subarray(0, fromTail), 0);
1821
+ if (emitLength > fromTail) {
1822
+ output.set(chunk.subarray(0, emitLength - fromTail), fromTail);
1823
+ }
1824
+ controller.enqueue(output);
1825
+ const nextTail = new Uint8Array(GZIP_TRAILER_LENGTH);
1826
+ const tailRemaining = tail.length - fromTail;
1827
+ if (tailRemaining) {
1828
+ nextTail.set(tail.subarray(fromTail), 0);
1829
+ }
1830
+ nextTail.set(chunk.subarray(emitLength - fromTail), tailRemaining);
1831
+ tail = nextTail;
1832
+ },
1833
+ flush() {
1834
+ const dataView = new DataView(tail.buffer, tail.byteOffset, tail.byteLength);
1835
+ stream.signature = dataView.getUint32(0, true);
1836
+ stream.uncompressedSize = dataView.getUint32(4, true);
1837
+ }
1838
+ });
1839
+ stream = this;
1840
+ }
1841
+ }
1842
+
1735
1843
  class InflateStream extends TransformStream {
1736
1844
 
1737
1845
  constructor(options, { chunkSize, DecompressionStreamZlib, DecompressionStream }) {
@@ -3693,6 +3801,11 @@
3693
3801
  const OPTION_OFFSET = "offset";
3694
3802
  const OPTION_USDZ = "usdz";
3695
3803
  const OPTION_UNIX_EXTRA_FIELD_TYPE = "unixExtraFieldType";
3804
+ const OPTION_STRICTNESS = "strictness";
3805
+ const OPTION_MAX_APPENDED_DATA_SIZE = "maxAppendedDataSize";
3806
+ const STRICTNESS_STRICT = "strict";
3807
+ const STRICTNESS_BALANCED = "balanced";
3808
+ const STRICTNESS_TOLERANT = "tolerant";
3696
3809
 
3697
3810
  /*
3698
3811
  Copyright (c) 2025 Gildas Lormeau. All rights reserved.
@@ -3782,7 +3895,11 @@
3782
3895
  throw new Error(ERR_BAD_FORMAT);
3783
3896
  }
3784
3897
  reader.chunkSize = getChunkSize(config);
3785
- const endOfDirectoryInfo = await seekSignature(reader, END_OF_CENTRAL_DIR_SIGNATURE, reader.size, END_OF_CENTRAL_DIR_LENGTH, MAX_16_BITS * 16);
3898
+ const strictness = getStrictness(getOptionValue$1(zipReader, options, OPTION_STRICTNESS), getOptionValue$1(zipReader, options, OPTION_CHECK_AMBIGUITY));
3899
+ const checkAmbiguity = strictness == STRICTNESS_STRICT;
3900
+ const rejectAmbiguousEndOfDirectory = strictness != STRICTNESS_TOLERANT;
3901
+ const maxAppendedDataSize = getMaxAppendedDataSize(getOptionValue$1(zipReader, options, OPTION_MAX_APPENDED_DATA_SIZE), strictness);
3902
+ const { endOfDirectoryInfo, endOfDirectoryReachingEndCount } = await findEndOfCentralDirectory(reader, rejectAmbiguousEndOfDirectory, maxAppendedDataSize);
3786
3903
  if (!endOfDirectoryInfo) {
3787
3904
  const signatureArray = await readUint8Array(reader, 0, 4);
3788
3905
  const signatureView = getDataView$1(signatureArray);
@@ -3792,14 +3909,18 @@
3792
3909
  throw new Error(ERR_EOCDR_NOT_FOUND);
3793
3910
  }
3794
3911
  }
3912
+ // two or more end-anchored records that each dereference to a valid central directory cannot be told
3913
+ // apart (see comment_length ⟺ EOF): the archive is genuinely ambiguous, so refuse rather than guess
3914
+ if (rejectAmbiguousEndOfDirectory && endOfDirectoryReachingEndCount > 1) {
3915
+ throwAmbiguousArchive("multiple end of central directory records");
3916
+ }
3795
3917
  const endOfDirectoryView = getDataView$1(endOfDirectoryInfo);
3796
3918
  let directoryDataLength = getUint32(endOfDirectoryView, 12);
3797
3919
  let directoryDataOffset = getUint32(endOfDirectoryView, 16);
3798
3920
  const commentOffset = endOfDirectoryInfo.offset;
3799
3921
  const commentLength = getUint16(endOfDirectoryView, 20);
3800
3922
  const appendedDataOffset = commentOffset + END_OF_CENTRAL_DIR_LENGTH + commentLength;
3801
- const checkAmbiguity = getOptionValue$1(zipReader, options, OPTION_CHECK_AMBIGUITY);
3802
- if (checkAmbiguity && appendedDataOffset != reader.size) {
3923
+ if (reader.size - appendedDataOffset > maxAppendedDataSize) {
3803
3924
  throwAmbiguousArchive("appended data");
3804
3925
  }
3805
3926
  let lastDiskNumber = getUint16(endOfDirectoryView, 4);
@@ -3878,14 +3999,28 @@
3878
3999
  throw new Error(ERR_BAD_FORMAT);
3879
4000
  }
3880
4001
  const expectedDirectoryDataOffset = centralDirectoryEndOffset - directoryDataLength - (reader.lastDiskOffset || 0);
3881
- if (getUint32(directoryView, offset) != CENTRAL_FILE_HEADER_SIGNATURE && directoryDataOffset != expectedDirectoryDataOffset && diskNumber == lastDiskNumber) {
3882
- const originalDirectoryDataOffset = directoryDataOffset;
3883
- directoryDataOffset = expectedDirectoryDataOffset;
3884
- if (directoryDataOffset > originalDirectoryDataOffset) {
3885
- prependedDataLength += directoryDataOffset - originalDirectoryDataOffset;
4002
+ if (directoryDataOffset != expectedDirectoryDataOffset && diskNumber == lastDiskNumber) {
4003
+ // the reconciled offset (the directory ends exactly where the canonical record begins) is anchored
4004
+ // to the unforgeable end of the archive; the stored offset is not. Prefer the reconciled offset
4005
+ // unless the stored one points at a directory and the reconciled one does not — that exception
4006
+ // keeps a corrupt declared directory length from sending the read astray, while still moving off a
4007
+ // stored offset that only looks valid because an append remnant left the previous (identically
4008
+ // laid out) directory sitting there.
4009
+ const storedPointsAtDirectory = getUint32(directoryView, offset) == CENTRAL_FILE_HEADER_SIGNATURE;
4010
+ let reconcile = !storedPointsAtDirectory;
4011
+ if (!reconcile && expectedDirectoryDataOffset >= 0 && expectedDirectoryDataOffset + 4 <= reader.size) {
4012
+ const expectedSignatureArray = await readUint8Array(reader, expectedDirectoryDataOffset, 4, diskNumber);
4013
+ reconcile = getUint32(getDataView$1(expectedSignatureArray), 0) == CENTRAL_FILE_HEADER_SIGNATURE;
4014
+ }
4015
+ if (reconcile) {
4016
+ const originalDirectoryDataOffset = directoryDataOffset;
4017
+ directoryDataOffset = expectedDirectoryDataOffset;
4018
+ if (directoryDataOffset > originalDirectoryDataOffset) {
4019
+ prependedDataLength += directoryDataOffset - originalDirectoryDataOffset;
4020
+ }
4021
+ directoryArray = await readUint8Array(reader, directoryDataOffset, directoryDataLength, diskNumber);
4022
+ directoryView = getDataView$1(directoryArray);
3886
4023
  }
3887
- directoryArray = await readUint8Array(reader, directoryDataOffset, directoryDataLength, diskNumber);
3888
- directoryView = getDataView$1(directoryArray);
3889
4024
  }
3890
4025
  }
3891
4026
  const expectedDirectoryDataLength = centralDirectoryEndOffset - directoryDataOffset - (reader.lastDiskOffset || 0);
@@ -4142,7 +4277,7 @@
4142
4277
  extraFieldLength,
4143
4278
  filenameLength
4144
4279
  } = localDirectory;
4145
- const checkAmbiguity = getOptionValue$1(zipEntry, options, OPTION_CHECK_AMBIGUITY);
4280
+ const checkAmbiguity = getStrictness(getOptionValue$1(zipEntry, options, OPTION_STRICTNESS), getOptionValue$1(zipEntry, options, OPTION_CHECK_AMBIGUITY)) == STRICTNESS_STRICT;
4146
4281
  let rawLocalFilename = new Uint8Array();
4147
4282
  if (checkAmbiguity && (filenameLength || extraFieldLength)) {
4148
4283
  const trailingDataArray = await readUint8Array(reader, offset + HEADER_SIZE, filenameLength + extraFieldLength, diskNumberStart);
@@ -4592,26 +4727,191 @@
4592
4727
  readRanges.set(index, range);
4593
4728
  }
4594
4729
 
4595
- async function seekSignature(reader, signature, startOffset, minimumBytes, maximumLength) {
4596
- const signatureArray = new Uint8Array(4);
4597
- const signatureView = getDataView$1(signatureArray);
4598
- setUint32$1(signatureView, 0, signature);
4599
- const maximumBytes = minimumBytes + maximumLength;
4600
- return (await seek(minimumBytes)) || await seek(Math.min(maximumBytes, startOffset));
4601
-
4602
- async function seek(length) {
4603
- const offset = startOffset - length;
4604
- const bytes = await readUint8Array(reader, offset, length);
4605
- for (let indexByte = bytes.length - minimumBytes; indexByte >= 0; indexByte--) {
4606
- if (bytes[indexByte] == signatureArray[0] && bytes[indexByte + 1] == signatureArray[1] &&
4607
- bytes[indexByte + 2] == signatureArray[2] && bytes[indexByte + 3] == signatureArray[3]) {
4608
- return {
4609
- offset: offset + indexByte,
4610
- buffer: bytes.slice(indexByte, indexByte + minimumBytes).buffer
4611
- };
4730
+ function getStrictness(strictness, checkAmbiguity) {
4731
+ if (strictness === UNDEFINED_VALUE) {
4732
+ // `checkAmbiguity: true` is kept as a backward-compatible alias for the strictest mode
4733
+ return checkAmbiguity ? STRICTNESS_STRICT : STRICTNESS_BALANCED;
4734
+ }
4735
+ return strictness;
4736
+ }
4737
+
4738
+ function getMaxAppendedDataSize(maxAppendedDataSize, strictness) {
4739
+ if (maxAppendedDataSize !== UNDEFINED_VALUE) {
4740
+ return maxAppendedDataSize;
4741
+ }
4742
+ if (strictness == STRICTNESS_STRICT) {
4743
+ // the comment must reach the end of the file: no trailing data tolerated
4744
+ return 0;
4745
+ }
4746
+ if (strictness == STRICTNESS_TOLERANT) {
4747
+ return Infinity;
4748
+ }
4749
+ // balanced: tolerate up to a legal comment's worth of trailing data (a 16-bit length); beyond that the
4750
+ // trailing bytes cannot hide inside a comment, so they are treated as a second thing bolted onto the file
4751
+ return MAX_16_BITS;
4752
+ }
4753
+
4754
+ // cap on how many out-of-window central directory probes the scan below may issue. A legitimate archive needs
4755
+ // at most a couple (the canonical record, plus one more to detect genuine ambiguity); every other reachability
4756
+ // check is served from the tail window already in memory. The cap keeps an archive whose comment is stuffed
4757
+ // with unreachable end-anchored records from forcing an unbounded number of (potentially remote) reads.
4758
+ const MAX_END_OF_CENTRAL_DIR_PROBES = 64;
4759
+
4760
+ // reachability rankings for an end-anchored candidate record (see getCentralDirectoryReachability)
4761
+ const CENTRAL_DIRECTORY_UNREACHABLE = 0;
4762
+ const CENTRAL_DIRECTORY_PLAUSIBLE = 1;
4763
+ const CENTRAL_DIRECTORY_REACHABLE = 2;
4764
+
4765
+ // Locates the authoritative end of central directory record. The canonical record is the last one whose
4766
+ // declared comment reaches exactly the end of the file ("end-anchored") and that points to a central directory
4767
+ // ("reachable"); earlier signatures (stale append remnants, bytes embedded in a comment) are ignored. When two
4768
+ // or more end-anchored records point to a central directory the archive is ambiguous, and the count is returned
4769
+ // so the caller can refuse it. A record that reaches the end of the file but points to no central directory (an
4770
+ // empty archive) is only "plausible": it cannot be corroborated, so it never counts towards ambiguity and is
4771
+ // chosen only when no reachable record exists — otherwise an empty record forged in a comment could outrank the
4772
+ // genuine directory. When no record is end-anchored (appended data or an oversized comment), falls back to the
4773
+ // last record within the tolerated window that points to a directory (see seekEndOfCentralDirectory).
4774
+ async function findEndOfCentralDirectory(reader, rejectAmbiguous, maxAppendedDataSize) {
4775
+ const { size } = reader;
4776
+ // an end-anchored record can only live within the last END_OF_CENTRAL_DIR_LENGTH + MAX_16_BITS bytes,
4777
+ // since the comment length that ties it to the end of the file is a 16-bit field
4778
+ const anchoredLength = Math.min(size, END_OF_CENTRAL_DIR_LENGTH + MAX_16_BITS);
4779
+ const anchoredOffset = size - anchoredLength;
4780
+ const anchoredArray = await readUint8Array(reader, anchoredOffset, anchoredLength);
4781
+ const anchoredView = getDataView$1(anchoredArray);
4782
+ const remoteProbeBudget = { count: MAX_END_OF_CENTRAL_DIR_PROBES };
4783
+ let endOfDirectoryInfo;
4784
+ let plausibleEndOfDirectoryInfo;
4785
+ let endOfDirectoryReachingEndCount = 0;
4786
+ for (let indexByte = anchoredArray.length - END_OF_CENTRAL_DIR_LENGTH; indexByte >= 0; indexByte--) {
4787
+ if (getUint32(anchoredView, indexByte) == END_OF_CENTRAL_DIR_SIGNATURE) {
4788
+ const offset = anchoredOffset + indexByte;
4789
+ const commentLength = getUint16(anchoredView, indexByte + 20);
4790
+ // end-anchored: the declared comment extends exactly to the end of the file. The comment length is
4791
+ // attacker-controlled, but the reconciliation against the (unforgeable) end of file is not.
4792
+ if (offset + END_OF_CENTRAL_DIR_LENGTH + commentLength == size) {
4793
+ const reachability = await getCentralDirectoryReachability(reader, anchoredView, anchoredOffset, indexByte, offset, size, remoteProbeBudget);
4794
+ if (reachability == CENTRAL_DIRECTORY_REACHABLE) {
4795
+ if (!endOfDirectoryInfo) {
4796
+ endOfDirectoryInfo = {
4797
+ offset,
4798
+ buffer: anchoredArray.slice(indexByte, indexByte + END_OF_CENTRAL_DIR_LENGTH).buffer
4799
+ };
4800
+ }
4801
+ endOfDirectoryReachingEndCount++;
4802
+ // the canonical record (highest offset) is found first; a second one is enough to flag ambiguity
4803
+ if (!rejectAmbiguous || endOfDirectoryReachingEndCount > 1) {
4804
+ break;
4805
+ }
4806
+ } else if (reachability == CENTRAL_DIRECTORY_PLAUSIBLE && !plausibleEndOfDirectoryInfo) {
4807
+ plausibleEndOfDirectoryInfo = {
4808
+ offset,
4809
+ buffer: anchoredArray.slice(indexByte, indexByte + END_OF_CENTRAL_DIR_LENGTH).buffer
4810
+ };
4811
+ }
4812
+ }
4813
+ }
4814
+ }
4815
+ if (!endOfDirectoryInfo) {
4816
+ // no record points to a directory: prefer a plausible (empty) end-anchored record before scanning the
4817
+ // tolerated window for a bare signature
4818
+ endOfDirectoryInfo = plausibleEndOfDirectoryInfo;
4819
+ }
4820
+ if (!endOfDirectoryInfo) {
4821
+ endOfDirectoryInfo = await seekEndOfCentralDirectory(reader, maxAppendedDataSize, remoteProbeBudget);
4822
+ }
4823
+ return { endOfDirectoryInfo, endOfDirectoryReachingEndCount };
4824
+ }
4825
+
4826
+ // Fallback for findEndOfCentralDirectory when no record is end-anchored: appended data or an oversized comment
4827
+ // pushed the record's declared comment past the end of the file, so its position can no longer be reconciled
4828
+ // against the end of the file. Scans the tolerated window from the end and returns the first record that points
4829
+ // to a central directory, so a stray signature embedded in appended data cannot hijack discovery (which the
4830
+ // anchored scan already prevents for anchored records). Falls back to the first plausible (empty) record, then
4831
+ // to the last bare signature, so a degenerate archive still opens as before. The window ends at the file, so the
4832
+ // same in-window / metered reads as the anchored scan apply.
4833
+ async function seekEndOfCentralDirectory(reader, maxAppendedDataSize, remoteProbeBudget) {
4834
+ const { size } = reader;
4835
+ const searchLength = Math.min(size, maxAppendedDataSize == Infinity ? size :
4836
+ END_OF_CENTRAL_DIR_LENGTH + MAX_16_BITS + maxAppendedDataSize);
4837
+ const searchOffset = size - searchLength;
4838
+ const searchArray = await readUint8Array(reader, searchOffset, searchLength);
4839
+ const searchView = getDataView$1(searchArray);
4840
+ let firstSignatureInfo, plausibleInfo;
4841
+ for (let indexByte = searchArray.length - END_OF_CENTRAL_DIR_LENGTH; indexByte >= 0; indexByte--) {
4842
+ if (getUint32(searchView, indexByte) == END_OF_CENTRAL_DIR_SIGNATURE) {
4843
+ const offset = searchOffset + indexByte;
4844
+ const record = { offset, buffer: searchArray.slice(indexByte, indexByte + END_OF_CENTRAL_DIR_LENGTH).buffer };
4845
+ if (!firstSignatureInfo) {
4846
+ firstSignatureInfo = record;
4612
4847
  }
4848
+ const reachability = await getCentralDirectoryReachability(reader, searchView, searchOffset, indexByte, offset, size, remoteProbeBudget);
4849
+ if (reachability == CENTRAL_DIRECTORY_REACHABLE) {
4850
+ return record;
4851
+ }
4852
+ if (reachability == CENTRAL_DIRECTORY_PLAUSIBLE && !plausibleInfo) {
4853
+ plausibleInfo = record;
4854
+ }
4855
+ }
4856
+ }
4857
+ return plausibleInfo || firstSignatureInfo;
4858
+ }
4859
+
4860
+ // Ranks how strongly an end-anchored candidate record is backed by an actual central directory, used to decide
4861
+ // which record is canonical and which count towards ambiguity. It is intentionally conservative and does not
4862
+ // fully parse the directory (the caller does that for the canonical record):
4863
+ // - REACHABLE: the record dereferences a central directory (a central file header signature at the
4864
+ // reconciled offset), or, for a zip64 record, the zip64 locator that a genuine zip64 archive places
4865
+ // immediately before it. Only these count towards ambiguity.
4866
+ // - PLAUSIBLE: the record describes an empty archive, which has no central directory to dereference. Its
4867
+ // fields are indistinguishable from an empty end of central directory record forged inside a comment, so it
4868
+ // is accepted only as a last resort and never inflates the ambiguity count.
4869
+ // - UNREACHABLE: neither — stale append remnant or signature bytes embedded in a comment.
4870
+ // Signatures are read from the tail window (`view`, spanning `[anchoredOffset, size)`) when they fall inside it;
4871
+ // only a target below the window costs a (possibly remote) read, metered by `remoteProbeBudget` so a stuffed
4872
+ // comment cannot amplify into an unbounded number of requests.
4873
+ async function getCentralDirectoryReachability(reader, view, anchoredOffset, indexByte, offset, size, remoteProbeBudget) {
4874
+ const filesLength = getUint16(view, indexByte + 10);
4875
+ const directoryDataLength = getUint32(view, indexByte + 12);
4876
+ const directoryDataOffset = getUint32(view, indexByte + 16);
4877
+ if (filesLength == MAX_16_BITS || directoryDataLength == MAX_32_BITS || directoryDataOffset == MAX_32_BITS) {
4878
+ // saturated fields imply a zip64 record; corroborate it with the zip64 end of central directory locator
4879
+ // that sits immediately before it, so saturated bytes inside a comment cannot masquerade as one
4880
+ const locatorSignature = await readSignature(reader, view, anchoredOffset, offset - ZIP64_END_OF_CENTRAL_DIR_LOCATOR_LENGTH, size, remoteProbeBudget);
4881
+ return locatorSignature == ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIGNATURE ? CENTRAL_DIRECTORY_REACHABLE : CENTRAL_DIRECTORY_UNREACHABLE;
4882
+ }
4883
+ if (!filesLength && !directoryDataLength) {
4884
+ // a valid empty archive has no central directory to dereference
4885
+ return CENTRAL_DIRECTORY_PLAUSIBLE;
4886
+ }
4887
+ // the central directory ends where this record starts; reconcile the stored offset against that actual
4888
+ // position (see stored_cd_offset ⟺ eocd_pos − cd_size) so prepended data does not hide the directory
4889
+ for (const centralDirectoryOffset of [offset - directoryDataLength, directoryDataOffset]) {
4890
+ if (await readSignature(reader, view, anchoredOffset, centralDirectoryOffset, size, remoteProbeBudget) == CENTRAL_FILE_HEADER_SIGNATURE) {
4891
+ return CENTRAL_DIRECTORY_REACHABLE;
4613
4892
  }
4614
4893
  }
4894
+ return CENTRAL_DIRECTORY_UNREACHABLE;
4895
+ }
4896
+
4897
+ // Reads a 4-byte little-endian signature at `signatureOffset`, served from the tail window (`view`, spanning
4898
+ // `[anchoredOffset, size)`) when it falls inside it and otherwise from a metered (possibly remote) read. Returns
4899
+ // UNDEFINED_VALUE when the position is out of range or the out-of-window read budget is exhausted.
4900
+ async function readSignature(reader, view, anchoredOffset, signatureOffset, size, remoteProbeBudget) {
4901
+ if (signatureOffset < 0 || signatureOffset + 4 > size) {
4902
+ return UNDEFINED_VALUE;
4903
+ }
4904
+ if (signatureOffset >= anchoredOffset) {
4905
+ // the target sits inside the tail window already in memory: no extra read needed
4906
+ return getUint32(view, signatureOffset - anchoredOffset);
4907
+ }
4908
+ if (remoteProbeBudget.count > 0) {
4909
+ remoteProbeBudget.count--;
4910
+ const signatureArray = await readUint8Array(reader, signatureOffset, 4);
4911
+ return getUint32(getDataView$1(signatureArray), 0);
4912
+ }
4913
+ // out of budget: leave this candidate unverified rather than issue another read
4914
+ return UNDEFINED_VALUE;
4615
4915
  }
4616
4916
 
4617
4917
  function checkLocalDirectory(zipEntry, localDirectory, rawLocalFilename) {
@@ -4676,10 +4976,6 @@
4676
4976
  return Number(view.getBigUint64(offset, true));
4677
4977
  }
4678
4978
 
4679
- function setUint32$1(view, offset, value) {
4680
- view.setUint32(offset, value, true);
4681
- }
4682
-
4683
4979
  function getDataView$1(array) {
4684
4980
  return new DataView(array.buffer, array.byteOffset, array.byteLength);
4685
4981
  }
@@ -5469,6 +5765,17 @@
5469
5765
  if (releaseLockWriter) {
5470
5766
  releaseLockWriter();
5471
5767
  }
5768
+ // A buffered entry uses a temporary stream (see `createTempStream`). When that stream is
5769
+ // backed by a resource (a file, an OPFS handle, ...) it must be released on every exit path,
5770
+ // including the error path where the stream is otherwise abandoned without being closed or
5771
+ // cancelled. `dispose()` is optional and best-effort so it never masks the original outcome.
5772
+ if (bufferedWrite && fileWriter && fileWriter.dispose) {
5773
+ try {
5774
+ await fileWriter.dispose();
5775
+ } catch {
5776
+ // ignored
5777
+ }
5778
+ }
5472
5779
  }
5473
5780
 
5474
5781
  function requestLockCurrentFileEntry() {
@@ -6506,6 +6813,177 @@
6506
6813
  */
6507
6814
 
6508
6815
 
6816
+ const DEFAULT_THRESHOLD = 1024 * 1024;
6817
+ const DEFAULT_DIRECTORY_NAME = ".zip.js-temp";
6818
+
6819
+ // Builds a `createTempStream` factory (see `ZipWriter`'s option of the same name) that spills the
6820
+ // data of a buffered entry to the Origin Private File System (OPFS) instead of keeping it in memory.
6821
+ //
6822
+ // It is meant for the buffered-write path (keep-order concurrent `add()`, non-seekable output, ...),
6823
+ // where the compressed data of an entry has to be held until the writer is free. In memory that data
6824
+ // grows with the entry size; here it is written to an OPFS file and streamed back, so peak memory
6825
+ // stays bounded.
6826
+ //
6827
+ // It is hybrid on purpose: an entry stays fully in memory until it exceeds `thresholdBytes`, and only
6828
+ // then spills to a file. Small entries never touch the disk, so a workload made of many tiny entries
6829
+ // keeps the speed of the in-memory default; only genuinely large entries pay for (and benefit from)
6830
+ // the file round-trip.
6831
+ //
6832
+ // OPFS is a browser/worker feature; there is no fallback here. Feature-detect
6833
+ // `navigator.storage.getDirectory` (or inject `getDirectory`) before using it, and let the writer use
6834
+ // its in-memory default elsewhere.
6835
+ //
6836
+ // Options:
6837
+ // thresholdBytes spill to a file once a buffered entry exceeds this size (default 1 MiB).
6838
+ // directoryName name of the OPFS sub-directory holding the temp files (default ".zip.js-temp").
6839
+ // getDirectory returns (or resolves to) the root `FileSystemDirectoryHandle`. Defaults to
6840
+ // `navigator.storage.getDirectory()`. Inject it to run inside a worker with a
6841
+ // pre-obtained handle, or to test against a mock.
6842
+ function createOPFSTempStream(options = {}) {
6843
+ const {
6844
+ thresholdBytes = DEFAULT_THRESHOLD,
6845
+ directoryName = DEFAULT_DIRECTORY_NAME,
6846
+ getDirectory = () => navigator.storage.getDirectory()
6847
+ } = options;
6848
+ // The temp directory is resolved once and shared by every entry of this factory.
6849
+ let directoryHandlePromise;
6850
+ function getTempDirectory() {
6851
+ if (!directoryHandlePromise) {
6852
+ directoryHandlePromise = Promise.resolve(getDirectory())
6853
+ .then(root => root.getDirectoryHandle(directoryName, { create: true }));
6854
+ }
6855
+ return directoryHandlePromise;
6856
+ }
6857
+ return async function () {
6858
+ const memoryChunks = [];
6859
+ let bufferedSize = 0;
6860
+ let spilled = false;
6861
+ let fileName, fileHandle, fileWriter, fileReader;
6862
+
6863
+ // Move whatever is buffered in memory to a fresh OPFS file, then keep writing to that file.
6864
+ // `FileSystemWritableFileStream` is a `WritableStream`, so it is driven through the standard
6865
+ // writer API rather than its non-standard `write()`/`seek()` convenience methods.
6866
+ async function spillToFile() {
6867
+ const directoryHandle = await getTempDirectory();
6868
+ fileName = crypto.randomUUID();
6869
+ fileHandle = await directoryHandle.getFileHandle(fileName, { create: true });
6870
+ fileWriter = (await fileHandle.createWritable()).getWriter();
6871
+ spilled = true;
6872
+ for (const chunk of memoryChunks) {
6873
+ await fileWriter.write(chunk);
6874
+ }
6875
+ // Release the in-memory copy; from now on data lives in the file.
6876
+ memoryChunks.length = 0;
6877
+ }
6878
+
6879
+ const writable = new WritableStream({
6880
+ async write(chunk) {
6881
+ if (spilled) {
6882
+ await fileWriter.write(chunk);
6883
+ } else {
6884
+ memoryChunks.push(chunk);
6885
+ bufferedSize += chunk.length;
6886
+ if (bufferedSize > thresholdBytes) {
6887
+ await spillToFile();
6888
+ }
6889
+ }
6890
+ },
6891
+ async close() {
6892
+ if (fileWriter) {
6893
+ await fileWriter.close();
6894
+ fileWriter = null;
6895
+ }
6896
+ }
6897
+ });
6898
+
6899
+ // The writer always closes `writable` before reading `readable`, so `spilled` is final by the
6900
+ // time `pull` runs. It is read lazily here (not in `start`, which runs at construction time,
6901
+ // before anything is written and before `spilled` is known).
6902
+ let memoryIndex = 0;
6903
+ const readable = new ReadableStream({
6904
+ async pull(controller) {
6905
+ if (spilled) {
6906
+ if (!fileReader) {
6907
+ const file = await fileHandle.getFile();
6908
+ fileReader = file.stream().getReader();
6909
+ }
6910
+ const { value, done } = await fileReader.read();
6911
+ if (done) {
6912
+ controller.close();
6913
+ } else {
6914
+ controller.enqueue(value);
6915
+ }
6916
+ } else if (memoryIndex < memoryChunks.length) {
6917
+ controller.enqueue(memoryChunks[memoryIndex++]);
6918
+ } else {
6919
+ controller.close();
6920
+ }
6921
+ },
6922
+ async cancel(reason) {
6923
+ if (fileReader) {
6924
+ await fileReader.cancel(reason);
6925
+ }
6926
+ }
6927
+ }, { highWaterMark: 0 });
6928
+ // highWaterMark 0: do not pull (and possibly close on an empty buffer) until the consumer
6929
+ // actually reads, which the writer only does once the data has been fully written.
6930
+
6931
+ // Called by the writer on every exit path (success, error, abort). Best-effort: it must never
6932
+ // throw. Closes any still-open file writer, then deletes the temp file.
6933
+ async function dispose() {
6934
+ if (fileWriter) {
6935
+ try {
6936
+ await fileWriter.close();
6937
+ } catch {
6938
+ // ignored
6939
+ }
6940
+ fileWriter = null;
6941
+ }
6942
+ if (fileName) {
6943
+ try {
6944
+ const directoryHandle = await getTempDirectory();
6945
+ await directoryHandle.removeEntry(fileName);
6946
+ } catch {
6947
+ // ignored
6948
+ }
6949
+ fileHandle = fileName = null;
6950
+ }
6951
+ memoryChunks.length = 0;
6952
+ }
6953
+
6954
+ return { writable, readable, dispose };
6955
+ };
6956
+ }
6957
+
6958
+ /*
6959
+ Copyright (c) 2025 Gildas Lormeau. All rights reserved.
6960
+
6961
+ Redistribution and use in source and binary forms, with or without
6962
+ modification, are permitted provided that the following conditions are met:
6963
+
6964
+ 1. Redistributions of source code must retain the above copyright notice,
6965
+ this list of conditions and the following disclaimer.
6966
+
6967
+ 2. Redistributions in binary form must reproduce the above copyright
6968
+ notice, this list of conditions and the following disclaimer in
6969
+ the documentation and/or other materials provided with the distribution.
6970
+
6971
+ 3. The names of the authors may not be used to endorse or promote products
6972
+ derived from this software without specific prior written permission.
6973
+
6974
+ THIS SOFTWARE IS PROVIDED ''AS IS'' AND ANY EXPRESSED OR IMPLIED WARRANTIES,
6975
+ INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
6976
+ FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL JCRAFT,
6977
+ INC. OR ANY CONTRIBUTORS TO THIS SOFTWARE BE LIABLE FOR ANY DIRECT, INDIRECT,
6978
+ INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
6979
+ LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,
6980
+ OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
6981
+ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
6982
+ NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
6983
+ EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
6984
+ */
6985
+
6986
+
6509
6987
  try {
6510
6988
  configure({ baseURI: (typeof document === 'undefined' && typeof location === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : typeof document === 'undefined' ? location.href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('zip-core.js', document.baseURI).href)) });
6511
6989
  } catch {
@@ -6560,6 +7038,7 @@
6560
7038
  exports.ZipWriter = ZipWriter;
6561
7039
  exports.ZipWriterStream = ZipWriterStream;
6562
7040
  exports.configure = configure;
7041
+ exports.createOPFSTempStream = createOPFSTempStream;
6563
7042
  exports.getMimeType = getMimeType;
6564
7043
  exports.terminateWorkers = terminateWorkers;
6565
7044