@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.
@@ -988,18 +988,30 @@
988
988
  EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
989
989
  */
990
990
 
991
- const table = [];
992
- for (let i = 0; i < 256; i++) {
993
- let t = i;
991
+ // Slicing-by-8 CRC-32 (Intel / zlib). The eight 256-entry tables let the inner loop
992
+ // consume 8 bytes per iteration with a shorter dependency chain, ~4x the byte-at-a-time
993
+ // rate (measured ~320 -> ~1400 MB/s on 64KB chunks).
994
+ //
995
+ // Every table MUST stay a PACKED_SMI array: build with array literals (not `new Array(n)`,
996
+ // which is HOLEY) and store the signed int32 XOR result (no `>>> 0`). An unsigned or holey
997
+ // table becomes a V8 FixedDoubleArray whose every hot-loop lookup unboxes a double (~1.6x
998
+ // slower). Signedness is irrelevant to the result — the reads mask/shift it and the final
999
+ // `~crc` normalizes it. Do NOT reintroduce `>>> 0` here or switch to `new Array(256)`.
1000
+ const T = [[], [], [], [], [], [], [], []];
1001
+ for (let n = 0; n < 256; n++) {
1002
+ let t = n;
994
1003
  for (let j = 0; j < 8; j++) {
995
- if (t & 1) {
996
- t = (t >>> 1) ^ 0xEDB88320;
997
- } else {
998
- t = t >>> 1;
999
- }
1004
+ t = (t & 1) ? (t >>> 1) ^ 0xEDB88320 : t >>> 1;
1005
+ }
1006
+ T[0][n] = t;
1007
+ }
1008
+ for (let n = 0; n < 256; n++) {
1009
+ for (let k = 1; k < 8; k++) {
1010
+ const previous = T[k - 1][n];
1011
+ T[k][n] = (previous >>> 8) ^ T[0][previous & 0xFF];
1000
1012
  }
1001
- table[i] = t;
1002
1013
  }
1014
+ const [T0, T1, T2, T3, T4, T5, T6, T7] = T;
1003
1015
 
1004
1016
  class Crc32 {
1005
1017
 
@@ -1009,8 +1021,24 @@
1009
1021
 
1010
1022
  append(data) {
1011
1023
  let crc = this.crc | 0;
1012
- for (let offset = 0, length = data.length | 0; offset < length; offset++) {
1013
- crc = (crc >>> 8) ^ table[(crc ^ data[offset]) & 0xFF];
1024
+ const length = data.length | 0;
1025
+ let offset = 0;
1026
+ // Process 8 bytes per iteration over the typed-array body. DataView.getInt32(le)
1027
+ // reads an unaligned little-endian word as a signed int32 (no double boxing), so no
1028
+ // alignment or endianness handling is needed; data.buffer guards non-typed inputs.
1029
+ if (length >= 8 && data.buffer) {
1030
+ const view = new DataView(data.buffer, data.byteOffset, length);
1031
+ const end = length - 8;
1032
+ for (; offset <= end; offset += 8) {
1033
+ const a = crc ^ view.getInt32(offset, true);
1034
+ const b = view.getInt32(offset + 4, true);
1035
+ crc = T7[a & 0xFF] ^ T6[(a >>> 8) & 0xFF] ^ T5[(a >>> 16) & 0xFF] ^ T4[(a >>> 24) & 0xFF] ^
1036
+ T3[b & 0xFF] ^ T2[(b >>> 8) & 0xFF] ^ T1[(b >>> 16) & 0xFF] ^ T0[(b >>> 24) & 0xFF];
1037
+ }
1038
+ }
1039
+ // Remaining tail (and non-typed inputs) byte-at-a-time with the base table.
1040
+ for (; offset < length; offset++) {
1041
+ crc = (crc >>> 8) ^ T0[(crc ^ data[offset]) & 0xFF];
1014
1042
  }
1015
1043
  this.crc = crc;
1016
1044
  }
@@ -2444,21 +2472,40 @@
2444
2472
  const ERR_INVALID_COMPRESSED_DATA = "Invalid compressed data";
2445
2473
  const FORMAT_DEFLATE_RAW = "deflate-raw";
2446
2474
  const FORMAT_DEFLATE64_RAW = "deflate64-raw";
2475
+ const FORMAT_GZIP = "gzip";
2476
+ const GZIP_HEADER_LENGTH = 10;
2477
+ const GZIP_TRAILER_LENGTH = 8;
2447
2478
 
2448
2479
  class DeflateStream extends TransformStream {
2449
2480
 
2450
2481
  constructor(options, { chunkSize, CompressionStreamZlib, CompressionStream }) {
2451
2482
  super({});
2452
- const { compressed, encrypted, useCompressionStream, zipCrypto, signed, level } = options;
2483
+ const { compressed, encrypted, useCompressionStream, zipCrypto, signed, level, deflate64 } = options;
2453
2484
  const stream = this;
2454
- let crc32Stream, encryptionStream;
2485
+ let crc32Stream, encryptionStream, gzipCrc32Stream;
2455
2486
  let readable = super.readable;
2456
- if ((!encrypted || zipCrypto) && signed) {
2487
+ // The gzip trailer carries a CRC-32 of the uncompressed data (same polynomial as zip),
2488
+ // computed in native code for free during compression. On the native CompressionStream,
2489
+ // harvest it instead of running a separate CRC pass: compress as gzip, then strip the
2490
+ // fixed 10-byte header and 8-byte trailer to recover the exact raw-deflate payload
2491
+ // (byte-identical to a deflate-raw compression) while capturing the CRC. The CRC becomes
2492
+ // available at end-of-stream, exactly like Crc32Stream, so nothing downstream (data
2493
+ // descriptor, central directory, ZipCrypto) needs it any earlier. Not applied for the
2494
+ // pure-JS/WASM ports (a separate slice-by-8 CRC pass is as fast or faster there).
2495
+ const useGzipCrc32 = signed && compressed && !deflate64 && (!encrypted || zipCrypto) &&
2496
+ Boolean(useCompressionStream && CompressionStream);
2497
+ if ((!encrypted || zipCrypto) && signed && !useGzipCrc32) {
2457
2498
  crc32Stream = new Crc32Stream();
2458
2499
  readable = pipeThrough(readable, crc32Stream);
2459
2500
  }
2460
2501
  if (compressed) {
2461
- readable = pipeThroughCommpressionStream(readable, useCompressionStream, { level, chunkSize }, CompressionStream, CompressionStreamZlib, CompressionStream);
2502
+ if (useGzipCrc32) {
2503
+ gzipCrc32Stream = new GzipToRawDeflateStream();
2504
+ readable = pipeThroughBackpressured(readable, new CompressionStream(FORMAT_GZIP));
2505
+ readable = pipeThrough(readable, gzipCrc32Stream);
2506
+ } else {
2507
+ readable = pipeThroughCommpressionStream(readable, useCompressionStream, { level, chunkSize }, CompressionStream, CompressionStreamZlib, CompressionStream);
2508
+ }
2462
2509
  }
2463
2510
  if (encrypted) {
2464
2511
  if (zipCrypto) {
@@ -2474,13 +2521,74 @@
2474
2521
  signature = encryptionStream.signature;
2475
2522
  }
2476
2523
  if ((!encrypted || zipCrypto) && signed) {
2477
- signature = new DataView(crc32Stream.value.buffer).getUint32(0);
2524
+ signature = useGzipCrc32 ? gzipCrc32Stream.signature : new DataView(crc32Stream.value.buffer).getUint32(0);
2478
2525
  }
2479
2526
  stream.signature = signature;
2480
2527
  });
2481
2528
  }
2482
2529
  }
2483
2530
 
2531
+ // Converts a gzip stream into its raw-deflate payload while capturing the CRC-32 from the gzip
2532
+ // trailer. The CompressionStream gzip header is always the fixed 10-byte form (FLG=0, no optional
2533
+ // fields) per the Compression Streams spec, so it is stripped by length; the trailer is the last
2534
+ // 8 bytes (CRC-32 LE, then ISIZE LE). Bounded memory: at most GZIP_TRAILER_LENGTH bytes are held
2535
+ // back across chunks. The signature is read big-endian-agnostically as a number, matching the
2536
+ // value Crc32Stream produces, so the writer path is unchanged.
2537
+ class GzipToRawDeflateStream extends TransformStream {
2538
+
2539
+ constructor() {
2540
+ // deno-lint-ignore prefer-const
2541
+ let stream;
2542
+ let headerLeft = GZIP_HEADER_LENGTH;
2543
+ let tail = new Uint8Array(0);
2544
+ super({
2545
+ transform(chunk, controller) {
2546
+ if (headerLeft) {
2547
+ const dropped = Math.min(headerLeft, chunk.length);
2548
+ headerLeft -= dropped;
2549
+ chunk = chunk.subarray(dropped);
2550
+ if (!chunk.length) {
2551
+ return;
2552
+ }
2553
+ }
2554
+ const available = tail.length + chunk.length;
2555
+ if (available <= GZIP_TRAILER_LENGTH) {
2556
+ const pending = new Uint8Array(available);
2557
+ pending.set(tail);
2558
+ pending.set(chunk, tail.length);
2559
+ tail = pending;
2560
+ return;
2561
+ }
2562
+ // Emit everything except the trailing GZIP_TRAILER_LENGTH bytes as a standalone,
2563
+ // right-sized Uint8Array. Consumers may read chunk.buffer directly (e.g. custom
2564
+ // writers), so an aliased subarray of a larger buffer would leak the held-back
2565
+ // trailer bytes. Bytes are copied exactly once, into `output` or `tail`.
2566
+ const emitLength = available - GZIP_TRAILER_LENGTH;
2567
+ const output = new Uint8Array(emitLength);
2568
+ const fromTail = Math.min(emitLength, tail.length);
2569
+ output.set(tail.subarray(0, fromTail), 0);
2570
+ if (emitLength > fromTail) {
2571
+ output.set(chunk.subarray(0, emitLength - fromTail), fromTail);
2572
+ }
2573
+ controller.enqueue(output);
2574
+ const nextTail = new Uint8Array(GZIP_TRAILER_LENGTH);
2575
+ const tailRemaining = tail.length - fromTail;
2576
+ if (tailRemaining) {
2577
+ nextTail.set(tail.subarray(fromTail), 0);
2578
+ }
2579
+ nextTail.set(chunk.subarray(emitLength - fromTail), tailRemaining);
2580
+ tail = nextTail;
2581
+ },
2582
+ flush() {
2583
+ const dataView = new DataView(tail.buffer, tail.byteOffset, tail.byteLength);
2584
+ stream.signature = dataView.getUint32(0, true);
2585
+ stream.uncompressedSize = dataView.getUint32(4, true);
2586
+ }
2587
+ });
2588
+ stream = this;
2589
+ }
2590
+ }
2591
+
2484
2592
  class InflateStream extends TransformStream {
2485
2593
 
2486
2594
  constructor(options, { chunkSize, DecompressionStreamZlib, DecompressionStream }) {
@@ -3641,6 +3749,11 @@
3641
3749
  const OPTION_OFFSET = "offset";
3642
3750
  const OPTION_USDZ = "usdz";
3643
3751
  const OPTION_UNIX_EXTRA_FIELD_TYPE = "unixExtraFieldType";
3752
+ const OPTION_STRICTNESS = "strictness";
3753
+ const OPTION_MAX_APPENDED_DATA_SIZE = "maxAppendedDataSize";
3754
+ const STRICTNESS_STRICT = "strict";
3755
+ const STRICTNESS_BALANCED = "balanced";
3756
+ const STRICTNESS_TOLERANT = "tolerant";
3644
3757
 
3645
3758
  /*
3646
3759
  Copyright (c) 2025 Gildas Lormeau. All rights reserved.
@@ -3730,7 +3843,11 @@
3730
3843
  throw new Error(ERR_BAD_FORMAT);
3731
3844
  }
3732
3845
  reader.chunkSize = getChunkSize(config);
3733
- const endOfDirectoryInfo = await seekSignature(reader, END_OF_CENTRAL_DIR_SIGNATURE, reader.size, END_OF_CENTRAL_DIR_LENGTH, MAX_16_BITS * 16);
3846
+ const strictness = getStrictness(getOptionValue$1(zipReader, options, OPTION_STRICTNESS), getOptionValue$1(zipReader, options, OPTION_CHECK_AMBIGUITY));
3847
+ const checkAmbiguity = strictness == STRICTNESS_STRICT;
3848
+ const rejectAmbiguousEndOfDirectory = strictness != STRICTNESS_TOLERANT;
3849
+ const maxAppendedDataSize = getMaxAppendedDataSize(getOptionValue$1(zipReader, options, OPTION_MAX_APPENDED_DATA_SIZE), strictness);
3850
+ const { endOfDirectoryInfo, endOfDirectoryReachingEndCount } = await findEndOfCentralDirectory(reader, rejectAmbiguousEndOfDirectory, maxAppendedDataSize);
3734
3851
  if (!endOfDirectoryInfo) {
3735
3852
  const signatureArray = await readUint8Array(reader, 0, 4);
3736
3853
  const signatureView = getDataView$1(signatureArray);
@@ -3740,14 +3857,18 @@
3740
3857
  throw new Error(ERR_EOCDR_NOT_FOUND);
3741
3858
  }
3742
3859
  }
3860
+ // two or more end-anchored records that each dereference to a valid central directory cannot be told
3861
+ // apart (see comment_length ⟺ EOF): the archive is genuinely ambiguous, so refuse rather than guess
3862
+ if (rejectAmbiguousEndOfDirectory && endOfDirectoryReachingEndCount > 1) {
3863
+ throwAmbiguousArchive("multiple end of central directory records");
3864
+ }
3743
3865
  const endOfDirectoryView = getDataView$1(endOfDirectoryInfo);
3744
3866
  let directoryDataLength = getUint32(endOfDirectoryView, 12);
3745
3867
  let directoryDataOffset = getUint32(endOfDirectoryView, 16);
3746
3868
  const commentOffset = endOfDirectoryInfo.offset;
3747
3869
  const commentLength = getUint16(endOfDirectoryView, 20);
3748
3870
  const appendedDataOffset = commentOffset + END_OF_CENTRAL_DIR_LENGTH + commentLength;
3749
- const checkAmbiguity = getOptionValue$1(zipReader, options, OPTION_CHECK_AMBIGUITY);
3750
- if (checkAmbiguity && appendedDataOffset != reader.size) {
3871
+ if (reader.size - appendedDataOffset > maxAppendedDataSize) {
3751
3872
  throwAmbiguousArchive("appended data");
3752
3873
  }
3753
3874
  let lastDiskNumber = getUint16(endOfDirectoryView, 4);
@@ -3826,14 +3947,28 @@
3826
3947
  throw new Error(ERR_BAD_FORMAT);
3827
3948
  }
3828
3949
  const expectedDirectoryDataOffset = centralDirectoryEndOffset - directoryDataLength - (reader.lastDiskOffset || 0);
3829
- if (getUint32(directoryView, offset) != CENTRAL_FILE_HEADER_SIGNATURE && directoryDataOffset != expectedDirectoryDataOffset && diskNumber == lastDiskNumber) {
3830
- const originalDirectoryDataOffset = directoryDataOffset;
3831
- directoryDataOffset = expectedDirectoryDataOffset;
3832
- if (directoryDataOffset > originalDirectoryDataOffset) {
3833
- prependedDataLength += directoryDataOffset - originalDirectoryDataOffset;
3950
+ if (directoryDataOffset != expectedDirectoryDataOffset && diskNumber == lastDiskNumber) {
3951
+ // the reconciled offset (the directory ends exactly where the canonical record begins) is anchored
3952
+ // to the unforgeable end of the archive; the stored offset is not. Prefer the reconciled offset
3953
+ // unless the stored one points at a directory and the reconciled one does not — that exception
3954
+ // keeps a corrupt declared directory length from sending the read astray, while still moving off a
3955
+ // stored offset that only looks valid because an append remnant left the previous (identically
3956
+ // laid out) directory sitting there.
3957
+ const storedPointsAtDirectory = getUint32(directoryView, offset) == CENTRAL_FILE_HEADER_SIGNATURE;
3958
+ let reconcile = !storedPointsAtDirectory;
3959
+ if (!reconcile && expectedDirectoryDataOffset >= 0 && expectedDirectoryDataOffset + 4 <= reader.size) {
3960
+ const expectedSignatureArray = await readUint8Array(reader, expectedDirectoryDataOffset, 4, diskNumber);
3961
+ reconcile = getUint32(getDataView$1(expectedSignatureArray), 0) == CENTRAL_FILE_HEADER_SIGNATURE;
3962
+ }
3963
+ if (reconcile) {
3964
+ const originalDirectoryDataOffset = directoryDataOffset;
3965
+ directoryDataOffset = expectedDirectoryDataOffset;
3966
+ if (directoryDataOffset > originalDirectoryDataOffset) {
3967
+ prependedDataLength += directoryDataOffset - originalDirectoryDataOffset;
3968
+ }
3969
+ directoryArray = await readUint8Array(reader, directoryDataOffset, directoryDataLength, diskNumber);
3970
+ directoryView = getDataView$1(directoryArray);
3834
3971
  }
3835
- directoryArray = await readUint8Array(reader, directoryDataOffset, directoryDataLength, diskNumber);
3836
- directoryView = getDataView$1(directoryArray);
3837
3972
  }
3838
3973
  }
3839
3974
  const expectedDirectoryDataLength = centralDirectoryEndOffset - directoryDataOffset - (reader.lastDiskOffset || 0);
@@ -4050,7 +4185,7 @@
4050
4185
  extraFieldLength,
4051
4186
  filenameLength
4052
4187
  } = localDirectory;
4053
- const checkAmbiguity = getOptionValue$1(zipEntry, options, OPTION_CHECK_AMBIGUITY);
4188
+ const checkAmbiguity = getStrictness(getOptionValue$1(zipEntry, options, OPTION_STRICTNESS), getOptionValue$1(zipEntry, options, OPTION_CHECK_AMBIGUITY)) == STRICTNESS_STRICT;
4054
4189
  let rawLocalFilename = new Uint8Array();
4055
4190
  if (checkAmbiguity && (filenameLength || extraFieldLength)) {
4056
4191
  const trailingDataArray = await readUint8Array(reader, offset + HEADER_SIZE, filenameLength + extraFieldLength, diskNumberStart);
@@ -4500,26 +4635,191 @@
4500
4635
  readRanges.set(index, range);
4501
4636
  }
4502
4637
 
4503
- async function seekSignature(reader, signature, startOffset, minimumBytes, maximumLength) {
4504
- const signatureArray = new Uint8Array(4);
4505
- const signatureView = getDataView$1(signatureArray);
4506
- setUint32$1(signatureView, 0, signature);
4507
- const maximumBytes = minimumBytes + maximumLength;
4508
- return (await seek(minimumBytes)) || await seek(Math.min(maximumBytes, startOffset));
4509
-
4510
- async function seek(length) {
4511
- const offset = startOffset - length;
4512
- const bytes = await readUint8Array(reader, offset, length);
4513
- for (let indexByte = bytes.length - minimumBytes; indexByte >= 0; indexByte--) {
4514
- if (bytes[indexByte] == signatureArray[0] && bytes[indexByte + 1] == signatureArray[1] &&
4515
- bytes[indexByte + 2] == signatureArray[2] && bytes[indexByte + 3] == signatureArray[3]) {
4516
- return {
4517
- offset: offset + indexByte,
4518
- buffer: bytes.slice(indexByte, indexByte + minimumBytes).buffer
4519
- };
4638
+ function getStrictness(strictness, checkAmbiguity) {
4639
+ if (strictness === UNDEFINED_VALUE) {
4640
+ // `checkAmbiguity: true` is kept as a backward-compatible alias for the strictest mode
4641
+ return checkAmbiguity ? STRICTNESS_STRICT : STRICTNESS_BALANCED;
4642
+ }
4643
+ return strictness;
4644
+ }
4645
+
4646
+ function getMaxAppendedDataSize(maxAppendedDataSize, strictness) {
4647
+ if (maxAppendedDataSize !== UNDEFINED_VALUE) {
4648
+ return maxAppendedDataSize;
4649
+ }
4650
+ if (strictness == STRICTNESS_STRICT) {
4651
+ // the comment must reach the end of the file: no trailing data tolerated
4652
+ return 0;
4653
+ }
4654
+ if (strictness == STRICTNESS_TOLERANT) {
4655
+ return Infinity;
4656
+ }
4657
+ // balanced: tolerate up to a legal comment's worth of trailing data (a 16-bit length); beyond that the
4658
+ // trailing bytes cannot hide inside a comment, so they are treated as a second thing bolted onto the file
4659
+ return MAX_16_BITS;
4660
+ }
4661
+
4662
+ // cap on how many out-of-window central directory probes the scan below may issue. A legitimate archive needs
4663
+ // at most a couple (the canonical record, plus one more to detect genuine ambiguity); every other reachability
4664
+ // check is served from the tail window already in memory. The cap keeps an archive whose comment is stuffed
4665
+ // with unreachable end-anchored records from forcing an unbounded number of (potentially remote) reads.
4666
+ const MAX_END_OF_CENTRAL_DIR_PROBES = 64;
4667
+
4668
+ // reachability rankings for an end-anchored candidate record (see getCentralDirectoryReachability)
4669
+ const CENTRAL_DIRECTORY_UNREACHABLE = 0;
4670
+ const CENTRAL_DIRECTORY_PLAUSIBLE = 1;
4671
+ const CENTRAL_DIRECTORY_REACHABLE = 2;
4672
+
4673
+ // Locates the authoritative end of central directory record. The canonical record is the last one whose
4674
+ // declared comment reaches exactly the end of the file ("end-anchored") and that points to a central directory
4675
+ // ("reachable"); earlier signatures (stale append remnants, bytes embedded in a comment) are ignored. When two
4676
+ // or more end-anchored records point to a central directory the archive is ambiguous, and the count is returned
4677
+ // so the caller can refuse it. A record that reaches the end of the file but points to no central directory (an
4678
+ // empty archive) is only "plausible": it cannot be corroborated, so it never counts towards ambiguity and is
4679
+ // chosen only when no reachable record exists — otherwise an empty record forged in a comment could outrank the
4680
+ // genuine directory. When no record is end-anchored (appended data or an oversized comment), falls back to the
4681
+ // last record within the tolerated window that points to a directory (see seekEndOfCentralDirectory).
4682
+ async function findEndOfCentralDirectory(reader, rejectAmbiguous, maxAppendedDataSize) {
4683
+ const { size } = reader;
4684
+ // an end-anchored record can only live within the last END_OF_CENTRAL_DIR_LENGTH + MAX_16_BITS bytes,
4685
+ // since the comment length that ties it to the end of the file is a 16-bit field
4686
+ const anchoredLength = Math.min(size, END_OF_CENTRAL_DIR_LENGTH + MAX_16_BITS);
4687
+ const anchoredOffset = size - anchoredLength;
4688
+ const anchoredArray = await readUint8Array(reader, anchoredOffset, anchoredLength);
4689
+ const anchoredView = getDataView$1(anchoredArray);
4690
+ const remoteProbeBudget = { count: MAX_END_OF_CENTRAL_DIR_PROBES };
4691
+ let endOfDirectoryInfo;
4692
+ let plausibleEndOfDirectoryInfo;
4693
+ let endOfDirectoryReachingEndCount = 0;
4694
+ for (let indexByte = anchoredArray.length - END_OF_CENTRAL_DIR_LENGTH; indexByte >= 0; indexByte--) {
4695
+ if (getUint32(anchoredView, indexByte) == END_OF_CENTRAL_DIR_SIGNATURE) {
4696
+ const offset = anchoredOffset + indexByte;
4697
+ const commentLength = getUint16(anchoredView, indexByte + 20);
4698
+ // end-anchored: the declared comment extends exactly to the end of the file. The comment length is
4699
+ // attacker-controlled, but the reconciliation against the (unforgeable) end of file is not.
4700
+ if (offset + END_OF_CENTRAL_DIR_LENGTH + commentLength == size) {
4701
+ const reachability = await getCentralDirectoryReachability(reader, anchoredView, anchoredOffset, indexByte, offset, size, remoteProbeBudget);
4702
+ if (reachability == CENTRAL_DIRECTORY_REACHABLE) {
4703
+ if (!endOfDirectoryInfo) {
4704
+ endOfDirectoryInfo = {
4705
+ offset,
4706
+ buffer: anchoredArray.slice(indexByte, indexByte + END_OF_CENTRAL_DIR_LENGTH).buffer
4707
+ };
4708
+ }
4709
+ endOfDirectoryReachingEndCount++;
4710
+ // the canonical record (highest offset) is found first; a second one is enough to flag ambiguity
4711
+ if (!rejectAmbiguous || endOfDirectoryReachingEndCount > 1) {
4712
+ break;
4713
+ }
4714
+ } else if (reachability == CENTRAL_DIRECTORY_PLAUSIBLE && !plausibleEndOfDirectoryInfo) {
4715
+ plausibleEndOfDirectoryInfo = {
4716
+ offset,
4717
+ buffer: anchoredArray.slice(indexByte, indexByte + END_OF_CENTRAL_DIR_LENGTH).buffer
4718
+ };
4719
+ }
4520
4720
  }
4521
4721
  }
4522
4722
  }
4723
+ if (!endOfDirectoryInfo) {
4724
+ // no record points to a directory: prefer a plausible (empty) end-anchored record before scanning the
4725
+ // tolerated window for a bare signature
4726
+ endOfDirectoryInfo = plausibleEndOfDirectoryInfo;
4727
+ }
4728
+ if (!endOfDirectoryInfo) {
4729
+ endOfDirectoryInfo = await seekEndOfCentralDirectory(reader, maxAppendedDataSize, remoteProbeBudget);
4730
+ }
4731
+ return { endOfDirectoryInfo, endOfDirectoryReachingEndCount };
4732
+ }
4733
+
4734
+ // Fallback for findEndOfCentralDirectory when no record is end-anchored: appended data or an oversized comment
4735
+ // pushed the record's declared comment past the end of the file, so its position can no longer be reconciled
4736
+ // against the end of the file. Scans the tolerated window from the end and returns the first record that points
4737
+ // to a central directory, so a stray signature embedded in appended data cannot hijack discovery (which the
4738
+ // anchored scan already prevents for anchored records). Falls back to the first plausible (empty) record, then
4739
+ // to the last bare signature, so a degenerate archive still opens as before. The window ends at the file, so the
4740
+ // same in-window / metered reads as the anchored scan apply.
4741
+ async function seekEndOfCentralDirectory(reader, maxAppendedDataSize, remoteProbeBudget) {
4742
+ const { size } = reader;
4743
+ const searchLength = Math.min(size, maxAppendedDataSize == Infinity ? size :
4744
+ END_OF_CENTRAL_DIR_LENGTH + MAX_16_BITS + maxAppendedDataSize);
4745
+ const searchOffset = size - searchLength;
4746
+ const searchArray = await readUint8Array(reader, searchOffset, searchLength);
4747
+ const searchView = getDataView$1(searchArray);
4748
+ let firstSignatureInfo, plausibleInfo;
4749
+ for (let indexByte = searchArray.length - END_OF_CENTRAL_DIR_LENGTH; indexByte >= 0; indexByte--) {
4750
+ if (getUint32(searchView, indexByte) == END_OF_CENTRAL_DIR_SIGNATURE) {
4751
+ const offset = searchOffset + indexByte;
4752
+ const record = { offset, buffer: searchArray.slice(indexByte, indexByte + END_OF_CENTRAL_DIR_LENGTH).buffer };
4753
+ if (!firstSignatureInfo) {
4754
+ firstSignatureInfo = record;
4755
+ }
4756
+ const reachability = await getCentralDirectoryReachability(reader, searchView, searchOffset, indexByte, offset, size, remoteProbeBudget);
4757
+ if (reachability == CENTRAL_DIRECTORY_REACHABLE) {
4758
+ return record;
4759
+ }
4760
+ if (reachability == CENTRAL_DIRECTORY_PLAUSIBLE && !plausibleInfo) {
4761
+ plausibleInfo = record;
4762
+ }
4763
+ }
4764
+ }
4765
+ return plausibleInfo || firstSignatureInfo;
4766
+ }
4767
+
4768
+ // Ranks how strongly an end-anchored candidate record is backed by an actual central directory, used to decide
4769
+ // which record is canonical and which count towards ambiguity. It is intentionally conservative and does not
4770
+ // fully parse the directory (the caller does that for the canonical record):
4771
+ // - REACHABLE: the record dereferences a central directory (a central file header signature at the
4772
+ // reconciled offset), or, for a zip64 record, the zip64 locator that a genuine zip64 archive places
4773
+ // immediately before it. Only these count towards ambiguity.
4774
+ // - PLAUSIBLE: the record describes an empty archive, which has no central directory to dereference. Its
4775
+ // fields are indistinguishable from an empty end of central directory record forged inside a comment, so it
4776
+ // is accepted only as a last resort and never inflates the ambiguity count.
4777
+ // - UNREACHABLE: neither — stale append remnant or signature bytes embedded in a comment.
4778
+ // Signatures are read from the tail window (`view`, spanning `[anchoredOffset, size)`) when they fall inside it;
4779
+ // only a target below the window costs a (possibly remote) read, metered by `remoteProbeBudget` so a stuffed
4780
+ // comment cannot amplify into an unbounded number of requests.
4781
+ async function getCentralDirectoryReachability(reader, view, anchoredOffset, indexByte, offset, size, remoteProbeBudget) {
4782
+ const filesLength = getUint16(view, indexByte + 10);
4783
+ const directoryDataLength = getUint32(view, indexByte + 12);
4784
+ const directoryDataOffset = getUint32(view, indexByte + 16);
4785
+ if (filesLength == MAX_16_BITS || directoryDataLength == MAX_32_BITS || directoryDataOffset == MAX_32_BITS) {
4786
+ // saturated fields imply a zip64 record; corroborate it with the zip64 end of central directory locator
4787
+ // that sits immediately before it, so saturated bytes inside a comment cannot masquerade as one
4788
+ const locatorSignature = await readSignature(reader, view, anchoredOffset, offset - ZIP64_END_OF_CENTRAL_DIR_LOCATOR_LENGTH, size, remoteProbeBudget);
4789
+ return locatorSignature == ZIP64_END_OF_CENTRAL_DIR_LOCATOR_SIGNATURE ? CENTRAL_DIRECTORY_REACHABLE : CENTRAL_DIRECTORY_UNREACHABLE;
4790
+ }
4791
+ if (!filesLength && !directoryDataLength) {
4792
+ // a valid empty archive has no central directory to dereference
4793
+ return CENTRAL_DIRECTORY_PLAUSIBLE;
4794
+ }
4795
+ // the central directory ends where this record starts; reconcile the stored offset against that actual
4796
+ // position (see stored_cd_offset ⟺ eocd_pos − cd_size) so prepended data does not hide the directory
4797
+ for (const centralDirectoryOffset of [offset - directoryDataLength, directoryDataOffset]) {
4798
+ if (await readSignature(reader, view, anchoredOffset, centralDirectoryOffset, size, remoteProbeBudget) == CENTRAL_FILE_HEADER_SIGNATURE) {
4799
+ return CENTRAL_DIRECTORY_REACHABLE;
4800
+ }
4801
+ }
4802
+ return CENTRAL_DIRECTORY_UNREACHABLE;
4803
+ }
4804
+
4805
+ // Reads a 4-byte little-endian signature at `signatureOffset`, served from the tail window (`view`, spanning
4806
+ // `[anchoredOffset, size)`) when it falls inside it and otherwise from a metered (possibly remote) read. Returns
4807
+ // UNDEFINED_VALUE when the position is out of range or the out-of-window read budget is exhausted.
4808
+ async function readSignature(reader, view, anchoredOffset, signatureOffset, size, remoteProbeBudget) {
4809
+ if (signatureOffset < 0 || signatureOffset + 4 > size) {
4810
+ return UNDEFINED_VALUE;
4811
+ }
4812
+ if (signatureOffset >= anchoredOffset) {
4813
+ // the target sits inside the tail window already in memory: no extra read needed
4814
+ return getUint32(view, signatureOffset - anchoredOffset);
4815
+ }
4816
+ if (remoteProbeBudget.count > 0) {
4817
+ remoteProbeBudget.count--;
4818
+ const signatureArray = await readUint8Array(reader, signatureOffset, 4);
4819
+ return getUint32(getDataView$1(signatureArray), 0);
4820
+ }
4821
+ // out of budget: leave this candidate unverified rather than issue another read
4822
+ return UNDEFINED_VALUE;
4523
4823
  }
4524
4824
 
4525
4825
  function checkLocalDirectory(zipEntry, localDirectory, rawLocalFilename) {
@@ -4584,10 +4884,6 @@
4584
4884
  return Number(view.getBigUint64(offset, true));
4585
4885
  }
4586
4886
 
4587
- function setUint32$1(view, offset, value) {
4588
- view.setUint32(offset, value, true);
4589
- }
4590
-
4591
4887
  function getDataView$1(array) {
4592
4888
  return new DataView(array.buffer, array.byteOffset, array.byteLength);
4593
4889
  }
@@ -5315,6 +5611,17 @@
5315
5611
  if (releaseLockWriter) {
5316
5612
  releaseLockWriter();
5317
5613
  }
5614
+ // A buffered entry uses a temporary stream (see `createTempStream`). When that stream is
5615
+ // backed by a resource (a file, an OPFS handle, ...) it must be released on every exit path,
5616
+ // including the error path where the stream is otherwise abandoned without being closed or
5617
+ // cancelled. `dispose()` is optional and best-effort so it never masks the original outcome.
5618
+ if (bufferedWrite && fileWriter && fileWriter.dispose) {
5619
+ try {
5620
+ await fileWriter.dispose();
5621
+ } catch {
5622
+ // ignored
5623
+ }
5624
+ }
5318
5625
  }
5319
5626
 
5320
5627
  function requestLockCurrentFileEntry() {
@@ -6679,6 +6986,10 @@
6679
6986
  return writable;
6680
6987
  }
6681
6988
 
6989
+ exportFileSystemHandle(handle, options = {}) {
6990
+ return exportFileSystemHandle(this, handle, options);
6991
+ }
6992
+
6682
6993
  async importZip(reader, options = {}) {
6683
6994
  await initStream(reader);
6684
6995
  const zipReader = new ZipReader(reader, options);
@@ -6944,6 +7255,10 @@
6944
7255
  return this.root.exportWritable(writable, options);
6945
7256
  }
6946
7257
 
7258
+ exportFileSystemHandle(handle, options) {
7259
+ return this.root.exportFileSystemHandle(handle, options);
7260
+ }
7261
+
6947
7262
  exportZip(writer, options) {
6948
7263
  return this.root.exportZip(writer, options);
6949
7264
  }
@@ -7181,6 +7496,56 @@
7181
7496
  }
7182
7497
  }
7183
7498
 
7499
+ async function exportFileSystemHandle(zipEntry, directoryHandle, options) {
7500
+ const totalSize = getTotalSize([zipEntry], "uncompressedSize");
7501
+ const writtenSizes = new Map();
7502
+ await exportChildren(zipEntry, directoryHandle);
7503
+ return directoryHandle;
7504
+
7505
+ async function exportChildren(entry, parentHandle) {
7506
+ // Separate files have no ordering constraint, so they can be written concurrently on demand.
7507
+ if (options.concurrent) {
7508
+ const results = await Promise.allSettled(entry.children.map(child => exportChild(child, parentHandle)));
7509
+ const failedResult = results.find(result => result.status == "rejected");
7510
+ if (failedResult) {
7511
+ throw failedResult.reason;
7512
+ }
7513
+ } else {
7514
+ for (const child of entry.children) {
7515
+ await exportChild(child, parentHandle);
7516
+ }
7517
+ }
7518
+ }
7519
+
7520
+ async function exportChild(child, parentHandle) {
7521
+ try {
7522
+ if (child.directory) {
7523
+ const childDirectoryHandle = await parentHandle.getDirectoryHandle(child.name, { create: true });
7524
+ await exportChildren(child, childDirectoryHandle);
7525
+ } else {
7526
+ const fileHandle = await parentHandle.getFileHandle(child.name, { create: true });
7527
+ const writable = await fileHandle.createWritable();
7528
+ // getData() streams the entry's data into `writable` and closes it (a FileSystemWritableFileStream
7529
+ // only commits the file on close), applying options such as password, signal and onprogress.
7530
+ await child.getData({ writable }, Object.assign({}, options, {
7531
+ onprogress: async progress => {
7532
+ if (options.onprogress) {
7533
+ writtenSizes.set(child.id, progress);
7534
+ try {
7535
+ await options.onprogress(Array.from(writtenSizes.values()).reduce((previousValue, currentValue) => previousValue + currentValue, 0), totalSize);
7536
+ } catch {
7537
+ // ignored
7538
+ }
7539
+ }
7540
+ }
7541
+ }));
7542
+ }
7543
+ } catch (error) {
7544
+ throw new Error(error.message + (child ? " (" + child.name + ")" : ""), { cause: error });
7545
+ }
7546
+ }
7547
+ }
7548
+
7184
7549
  async function transformToFileSystemhandle(entry) {
7185
7550
  const handle = {
7186
7551
  name: entry.name