diff --git a/doc/api/errors.md b/doc/api/errors.md
index db3eb81eff6b90..3971e658879efa 100644
--- a/doc/api/errors.md
+++ b/doc/api/errors.md
@@ -3674,6 +3674,58 @@ All attempts at serializing an uncaught exception from a worker thread failed.
The requested functionality is not supported in worker threads.
+
+
+### `ERR_ZIP_ARCHIVE_TOO_LARGE`
+
+An archive-level structure exceeds a limit: the archive comment exceeds the
+65,535-byte encoded length that the ZIP format allows, or the archive's
+central directory is too large to buffer in memory.
+
+
+
+### `ERR_ZIP_ENTRY_CORRUPT`
+
+A ZIP archive entry failed CRC-32 verification, or produced more or fewer
+bytes than its declared uncompressed size, while being read.
+
+
+
+### `ERR_ZIP_ENTRY_NOT_FOUND`
+
+A named entry was requested from a [`ZipFile`][] or [`ZipBuffer`][] that does
+not contain an entry with that name.
+
+
+
+### `ERR_ZIP_ENTRY_TOO_LARGE`
+
+A ZIP archive entry's declared size exceeds the configured limit, or a
+provided entry name or comment exceeds the 65,535-byte encoded length that
+the ZIP format allows.
+
+
+
+### `ERR_ZIP_INVALID_ARCHIVE`
+
+Data that was expected to be a ZIP archive, or a structure within one, is
+missing, out of bounds, or otherwise inconsistent with the ZIP format.
+
+
+
+### `ERR_ZIP_NOT_WRITABLE`
+
+A mutating method (such as `zipFile.addEntry()` or `zipFile.delete()`) was
+called on a [`ZipFile`][] that was not opened with `{ writable: true }`.
+
+
+
+### `ERR_ZIP_UNSUPPORTED_FEATURE`
+
+A ZIP archive uses a feature outside of what this implementation supports,
+such as entry encryption, an unsupported compression method, or a multi-disk
+archive.
+
### `ERR_ZLIB_INITIALIZATION_FAILED`
@@ -4617,6 +4669,8 @@ An error occurred trying to allocate memory. This should never happen.
[`ServerResponse`]: http.md#class-httpserverresponse
[`Temporal`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal
[`Writable`]: stream.md#class-streamwritable
+[`ZipBuffer`]: zlib.md#class-zlibzipbuffer
+[`ZipFile`]: zlib.md#class-zlibzipfile
[`child_process`]: child_process.md
[`cipher.getAuthTag()`]: crypto.md#ciphergetauthtag
[`crypto.getDiffieHellman()`]: crypto.md#cryptogetdiffiehellmangroupname
diff --git a/doc/api/zlib.md b/doc/api/zlib.md
index f32715ba6cc6b2..acb4e3a31d13dc 100644
--- a/doc/api/zlib.md
+++ b/doc/api/zlib.md
@@ -1006,6 +1006,1057 @@ added: v0.5.8
Decompress either a Gzip- or Deflate-compressed stream by auto-detecting
the header.
+## Class: `zlib.ZipBuffer`
+
+
+
+> Stability: 1 - Experimental
+
+An in-memory, **zero-copy** view over the entries of a ZIP archive already
+held in a `Buffer`, `TypedArray`, `DataView`, or `ArrayBuffer`. Its set of
+entries can be edited - entries added or removed - but, unlike [`ZipFile`][],
+those edits are **not** written into the source buffer: a newly added entry is
+held as a separate in-memory [`ZipEntry`][] (the passed buffer is a fixed-size
+view with no room to append to), and removal just drops the entry from
+`ZipBuffer`'s index. The original bytes are never modified.
+[`zipBuffer.toBuffer()`][] serializes the current set of entries into a fresh
+archive.
+
+`ZipBuffer` does not copy the archive you hand it. It keeps a view onto that
+memory and reads each entry's content lazily and directly from it, which is
+what makes construction cheap regardless of archive size. The trade-off is
+that you **must not modify or reuse** that memory - including the
+`ArrayBuffer` backing a `TypedArray`/`DataView` - while the `ZipBuffer`, or
+any [`ZipEntry`][] obtained from it, is still in use: a later read would
+observe the change and may fail or return corrupt data. Pass a copy (for
+example `Buffer.from(source)`) if the source might be mutated or reused.
+
+`add()` and `toBuffer()` each have a `*Sync` counterpart
+([`addSync()`][`zipBuffer.addSync()`], [`toBufferSync()`][`zipBuffer.toBufferSync()`])
+that performs the same compression work synchronously. As with the
+synchronous `node:fs` APIs, these block the Node.js event loop and further
+JavaScript execution until the operation completes; use them only where
+synchronous execution is appropriate (for example, short-lived scripts or
+startup code), not in code that must stay responsive.
+
+```mjs
+import { ZipBuffer } from 'node:zlib';
+import { readFileSync, writeFileSync } from 'node:fs';
+import { Buffer } from 'node:buffer';
+
+const zip = new ZipBuffer(readFileSync('archive.zip'));
+for (const [name, entry] of zip) {
+ console.log(name, entry.size);
+}
+await zip.add('hello.txt', Buffer.from('Hello, world!'));
+zip.delete('unwanted.txt');
+writeFileSync('archive.zip', await zip.toBuffer());
+```
+
+```cjs
+const { ZipBuffer } = require('node:zlib');
+const { readFileSync, writeFileSync } = require('node:fs');
+
+async function main() {
+ const zip = new ZipBuffer(readFileSync('archive.zip'));
+ for (const [name, entry] of zip) {
+ console.log(name, entry.size);
+ }
+ await zip.add('hello.txt', Buffer.from('Hello, world!'));
+ zip.delete('unwanted.txt');
+ writeFileSync('archive.zip', await zip.toBuffer());
+}
+main();
+```
+
+### `new zlib.ZipBuffer(buffer)`
+
+
+
+* `buffer` {Buffer|TypedArray|DataView|ArrayBuffer} A complete ZIP archive.
+
+Parses the archive's central directory. Throws an [`ERR_ZIP_INVALID_ARCHIVE`][]
+or [`ERR_ZIP_UNSUPPORTED_FEATURE`][] error if `buffer` is not a well-formed,
+supported archive.
+
+`buffer` is **not copied**: the `ZipBuffer` retains a zero-copy view of it (for
+a `TypedArray`, `DataView`, or `ArrayBuffer`, of the underlying `ArrayBuffer`)
+and reads entry content directly from it on demand. Do not mutate or reuse that
+memory while the `ZipBuffer` or any entry read from it is still live; pass a
+copy if it might change.
+
+### `zipBuffer.add(filename, data[, options])`
+
+
+
+* `filename` {string} The entry's name within the archive. A trailing `/`
+ marks a directory entry.
+* `data` {Buffer|TypedArray|DataView|ArrayBuffer} The entry's complete,
+ uncompressed content.
+* `options` {Object} See [`zlib.ZipEntry.create()`][].
+* Returns: {Promise} Fulfilled with the created {ZipEntry}.
+
+Equivalent to `zipBuffer.addEntry(await zlib.ZipEntry.create(filename, data,
+options))`.
+
+### `zipBuffer.addSync(filename, data[, options])`
+
+
+
+* `filename` {string} The entry's name within the archive. A trailing `/`
+ marks a directory entry.
+* `data` {Buffer|TypedArray|DataView|ArrayBuffer} The entry's complete,
+ uncompressed content.
+* `options` {Object} See [`zlib.ZipEntry.createSync()`][].
+* Returns: {ZipEntry} The created entry.
+
+The synchronous version of [`zipBuffer.add()`][]. Equivalent to
+`zipBuffer.addEntry(zlib.ZipEntry.createSync(filename, data, options))`.
+
+### `zipBuffer.addEntry(entry)`
+
+
+
+* `entry` {ZipEntry}
+* Returns: {ZipEntry} `entry`.
+
+Adds an already-built entry, keyed by its own [`zipEntry.name`][]. Replaces
+any existing entry of that name.
+
+### `zipBuffer.clear()`
+
+
+
+Removes every entry.
+
+### `zipBuffer.comment`
+
+
+
+* Type: {string}
+
+The archive-level comment, preserved byte-for-byte across
+[`zipBuffer.toBuffer()`][] calls unless overridden. The bytes are decoded as
+UTF-8 when they are valid UTF-8 and as CP437 otherwise (the field carries no
+encoding flag of its own).
+
+### `zipBuffer.delete(name)`
+
+
+
+* `name` {string}
+* Returns: {boolean} `true` if an entry named `name` existed and was removed.
+
+### `zipBuffer.entries()`
+
+
+
+* Returns: {Iterator} of `[name, entry]` pairs, where `entry` is a
+ [`ZipEntry`][].
+
+### `zipBuffer.forEach(callback[, thisArg])`
+
+
+
+* `callback` {Function}
+* `thisArg` {any}
+
+Calls `callback` once for each entry, in the order the archive lists them.
+
+### `zipBuffer.get(name)`
+
+
+
+* `name` {string}
+* Returns: {ZipEntry}
+
+Throws [`ERR_ZIP_ENTRY_NOT_FOUND`][] if the archive has no entry named `name`.
+
+### `zipBuffer.has(name)`
+
+
+
+* `name` {string}
+* Returns: {boolean}
+
+### `zipBuffer.keys()`
+
+
+
+* Returns: {Iterator} of entry names.
+
+### `zipBuffer.size`
+
+
+
+* Type: {number}
+
+The number of entries in the archive.
+
+### `zipBuffer.toBuffer([options])`
+
+
+
+* `options` {string|Object} An archive comment, as a shorthand for
+ `{ comment: options }`.
+ * `comment` {string} An archive comment. **Default:** [`zipBuffer.comment`][].
+ * `baseOffset` {number} Shifts every offset the archive records by this
+ many bytes, so the serialized archive is self-describing even when it is
+ written somewhere other than the start of its eventual file - for example,
+ after `baseOffset` bytes of other content already written to the same
+ output. **Default:** `0`.
+* Returns: {Promise} Fulfilled with a {Buffer} containing the serialized
+ archive.
+
+Serializes the current set of entries - in the order they were added or
+read - into a fresh archive, switching to Zip64 structures automatically as
+needed (see [`zlib.createZipArchive()`][]).
+
+### `zipBuffer.toBufferSync([options])`
+
+
+
+* `options` {string|Object} See [`zipBuffer.toBuffer()`][].
+* Returns: {Buffer} The serialized archive.
+
+The synchronous version of [`zipBuffer.toBuffer()`][] (see
+[`zlib.createZipArchiveSync()`][]).
+
+### `zipBuffer.values()`
+
+
+
+* Returns: {Iterator} of [`ZipEntry`][].
+
+### `zipBuffer.writable`
+
+
+
+* Type: {boolean}
+
+Always `true`.
+
+## Class: `zlib.ZipEntry`
+
+
+
+> Stability: 1 - Experimental
+
+A single file or directory inside a ZIP archive. Instances are produced by
+[`ZipBuffer`][] and [`ZipFile`][], or created directly for writing with
+`ZipEntry.create()`/`ZipEntry.createStream()`.
+
+`create()` and `content()` each have a `*Sync` counterpart (the streaming
+`contentIterator()` does not). As with the synchronous `node:fs` APIs, these
+block the
+Node.js event loop and further JavaScript execution until the operation
+(including any deflate/inflate pass) completes; use them only where
+synchronous execution is appropriate (for example, short-lived scripts or
+startup code), not in code that must stay responsive.
+
+### Static method: `zlib.ZipEntry.create(filename, data[, options])`
+
+
+
+* `filename` {string} The entry's name within the archive. A trailing `/`
+ marks a directory entry.
+* `data` {Buffer|TypedArray|DataView|ArrayBuffer} The entry's complete,
+ uncompressed content. Must be empty when `filename` names a directory.
+* `options` {Object}
+ * `comment` {string} An entry comment.
+ * `mode` {integer} Unix permission bits. **Default:** `0o644` (`0o755` for
+ directories).
+ * `modified` {Date} The entry's modification time. **Default:** the
+ current time.
+ * `method` {string} One of `'deflate'`, `'store'`, or `'zstd'`. **Default:**
+ `'deflate'`, except for directories and empty content, which are always
+ stored.
+* Returns: {Promise} Fulfilled with a {ZipEntry}.
+
+Compresses `data` (unless `method` is `'store'`, or compression would not
+reduce its size) and computes its CRC-32.
+
+When the entry ends up stored uncompressed (because `method` is `'store'`,
+or because compression would not reduce the size), the entry retains a
+zero-copy view of `data` rather than a copy, and its CRC-32 has already been
+recorded. Do not mutate `data` after creating the entry; pass a copy if it
+might change.
+
+The MS-DOS date/time fields ZIP uses for `modified` have 2-second resolution
+and no time zone. When `modified` does not fall on a whole 2-second
+boundary, an Info-ZIP extended-timestamp extra field is written as well,
+recording the whole (UTC) second so the time round-trips more precisely (see
+[`zipEntry.modified`][]). This applies to every entry-creation path.
+
+### Static method: `zlib.ZipEntry.createStream(filename, source[, options])`
+
+
+
+* `filename` {string} The entry's name within the archive. Must not end
+ in `/`.
+* `source` {AsyncIterable} Yields the entry's uncompressed content as
+ `Uint8Array` chunks.
+* `options` {Object}
+ * `comment` {string} An entry comment.
+ * `mode` {integer} Unix permission bits. **Default:** `0o644`.
+ * `modified` {Date} The entry's modification time. **Default:** the
+ current time.
+ * `method` {string} One of `'deflate'`, `'store'`, or `'zstd'`. **Default:**
+ `'deflate'`.
+* Returns: {ZipEntry}
+
+Creates an entry whose content is compressed on the fly as it is serialized
+by [`zlib.createZipArchive()`][], without buffering `source` in memory. Its
+`size`, `compressedSize`, and `crc32` only become available once
+serialization has finished. There is no synchronous counterpart: streaming
+entries only make sense with an asynchronous, incrementally-produced
+`source`.
+
+`source` is drained exactly once, during serialization. Until that happens
+the entry has no readable content, so [`zipEntry.content()`][],
+[`zipEntry.contentSync()`][], and [`zipEntry.contentIterator()`][] throw
+[`ERR_INVALID_STATE`][]. If the entry is serialized by adding it to a writable
+[`ZipFile`][] with [`zipFile.addEntry()`][] (or `addEntrySync()`), it is then
+**promoted in place** to a file-backed entry pointing at the copy just written,
+so it becomes readable (and can be serialized again) for as long as that
+`ZipFile` stays open. Serializing it any other way (for example directly
+through [`zlib.createZipArchive()`][]) leaves it spent and unreadable.
+
+### Static method: `zlib.ZipEntry.createSymlink(filename, target[, options])`
+
+
+
+* `filename` {string} The entry's name within the archive.
+* `target` {string} The symbolic link's target path.
+* `options` {Object}
+ * `comment` {string} An entry comment.
+ * `mode` {integer} Unix permission bits. **Default:** `0o777`.
+ * `modified` {Date} The entry's modification time. **Default:** the current
+ time.
+* Returns: {ZipEntry}
+
+Creates a symbolic-link entry: a stored entry whose content is `target` and
+whose Unix mode type bits mark it as a symlink, so [`zipEntry.isSymlink`][] is
+`true` when it is read back. Extraction tools that honor symlink entries
+recreate the link; treat `target` as untrusted (see [`zipEntry.name`][] on
+path safety).
+
+### Static method: `zlib.ZipEntry.createSync(filename, data[, options])`
+
+
+
+* `filename` {string} The entry's name within the archive. A trailing `/`
+ marks a directory entry.
+* `data` {Buffer|TypedArray|DataView|ArrayBuffer} The entry's complete,
+ uncompressed content. Must be empty when `filename` names a directory.
+* `options` {Object} See [`zlib.ZipEntry.create()`][].
+* Returns: {ZipEntry}
+
+The synchronous version of [`zlib.ZipEntry.create()`][].
+
+### Static method: `zlib.ZipEntry.read(buffer)`
+
+
+
+* `buffer` {Buffer|TypedArray|DataView|ArrayBuffer} A complete ZIP archive.
+* Returns: {Iterator} of {ZipEntry}.
+
+Parses every entry out of `buffer` directly, without indexing it into a
+[`ZipBuffer`][]. Like [`ZipBuffer`][], the yielded entries hold zero-copy views
+of `buffer` rather than copies of their content, so the same rule applies: do
+not mutate or reuse `buffer` while any of them is still in use.
+
+### `zipEntry.comment`
+
+
+
+* Type: {string}
+
+### `zipEntry.compressed`
+
+
+
+* Type: {boolean}
+
+`true` if the entry's content is stored in compressed form (any compression
+method, currently deflate or Zstandard); `false` if it is stored
+uncompressed.
+
+### `zipEntry.compressedSize`
+
+
+
+* Type: {number}
+
+### `zipEntry.content([options])`
+
+
+
+* `options` {Object}
+ * `verify` {boolean} Verify the entry's CRC-32 checksum. **Default:** `true`.
+ * `maxSize` {number} Reject content declaring more than this many
+ uncompressed bytes, before allocating anything. **Default:**
+ [`zlib.getMaxZipContentSize()`][].
+* Returns: {Promise} Fulfilled with a {Buffer} containing the entry's
+ decompressed content. The buffer is a fresh copy that shares no memory
+ with the archive or with data the entry was created from.
+
+Throws an [`ERR_ZIP_ENTRY_TOO_LARGE`][] error if the entry's declared size
+exceeds `maxSize`, an [`ERR_ZIP_ENTRY_CORRUPT`][] error if the content fails
+CRC-32 verification or does not match its declared size, and an
+[`ERR_INVALID_STATE`][] error for a streaming entry
+([`zlib.ZipEntry.createStream()`][]) whose content is not yet available (see
+that method for when a streaming entry becomes readable).
+
+### `zipEntry.contentSync([options])`
+
+
+
+* `options` {Object} See [`zipEntry.content()`][].
+* Returns: {Buffer} The entry's decompressed content.
+
+The synchronous version of [`zipEntry.content()`][].
+
+### `zipEntry.contentIterator([options])`
+
+
+
+* `options` {Object}
+ * `verify` {boolean} Verify the entry's CRC-32 checksum. **Default:** `true`.
+ * `maxSize` {number} Reject content declaring more than this many
+ uncompressed bytes. **Default:** no limit.
+* Returns: {AsyncIterator} of {Buffer} chunks of the entry's decompressed
+ content.
+
+Unlike [`zipEntry.content()`][], this does not buffer the whole member in
+memory. For a file-backed entry (one returned by [`zipFile.get()`][]) the
+compressed bytes are read from disk as the iterator is consumed and nothing is
+retained; the entry is valid only while its `ZipFile` is open.
+
+For an in-memory entry stored without compression, the yielded chunks are
+zero-copy views of the entry's retained content (see
+[`zipEntry.rawContent`][]); do not mutate them.
+
+### `zipEntry.crc32`
+
+
+
+* Type: {number}
+
+### `zipEntry.flags`
+
+
+
+* Type: {number}
+
+The entry's raw general-purpose bit flag.
+
+### `zipEntry.isDirectory`
+
+
+
+* Type: {boolean}
+
+`true` if the entry is a directory (its name ends with `/`).
+
+### `zipEntry.isFile`
+
+
+
+* Type: {boolean}
+
+`true` if the entry is a regular file — that is, neither a directory nor a
+symbolic link.
+
+### `zipEntry.isSymlink`
+
+
+
+* Type: {boolean}
+
+`true` if the entry is a symbolic link (its Unix mode type bits are
+`S_IFLNK`); its content is the link target. Always `false` for archives not
+written on a Unix-like system. When extracting, treat a symlink's target as
+untrusted — see [`zipEntry.name`][] on path safety.
+
+### `zipEntry.mode`
+
+
+
+* Type: {number}
+
+The entry's Unix mode permission bits, including the setuid, setgid, and
+sticky bits (the low 12 bits, `0o7777`), or `0` if the archive was not written
+on a Unix-like system. The file-type bits are not included here; use
+[`zipEntry.isDirectory`][] / [`zipEntry.isSymlink`][] for the type.
+
+### `zipEntry.modified`
+
+
+
+* Type: {Date}
+
+The entry's last-modification time. When the archive carries a higher-fidelity
+timestamp in an extra field — an NTFS (`0x000a`), Info-ZIP extended (`0x5455`),
+or Info-ZIP Unix (`0x5855`) field, as most modern tools write — that absolute
+(UTC) time is used; otherwise the coarse, local-time MS-DOS date/time field
+(2-second resolution) is used.
+
+Some tools store their high-fidelity timestamp only in the local file header,
+so on a file-backed entry (one returned by [`zipFile.get()`][]) the first read
+of this property may perform a small synchronous positioned disk read to
+resolve that header. If that read fails, the value silently falls back to the
+central-directory data.
+
+### `zipEntry.method`
+
+
+
+* Type: {number}
+
+The entry's raw compression method: `0` for stored, `8` for deflate, `93`
+for Zstandard.
+
+### `zipEntry.name`
+
+
+
+* Type: {string}
+
+The entry's name, decoded from the central directory, which is treated as
+authoritative — a local file header that disagrees is ignored, so a
+mismatched-header ("ZIP-confusion") archive cannot make `name` disagree with
+what is read. The bytes are decoded from a valid Info-ZIP Unicode Path extra
+field (`0x7075`) when one is present; otherwise as UTF-8 when the
+language-encoding flag (general-purpose bit 11) is set **or the bytes are
+valid UTF-8** (plenty of tools wrote UTF-8 names without ever setting the
+flag); and as CP437 — the historical default — only when they are not.
+See [`zipEntry.nameBuffer`][] for the raw bytes.
+
+The name is returned **verbatim**: it is never normalized, and a name
+containing `..`, a leading `/`, a drive letter, or backslashes is neither
+rewritten nor rejected. A `ZipFile`/`ZipBuffer` never writes to disk, so
+guarding against path traversal ("Zip Slip") when extracting is the caller's
+responsibility.
+
+### `zipEntry.nameBuffer`
+
+
+
+* Type: {Buffer}
+
+The entry's raw name bytes, before any character decoding. Useful when the
+archive's names are in an encoding other than UTF-8 or CP437 and the caller
+wants to decode them itself.
+
+### `zipEntry.rawContent`
+
+
+
+* Type: {Buffer|null}
+
+The entry's raw (still compressed, if applicable) content when it is held in
+memory, or `null` when there is no in-memory buffer to expose - for an entry
+created with [`zlib.ZipEntry.createStream()`][], or a file-backed entry
+returned by [`zipFile.get()`][], whose bytes are read from disk on demand
+rather than retained. Use [`zipEntry.content()`][] or
+[`zipEntry.contentIterator()`][] to read a file-backed entry.
+
+### `zipEntry.size`
+
+
+
+* Type: {number}
+
+The entry's uncompressed size, in bytes.
+
+## Class: `zlib.ZipFile`
+
+
+
+> Stability: 1 - Experimental
+
+A random-access view over the entries of a ZIP archive on disk. Only the
+archive's tail and central directory are read up front; member content is
+read from disk lazily, on demand. Writable when opened with
+`{ writable: true }`: [`zipFile.addEntry()`][]/[`zipFile.add()`][] append the
+new member's data where the central directory used to be, then rewrite the
+central directory immediately after it; [`zipFile.delete()`][] just rewrites
+the central directory. Both mean the file is altered as soon as the method's
+returned `Promise` fulfills. Deleted or replaced members are left behind as
+dead space; [`zipFile.compact()`][] produces a stream with none.
+
+Every method has a `*Sync` counterpart. As with the synchronous `node:fs`
+APIs, these block the Node.js event loop and further JavaScript execution
+until the operation completes; use them only where synchronous execution is
+appropriate (for example, short-lived scripts or startup code), not in code
+that must stay responsive. A synchronous method throws `ERR_INVALID_STATE`
+if called while an asynchronous `add()`, `addEntry()`, `delete()`, or
+`close()` on the same `ZipFile` has not settled yet, since letting the two
+interleave could corrupt the archive.
+
+```mjs
+import { ZipFile } from 'node:zlib';
+import { Buffer } from 'node:buffer';
+
+const zip = await ZipFile.open('archive.zip', { writable: true });
+try {
+ const entry = await zip.get('member.txt');
+ console.log((await entry.content()).toString());
+ for await (const chunk of await zip.stream('huge.bin')) {
+ // Process each chunk without buffering the whole member.
+ }
+ await zip.add('new.txt', Buffer.from('hello'));
+ await zip.delete('unwanted.txt');
+} finally {
+ await zip.close();
+}
+```
+
+```cjs
+const { ZipFile } = require('node:zlib');
+
+async function main() {
+ const zip = await ZipFile.open('archive.zip', { writable: true });
+ try {
+ const entry = await zip.get('member.txt');
+ console.log((await entry.content()).toString());
+ for await (const chunk of await zip.stream('huge.bin')) {
+ // Process each chunk without buffering the whole member.
+ }
+ await zip.add('new.txt', Buffer.from('hello'));
+ await zip.delete('unwanted.txt');
+ } finally {
+ await zip.close();
+ }
+}
+main();
+```
+
+### Static method: `zlib.ZipFile.open(filename[, options])`
+
+
+
+* `filename` {string}
+* `options` {Object}
+ * `writable` {boolean} Open the underlying file for both reading and
+ writing (`'r+'`), enabling [`zipFile.addEntry()`][]/[`zipFile.add()`][]/
+ [`zipFile.delete()`][]. **Default:** `false`.
+* Returns: {Promise} Fulfilled with a {ZipFile}.
+
+Throws an [`ERR_ZIP_ARCHIVE_TOO_LARGE`][] error if the archive's central
+directory is too large to buffer in memory.
+
+### Static method: `zlib.ZipFile.openSync(filename[, options])`
+
+
+
+* `filename` {string}
+* `options` {Object} See [`zlib.ZipFile.open()`][].
+* Returns: {ZipFile}
+
+The synchronous version of [`zlib.ZipFile.open()`][].
+
+### `zipFile.add(filename, data[, options])`
+
+
+
+* `filename` {string} The entry's name within the archive. A trailing `/`
+ marks a directory entry.
+* `data` {Buffer|TypedArray|DataView|ArrayBuffer} The entry's complete,
+ uncompressed content.
+* `options` {Object} See [`zlib.ZipEntry.create()`][].
+* Returns: {Promise} Fulfilled with the created {ZipEntry}.
+
+Equivalent to `zipFile.addEntry(await zlib.ZipEntry.create(filename, data,
+options))`.
+
+### `zipFile.addEntry(entry)`
+
+
+
+* `entry` {ZipEntry}
+* Returns: {Promise} Fulfilled with `entry`.
+
+Writes `entry` where the central directory currently starts, then rewrites
+the central directory to include it, replacing any existing entry of the
+same name. Throws [`ERR_ZIP_NOT_WRITABLE`][] if the `ZipFile` was not opened
+with `{ writable: true }`.
+
+The returned (same) `entry` is left readable: a streaming entry created with
+[`zlib.ZipEntry.createStream()`][], which would otherwise be spent once
+serialized, is promoted in place to a file-backed entry pointing at the copy
+just written (valid while this `ZipFile` is open). In-memory entries keep their
+own buffer unchanged.
+
+### `zipFile.addEntrySync(entry)`
+
+
+
+* `entry` {ZipEntry}
+* Returns: {ZipEntry} `entry`.
+
+The synchronous version of [`zipFile.addEntry()`][]. `entry` must not be a
+pending streaming entry (one created with
+[`zlib.ZipEntry.createStream()`][]) - there is no synchronous way to drain
+its asynchronous source.
+
+### `zipFile.addSync(filename, data[, options])`
+
+
+
+* `filename` {string} The entry's name within the archive. A trailing `/`
+ marks a directory entry.
+* `data` {Buffer|TypedArray|DataView|ArrayBuffer} The entry's complete,
+ uncompressed content.
+* `options` {Object} See [`zlib.ZipEntry.createSync()`][].
+* Returns: {ZipEntry} The created entry.
+
+The synchronous version of [`zipFile.add()`][]. Equivalent to
+`zipFile.addEntrySync(zlib.ZipEntry.createSync(filename, data, options))`.
+
+### `zipFile.close()`
+
+
+
+* Returns: {Promise}
+
+Closes the underlying file handle.
+
+Closing does not invalidate outstanding objects: `ZipEntry` objects previously
+returned by [`zipFile.get()`][] and the `ZipFile`'s own methods will fail with
+system-level errors (for example `EBADF`) if used after close, rather than a
+dedicated Node.js error code. The same applies to [`zipFile.closeSync()`][].
+
+### `zipFile.closeSync()`
+
+
+
+The synchronous version of [`zipFile.close()`][].
+
+### `zipFile.comment`
+
+
+
+* Type: {string}
+
+The archive-level comment, preserved byte-for-byte across
+[`zipFile.addEntry()`][]/[`zipFile.delete()`][] calls. The bytes are decoded
+as UTF-8 when they are valid UTF-8 and as CP437 otherwise (the field carries
+no encoding flag of its own).
+
+### `zipFile.compact([comment])`
+
+
+
+* `comment` {string} An archive comment. **Default:** [`zipFile.comment`][].
+* Returns: {stream.Readable} A stream of the currently live entries,
+ serialized as a fresh archive with no dead space left by prior
+ [`zipFile.addEntry()`][]/[`zipFile.delete()`][] calls.
+
+Does not modify the open file; pipe the result into a new one:
+
+```mjs
+import { createWriteStream } from 'node:fs';
+zip.compact().pipe(createWriteStream('compacted.zip'));
+```
+
+### `zipFile.compactSync([comment])`
+
+
+
+* `comment` {string} An archive comment. **Default:** [`zipFile.comment`][].
+* Returns: {Buffer} The currently live entries, serialized as a fresh
+ archive with no dead space left by prior
+ [`zipFile.addEntry()`][]/[`zipFile.delete()`][] calls.
+
+The synchronous version of [`zipFile.compact()`][]. Does not modify the
+open file.
+
+### `zipFile.delete(name)`
+
+
+
+* `name` {string}
+* Returns: {Promise} Fulfilled with `true` if an entry named `name` existed
+ and was removed, `false` otherwise.
+
+Rewrites the central directory without writing any new content - the
+archive does not grow. Throws [`ERR_ZIP_NOT_WRITABLE`][] if the `ZipFile` was
+not opened with `{ writable: true }`.
+
+### `zipFile.deleteSync(name)`
+
+
+
+* `name` {string}
+* Returns: {boolean} `true` if an entry named `name` existed and was
+ removed, `false` otherwise.
+
+The synchronous version of [`zipFile.delete()`][].
+
+### `zipFile.entries()`
+
+
+
+* Returns: {Iterator} of `[name, entry]` pairs, where `entry` is a
+ {Promise} fulfilled with a [`ZipEntry`][].
+
+### `zipFile.entriesSync()`
+
+
+
+* Returns: {Iterator} of `[name, entry]` pairs, where `entry` is a resolved
+ [`ZipEntry`][] (not a `Promise`).
+
+The synchronous version of [`zipFile.entries()`][].
+
+### `zipFile.forEach(callback[, thisArg])`
+
+
+
+* `callback` {Function}
+* `thisArg` {any}
+
+### `zipFile.forEachSync(callback[, thisArg])`
+
+
+
+* `callback` {Function}
+* `thisArg` {any}
+
+The synchronous version of [`zipFile.forEach()`][]: `callback` is invoked
+with a resolved [`ZipEntry`][] instead of a `Promise`.
+
+### `zipFile.get(name)`
+
+
+
+* `name` {string}
+* Returns: {Promise} Fulfilled with a {ZipEntry}.
+
+Returns a lazy, file-backed [`ZipEntry`][] for `name`. Nothing is read from
+disk here and no content is buffered: the returned entry reads (and, for
+[`zipEntry.content()`][], decompresses) its member straight from the file on
+each access, and the `ZipFile` retains no member content. The entry is valid
+only while this `ZipFile` is open. Reading its content later may throw
+[`ERR_ZIP_ENTRY_TOO_LARGE`][] if the member is too large to hold in a single
+buffer; use [`zipEntry.contentIterator()`][] (or [`zipFile.stream()`][])
+instead. Throws [`ERR_ZIP_ENTRY_NOT_FOUND`][] if the archive has no entry
+named `name`.
+
+### `zipFile.getSync(name)`
+
+
+
+* `name` {string}
+* Returns: {ZipEntry}
+
+The synchronous version of [`zipFile.get()`][]. Like `get()`, it reads
+nothing up front and only builds the lazy handle, so it does not itself block
+on I/O - but reads performed later through the returned entry (such as
+[`zipEntry.contentSync()`][]) do; see the note above on synchronous methods.
+
+### `zipFile.has(name)`
+
+
+
+* `name` {string}
+* Returns: {boolean}
+
+### `zipFile.keys()`
+
+
+
+* Returns: {Iterator} of entry names.
+
+### `zipFile.size`
+
+
+
+* Type: {number}
+
+The number of entries in the archive.
+
+### `zipFile.stream(name[, options])`
+
+
+
+* `name` {string}
+* `options` {Object}
+ * `verify` {boolean} Verify the entry's CRC-32 checksum. **Default:** `true`.
+ * `maxSize` {number} Reject content declaring more than this many
+ uncompressed bytes. **Default:** no limit.
+* Returns: {Promise} Fulfilled with a {stream.Readable} of the member's
+ decompressed content, without buffering the whole member in memory.
+
+Convenience wrapper that resolves to a `Readable` over
+[`zipEntry.contentIterator()`][] of [`zipFile.get()`][]`(name)`; the
+compressed bytes are read from disk as the stream is consumed. The returned
+promise rejects with [`ERR_ZIP_ENTRY_NOT_FOUND`][] if the archive has no entry
+named `name`.
+
+### `zipFile.values()`
+
+
+
+* Returns: {Iterator} of {Promise} objects, each fulfilled with a
+ [`ZipEntry`][].
+
+### `zipFile.valuesSync()`
+
+
+
+* Returns: {Iterator} of resolved [`ZipEntry`][] values (not `Promise`s).
+
+The synchronous version of [`zipFile.values()`][].
+
+### `zipFile.writable`
+
+
+
+* Type: {boolean}
+
+Whether this `ZipFile` was opened with `{ writable: true }`.
+
## Class: `zlib.ZlibBase`
+
+> Stability: 1 - Experimental
+
+* `entries` {Iterable|AsyncIterable} of [`ZipEntry`][].
+* `options` {string|Object} An archive comment, as a shorthand for
+ `{ comment: options }`.
+ * `comment` {string} An archive comment.
+ * `baseOffset` {number} Shifts every local/central header offset the
+ archive records by this many bytes, so the emitted stream is
+ self-describing even when something else is written before it - for
+ example, appending the archive after `baseOffset` bytes already written to
+ the same file, rather than at its start. **Default:** `0`.
+* Returns: {stream.Readable} A byte stream of the serialized archive.
+
+Serializes `entries` into a ZIP archive, switching to Zip64 structures
+automatically once the entry count, or any offset or size, exceeds what the
+classic 32-/16-bit ZIP fields can hold. The returned `Readable` is also an
+`AsyncIterable` of the same {Buffer} chunks it streams.
+
+Entries are written in iteration order and nothing deduplicates names: an
+iterable that yields two entries with the same name produces an archive
+containing both, and most extraction tools keep the one that appears later.
+[`ZipBuffer`][] and [`ZipFile`][] `add()` methods replace entries by name
+instead.
+
+Throws an [`ERR_ZIP_ARCHIVE_TOO_LARGE`][] error if the archive comment
+exceeds 65,535 bytes when encoded as UTF-8.
+
+```mjs
+import { createWriteStream } from 'node:fs';
+import { pipeline } from 'node:stream/promises';
+import { Buffer } from 'node:buffer';
+import { ZipEntry, createZipArchive } from 'node:zlib';
+
+const entries = [
+ await ZipEntry.create('hello.txt', Buffer.from('Hello, world!')),
+ await ZipEntry.create('data/', Buffer.alloc(0)),
+];
+await pipeline(
+ createZipArchive(entries, 'created by node:zlib'),
+ createWriteStream('archive.zip'),
+);
+```
+
+```cjs
+const { createWriteStream } = require('node:fs');
+const { pipeline } = require('node:stream/promises');
+const { ZipEntry, createZipArchive } = require('node:zlib');
+
+async function main() {
+ const entries = [
+ await ZipEntry.create('hello.txt', Buffer.from('Hello, world!')),
+ await ZipEntry.create('data/', Buffer.alloc(0)),
+ ];
+ await pipeline(
+ createZipArchive(entries, 'created by node:zlib'),
+ createWriteStream('archive.zip'),
+ );
+}
+main();
+```
+
+Passing `options.baseOffset` produces an archive that is valid immediately
+when placed after other content in the same file, without relying on a
+reader's self-extracting-archive detection to compensate for the shift:
+
+```mjs
+import { createWriteStream } from 'node:fs';
+import { Buffer } from 'node:buffer';
+import { ZipEntry, createZipArchive } from 'node:zlib';
+
+const prefix = Buffer.from('#!/bin/sh\nexit 0\n');
+const entries = [await ZipEntry.create('hello.txt', Buffer.from('Hello, world!'))];
+const out = createWriteStream('self-extracting.zip');
+out.write(prefix);
+createZipArchive(entries, { baseOffset: prefix.byteLength }).pipe(out);
+```
+
+## `zlib.createZipArchiveSync(entries[, options])`
+
+
+
+> Stability: 1 - Experimental
+
+* `entries` {Iterable} of [`ZipEntry`][].
+* `options` {string|Object} See [`zlib.createZipArchive()`][].
+* Returns: {Iterator} of {Buffer} chunks making up the serialized archive.
+
+The synchronous version of [`zlib.createZipArchive()`][]. Blocks the
+Node.js event loop and further JavaScript execution until the whole
+archive (including any deflate passes) has been produced; use only where
+synchronous execution is appropriate (for example, short-lived scripts or
+startup code), not in code that must stay responsive. `entries` must be a
+plain (synchronous) `Iterable` - a streaming entry created with
+[`zlib.ZipEntry.createStream()`][] throws when its turn to serialize comes
+up, since draining its asynchronous source has no synchronous equivalent.
+
+## `zlib.zipFiles(files[, options])`
+
+
+
+> Stability: 1 - Experimental
+
+* `files` {Iterable} of `[sourcePath, entryName]` string pairs. Any iterable
+ works — an array, a `Map`, the result of `Object.entries()`, a generator.
+* `options` {string|Object}
+ * `followSymlinks` {boolean} Resolve a symbolic link and archive the file it
+ points to, rather than storing the link itself. **Default:** `true`.
+ * `comment` {string} An archive comment; a string `options` is shorthand for
+ `{ comment: options }`.
+ * `baseOffset` {number} See [`zlib.createZipArchive()`][].
+* Returns: {stream.Readable} of {Buffer} chunks making up the serialized
+ archive.
+
+Builds an archive from files on disk. For each `[sourcePath, entryName]` pair
+it reads `sourcePath` and adds an entry named `entryName`, capturing the file's
+Unix mode and modification time. A directory becomes a directory entry; a
+regular file's contents are streamed in (as a [`zlib.ZipEntry.createStream()`][]
+entry) without being buffered in memory. Directory contents are not walked
+recursively — list each path you want included.
+
+When `followSymlinks` is `true` (the default) a symbolic link is resolved and
+archived as its target file; when it is `false` the link itself is stored as a
+symbolic-link entry whose content is the target path (see
+[`zlib.ZipEntry.createSymlink()`][]).
+
+```mjs
+import { zipFiles } from 'node:zlib';
+import { createWriteStream } from 'node:fs';
+import { pipeline } from 'node:stream/promises';
+
+await pipeline(
+ zipFiles([
+ ['/data/report.pdf', 'report.pdf'],
+ ['/data/notes.txt', 'docs/notes.txt'],
+ ]),
+ createWriteStream('archive.zip'),
+);
+```
+
## `zlib.createZstdCompress([options])`
> Stability: 1 - Experimental
@@ -1358,6 +2558,36 @@ added:
Creates and returns a new [`ZstdDecompress`][] object.
+## `zlib.getMaxZipContentSize()`
+
+
+
+> Stability: 1 - Experimental
+
+* Returns: {number}
+
+The current default ceiling, in bytes, applied by [`zipEntry.content()`][]
+when no explicit `maxSize` is given. **Default:** `268435456` (256 MiB).
+
+## `zlib.setMaxZipContentSize(size)`
+
+
+
+> Stability: 1 - Experimental
+
+* `size` {number}
+
+Sets the default ceiling used by [`zipEntry.content()`][] when no explicit
+`maxSize` option is given. This is a guard against zip bombs: an archive
+whose central directory declares a member larger than this is rejected
+before allocating memory for it. Streaming reads
+([`zipEntry.contentIterator()`][], [`zipFile.stream()`][]) are bounded-memory
+by design and are not affected by this setting.
+
## Convenience methods
@@ -2023,11 +3253,22 @@ Create a Zstandard decompression transform.
[`Content-Encoding`]: https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.11
[`DeflateRaw`]: #class-zlibdeflateraw
[`Deflate`]: #class-zlibdeflate
+[`ERR_INVALID_STATE`]: errors.md#err_invalid_state
+[`ERR_ZIP_ARCHIVE_TOO_LARGE`]: errors.md#err_zip_archive_too_large
+[`ERR_ZIP_ENTRY_CORRUPT`]: errors.md#err_zip_entry_corrupt
+[`ERR_ZIP_ENTRY_NOT_FOUND`]: errors.md#err_zip_entry_not_found
+[`ERR_ZIP_ENTRY_TOO_LARGE`]: errors.md#err_zip_entry_too_large
+[`ERR_ZIP_INVALID_ARCHIVE`]: errors.md#err_zip_invalid_archive
+[`ERR_ZIP_NOT_WRITABLE`]: errors.md#err_zip_not_writable
+[`ERR_ZIP_UNSUPPORTED_FEATURE`]: errors.md#err_zip_unsupported_feature
[`Gunzip`]: #class-zlibgunzip
[`Gzip`]: #class-zlibgzip
[`InflateRaw`]: #class-zlibinflateraw
[`Inflate`]: #class-zlibinflate
[`Unzip`]: #class-zlibunzip
+[`ZipBuffer`]: #class-zlibzipbuffer
+[`ZipEntry`]: #class-zlibzipentry
+[`ZipFile`]: #class-zlibzipfile
[`ZlibBase`]: #class-zlibzlibbase
[`ZstdCompress`]: #class-zlibzstdcompress
[`ZstdDecompress`]: #class-zlibzstddecompress
@@ -2037,6 +3278,40 @@ Create a Zstandard decompression transform.
[`pipeTo()`]: stream_iter.md#pipetosource-transforms-writer-options
[`pull()`]: stream_iter.md#pullsource-transforms-options
[`stream.Transform`]: stream.md#class-streamtransform
+[`zipBuffer.add()`]: #zipbufferaddfilename-data-options
+[`zipBuffer.addSync()`]: #zipbufferaddsyncfilename-data-options
+[`zipBuffer.comment`]: #zipbuffercomment
+[`zipBuffer.toBuffer()`]: #zipbuffertobufferoptions
+[`zipBuffer.toBufferSync()`]: #zipbuffertobuffersyncoptions
+[`zipEntry.content()`]: #zipentrycontentoptions
+[`zipEntry.contentIterator()`]: #zipentrycontentiteratoroptions
+[`zipEntry.contentSync()`]: #zipentrycontentsyncoptions
+[`zipEntry.isDirectory`]: #zipentryisdirectory
+[`zipEntry.isSymlink`]: #zipentryissymlink
+[`zipEntry.modified`]: #zipentrymodified
+[`zipEntry.nameBuffer`]: #zipentrynamebuffer
+[`zipEntry.name`]: #zipentryname
+[`zipEntry.rawContent`]: #zipentryrawcontent
+[`zipFile.add()`]: #zipfileaddfilename-data-options
+[`zipFile.addEntry()`]: #zipfileaddentryentry
+[`zipFile.close()`]: #zipfileclose
+[`zipFile.closeSync()`]: #zipfileclosesync
+[`zipFile.comment`]: #zipfilecomment
+[`zipFile.compact()`]: #zipfilecompactcomment
+[`zipFile.delete()`]: #zipfiledeletename
+[`zipFile.entries()`]: #zipfileentries
+[`zipFile.forEach()`]: #zipfileforeachcallback-thisarg
+[`zipFile.get()`]: #zipfilegetname
+[`zipFile.stream()`]: #zipfilestreamname-options
+[`zipFile.values()`]: #zipfilevalues
+[`zlib.ZipEntry.create()`]: #static-method-zlibzipentrycreatefilename-data-options
+[`zlib.ZipEntry.createStream()`]: #static-method-zlibzipentrycreatestreamfilename-source-options
+[`zlib.ZipEntry.createSymlink()`]: #static-method-zlibzipentrycreatesymlinkfilename-target-options
+[`zlib.ZipEntry.createSync()`]: #static-method-zlibzipentrycreatesyncfilename-data-options
+[`zlib.ZipFile.open()`]: #static-method-zlibzipfileopenfilename-options
+[`zlib.createZipArchive()`]: #zlibcreateziparchiveentries-options
+[`zlib.createZipArchiveSync()`]: #zlibcreateziparchivesyncentries-options
+[`zlib.getMaxZipContentSize()`]: #zlibgetmaxzipcontentsize
[convenience methods]: #convenience-methods
[zlib documentation]: https://zlib.net/manual.html#Constants
[zlib.createGzip example]: #zlib
diff --git a/doc/type-map.json b/doc/type-map.json
index 4741264f60e46f..33ffdbc876e40e 100644
--- a/doc/type-map.json
+++ b/doc/type-map.json
@@ -114,6 +114,9 @@
"WritableStreamDefaultWriter": "webstreams.html#class-writablestreamdefaultwriter",
"Worker": "worker_threads.html#class-worker",
"X509Certificate": "crypto.html#class-x509certificate",
+ "ZipBuffer": "zlib.html#class-zlibzipbuffer",
+ "ZipEntry": "zlib.html#class-zlibzipentry",
+ "ZipFile": "zlib.html#class-zlibzipfile",
"brotli options": "zlib.html#class-brotlioptions",
"import.meta": "esm.html#importmeta",
"os.constants.dlopen": "os.html#dlopen-constants",
diff --git a/lib/internal/errors.js b/lib/internal/errors.js
index 03d27332c894d8..45de6b23182617 100644
--- a/lib/internal/errors.js
+++ b/lib/internal/errors.js
@@ -2002,4 +2002,11 @@ E('ERR_WORKER_UNSERIALIZABLE_ERROR',
'Serializing an uncaught exception failed', Error);
E('ERR_WORKER_UNSUPPORTED_OPERATION',
'%s is not supported in workers', TypeError);
+E('ERR_ZIP_ARCHIVE_TOO_LARGE', 'ZIP archive structure exceeds the allowed size: %s', RangeError);
+E('ERR_ZIP_ENTRY_CORRUPT', 'ZIP entry is corrupt: %s', Error);
+E('ERR_ZIP_ENTRY_NOT_FOUND', 'no such entry %j in the archive', Error);
+E('ERR_ZIP_ENTRY_TOO_LARGE', 'ZIP entry exceeds the allowed size: %s', RangeError);
+E('ERR_ZIP_INVALID_ARCHIVE', 'invalid ZIP archive: %s', Error);
+E('ERR_ZIP_NOT_WRITABLE', 'this archive was not opened for writing', TypeError);
+E('ERR_ZIP_UNSUPPORTED_FEATURE', 'unsupported ZIP feature: %s', Error);
E('ERR_ZSTD_INVALID_PARAM', '%s is not a valid zstd parameter', RangeError);
diff --git a/lib/internal/zip.js b/lib/internal/zip.js
new file mode 100644
index 00000000000000..1889a2eea9da0a
--- /dev/null
+++ b/lib/internal/zip.js
@@ -0,0 +1,44 @@
+'use strict';
+
+// Public entry point for ZIP archive support in `node:zlib`. The
+// implementation is split across `internal/zip/`:
+//
+// constants shared signatures, flags, symbols, and small values
+// binary bounds-checked reads and buffer coercion
+// content-size the module-global in-memory decompression ceiling
+// dos MS-DOS date/time and CP437 legacy name/text decoding
+// extra-fields TLV extra-field parsing and building
+// headers reader-side header structures and archive-end location
+// header-builders writer-side header/record builders
+// compression deflate/inflate/zstd plumbing and member decoding
+// fs-util fd read/write helpers
+// entry ZipEntry
+// archive createZipArchive()/zipFiles() serialization
+// buffer ZipBuffer
+// file ZipFile
+//
+// This barrel re-exports only the surface `lib/zlib.js` consumes.
+
+const { ZipEntry } = require('internal/zip/entry');
+const { ZipBuffer } = require('internal/zip/buffer');
+const { ZipFile } = require('internal/zip/file');
+const {
+ createZipArchive,
+ createZipArchiveSync,
+ zipFiles,
+} = require('internal/zip/archive');
+const {
+ getMaxZipContentSize,
+ setMaxZipContentSize,
+} = require('internal/zip/content-size');
+
+module.exports = {
+ ZipEntry,
+ ZipFile,
+ ZipBuffer,
+ createZipArchive,
+ createZipArchiveSync,
+ zipFiles,
+ getMaxZipContentSize,
+ setMaxZipContentSize,
+};
diff --git a/lib/internal/zip/archive.js b/lib/internal/zip/archive.js
new file mode 100644
index 00000000000000..d70e1f154ee882
--- /dev/null
+++ b/lib/internal/zip/archive.js
@@ -0,0 +1,214 @@
+'use strict';
+
+// Archive serialization: `createZipArchive()`/`createZipArchiveSync()` and
+// the `zipFiles()` on-disk variant, the `generateZipArchive()` generator they
+// build on (auto-switching to Zip64 as offsets/counts overflow), and the
+// shared archive-option normalizer.
+
+const {
+ ArrayPrototypePush,
+ NumberMAX_SAFE_INTEGER,
+ StringPrototypeEndsWith,
+} = primordials;
+
+const {
+ codes: {
+ ERR_ZIP_ARCHIVE_TOO_LARGE,
+ },
+} = require('internal/errors');
+const {
+ validateBoolean,
+ validateInteger,
+ validateObject,
+ validateString,
+} = require('internal/validators');
+const { isUint8Array } = require('internal/util/types');
+const { Buffer } = require('buffer');
+const { Readable } = require('stream');
+const fs = require('fs');
+const {
+ EMPTY_BUFFER,
+ SENTINEL16,
+ kFinalize,
+} = require('internal/zip/constants');
+const {
+ buildArchiveTrailer,
+} = require('internal/zip/header-builders');
+const {
+ fsStatAsync,
+ fsLstatAsync,
+ fsReadlinkAsync,
+} = require('internal/zip/fs-util');
+const { ZipEntry } = require('internal/zip/entry');
+
+/**
+ * `createZipArchive()`/`createZipArchiveSync()` (and the `ZipBuffer`
+ * `toBuffer()`/`toBufferSync()` methods that forward to them) take a single
+ * optional `options` argument that doubles as a plain archive comment: a
+ * string is shorthand for `{ comment: options }`.
+ * @param {string | { comment?: string, baseOffset?: number }} [options]
+ * @returns {{ comment: string | undefined, baseOffset: number }}
+ */
+function normalizeArchiveOptions(options) {
+ if (options === undefined) return { comment: undefined, baseOffset: 0 };
+ if (typeof options === 'string') return { comment: options, baseOffset: 0 };
+ validateObject(options, 'options');
+ const { comment, baseOffset = 0 } = options;
+ // A Buffer comment is an internal convenience (used when round-tripping an
+ // existing archive) that preserves the original bytes without forcing them
+ // through a decode/re-encode cycle that would corrupt non-UTF-8 comments.
+ if (comment !== undefined && !isUint8Array(comment)) {
+ validateString(comment, 'options.comment');
+ }
+ validateInteger(baseOffset, 'options.baseOffset', 0, NumberMAX_SAFE_INTEGER);
+ return { comment, baseOffset };
+}
+
+/**
+ * Serializes `entries` (a (async) iterable of `ZipEntry`) into a `Readable`
+ * stream of archive byte chunks, automatically switching to Zip64 structures
+ * once the entry count or any offset/size exceeds the classic 32-/16-bit
+ * limits.
+ *
+ * `options.baseOffset` shifts every local/central header offset the archive
+ * records by that many bytes, so the emitted bytes are self-describing even
+ * when something else is written before them - for example, appending the
+ * archive after `baseOffset` bytes already written to the same file, rather
+ * than at its start.
+ * @param {Iterable | AsyncIterable} entries
+ * @param {string | { comment?: string, baseOffset?: number }} [options]
+ * @returns {Readable}
+ */
+function createZipArchive(entries, options) {
+ return Readable.from(generateZipArchive(entries, options), { objectMode: false });
+}
+
+/**
+ * Creates an archive from files on disk. `files` is an iterable of
+ * `[sourcePath, entryName]` pairs - an array, a `Map`, the result of
+ * `Object.entries()`, a generator, and so on. Each entry captures the file's
+ * Unix mode and modification time; a directory becomes a directory entry and a
+ * regular file's contents are streamed in without being buffered in memory.
+ *
+ * With `options.followSymlinks` (default `true`) a symbolic link is resolved
+ * and archived as the file it points to; with it `false` the link itself is
+ * stored as a symlink entry whose content is the target path.
+ * @param {Iterable<[string, string]>} files
+ * @param {string | { followSymlinks?: boolean, comment?: string, baseOffset?: number }} [options]
+ * @returns {import('stream').Readable}
+ */
+function zipFiles(files, options) {
+ const followSymlinks = options?.followSymlinks ?? true;
+ validateBoolean(followSymlinks, 'options.followSymlinks');
+ return createZipArchive(fileEntries(files, followSymlinks), options);
+}
+
+// Turn each `[sourcePath, entryName]` pair into a `ZipEntry`, stat-ing the
+// source to capture its mode/mtime and picking the symlink/directory/file
+// entry shape; the file-backed variant streams contents rather than buffering.
+async function* fileEntries(files, followSymlinks) {
+ for await (const pair of files) {
+ const sourcePath = pair[0];
+ const name = pair[1];
+ validateString(sourcePath, 'sourcePath');
+ validateString(name, 'name');
+ // Following links resolves through to the target (stat); otherwise the
+ // link itself is inspected (lstat) and stored as a symlink entry.
+ const stats = followSymlinks ? await fsStatAsync(sourcePath) : await fsLstatAsync(sourcePath);
+ const options = { __proto__: null, mode: stats.mode & 0o7777, modified: stats.mtime };
+ if (stats.isSymbolicLink()) {
+ yield ZipEntry.createSymlink(name, await fsReadlinkAsync(sourcePath), options);
+ } else if (stats.isDirectory()) {
+ const dirName = StringPrototypeEndsWith(name, '/') ? name : `${name}/`;
+ yield await ZipEntry.create(dirName, EMPTY_BUFFER, options);
+ } else {
+ yield ZipEntry.createStream(name, fs.createReadStream(sourcePath), options);
+ }
+ }
+}
+
+// Encode the archive comment (a string, or raw bytes when round-tripping a
+// non-UTF-8 comment) and enforce the 16-bit length field (sec. 4.3.16).
+function normalizeCommentBuffer(comment) {
+ if (comment === undefined) return EMPTY_BUFFER;
+ const buffer = isUint8Array(comment) ? comment : Buffer.from(comment, 'utf8');
+ if (buffer.length > SENTINEL16) {
+ throw new ERR_ZIP_ARCHIVE_TOO_LARGE(
+ 'the archive comment must not exceed 65535 bytes when encoded as UTF-8');
+ }
+ return buffer;
+}
+
+// Core serializer backing `createZipArchive()`: emits each entry's local header
+// + data, then the central directory, then the end-of-central-directory record
+// (APPNOTE sec. 4.3.6 archive layout), promoting to Zip64 end records
+// (sec. 4.3.14/4.3.15) once any count/offset/size overflows its classic field.
+async function* generateZipArchive(entries, options) {
+ const { comment, baseOffset } = normalizeArchiveOptions(options);
+ const commentBuffer = normalizeCommentBuffer(comment);
+ const centralHeaders = [];
+ let pos = baseOffset;
+ for await (const entry of entries) {
+ const start = pos;
+ for await (const chunk of entry) {
+ yield chunk;
+ pos += chunk.length;
+ }
+ ArrayPrototypePush(centralHeaders, entry[kFinalize](start));
+ }
+ const centralDirectoryOffset = pos;
+ for (let i = 0; i < centralHeaders.length; i++) {
+ const chunk = centralHeaders[i];
+ yield chunk;
+ pos += chunk.length;
+ }
+ const centralDirectorySize = pos - centralDirectoryOffset;
+ const count = centralHeaders.length;
+ const trailer = buildArchiveTrailer(count, centralDirectorySize, centralDirectoryOffset, commentBuffer);
+ for (let i = 0; i < trailer.length; i++) yield trailer[i];
+}
+
+/**
+ * The synchronous counterpart of `createZipArchive()`. `entries` must be a
+ * plain (synchronous) `Iterable` of entries that don't require an
+ * asynchronous serialization pass - a streaming entry created with
+ * `ZipEntry.createStream()` throws when its turn to serialize comes up, the
+ * same as calling `entry[Symbol.iterator]()` on one directly. Blocks the
+ * event loop and further JavaScript execution until the whole archive
+ * (including any deflate passes) has been produced; see
+ * `zipEntry.contentSync()`.
+ * @param {Iterable} entries
+ * @param {string | { comment?: string, baseOffset?: number }} [options]
+ * @yields {Buffer}
+ */
+function* createZipArchiveSync(entries, options) {
+ const { comment, baseOffset } = normalizeArchiveOptions(options);
+ const commentBuffer = normalizeCommentBuffer(comment);
+ const centralHeaders = [];
+ let pos = baseOffset;
+ for (const entry of entries) {
+ const start = pos;
+ for (const chunk of entry) {
+ yield chunk;
+ pos += chunk.length;
+ }
+ ArrayPrototypePush(centralHeaders, entry[kFinalize](start));
+ }
+ const centralDirectoryOffset = pos;
+ for (let i = 0; i < centralHeaders.length; i++) {
+ const chunk = centralHeaders[i];
+ yield chunk;
+ pos += chunk.length;
+ }
+ const centralDirectorySize = pos - centralDirectoryOffset;
+ const count = centralHeaders.length;
+ const trailer = buildArchiveTrailer(count, centralDirectorySize, centralDirectoryOffset, commentBuffer);
+ for (let i = 0; i < trailer.length; i++) yield trailer[i];
+}
+
+module.exports = {
+ normalizeArchiveOptions,
+ createZipArchive,
+ createZipArchiveSync,
+ zipFiles,
+};
diff --git a/lib/internal/zip/binary.js b/lib/internal/zip/binary.js
new file mode 100644
index 00000000000000..3aa6b89a5cd371
--- /dev/null
+++ b/lib/internal/zip/binary.js
@@ -0,0 +1,87 @@
+'use strict';
+
+// Low-level binary helpers: bounds-checked archive ranges, safe 64-bit
+// integer read/write, and buffer coercion of user input.
+
+const {
+ BigInt,
+ Number,
+ NumberIsInteger,
+} = primordials;
+
+const {
+ codes: {
+ ERR_INVALID_ARG_TYPE,
+ ERR_ZIP_INVALID_ARCHIVE,
+ },
+} = require('internal/errors');
+const {
+ isAnyArrayBuffer,
+ isArrayBufferView,
+ isUint8Array,
+} = require('internal/util/types');
+const { Buffer } = require('buffer');
+const { BIGINT_MAX_SAFE_INTEGER } = require('internal/zip/constants');
+
+// Reject an [offset, offset + length) slice that escapes the archive buffer
+// before it is used to read a record: guards every offset taken from archive
+// bytes against corrupt or hostile values.
+function validateArchiveRange(buffer, offset, length, what) {
+ if (
+ !NumberIsInteger(offset) ||
+ offset < 0 ||
+ !NumberIsInteger(length) ||
+ length < 0 ||
+ offset + length > buffer.length
+ ) {
+ throw new ERR_ZIP_INVALID_ARCHIVE(`${what} is out of bounds`);
+ }
+}
+
+// Read a little-endian u64 (sizes/offsets, Zip64) that must land in the JS
+// safe-integer range; a field past the buffer or beyond that range means a
+// corrupt or hostile archive.
+function readSafeUint64(buffer, offset) {
+ if (offset + 8 > buffer.length) {
+ throw new ERR_ZIP_INVALID_ARCHIVE('64-bit field is out of bounds');
+ }
+ const value = buffer.readBigUInt64LE(offset);
+ if (value > BIGINT_MAX_SAFE_INTEGER) {
+ throw new ERR_ZIP_INVALID_ARCHIVE('64-bit field exceeds the safe integer range');
+ }
+ return Number(value);
+}
+
+// Write a JS number as a little-endian u64; the write paths only feed values
+// already bounded by the safe-integer range, so no range check is needed here.
+function writeSafeUint64(buffer, offset, value) {
+ buffer.writeBigUInt64LE(BigInt(value), offset);
+}
+
+
+// Coerce user-supplied binary input to a Buffer, aliasing the same memory
+// (no copy) for a TypedArray/DataView/ArrayBuffer and rejecting anything else.
+// internal/crypto/util.js's getArrayBufferOrView() covers similar coercion but
+// returns the view unchanged (and accepts strings with an encoding); this
+// helper exists because the ZIP code needs an actual Buffer over that memory.
+function toBuffer(value, name) {
+ if (isUint8Array(value)) {
+ return Buffer.isBuffer(value) ?
+ value : Buffer.from(value.buffer, value.byteOffset, value.byteLength);
+ }
+ if (isArrayBufferView(value)) {
+ return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
+ }
+ if (isAnyArrayBuffer(value)) {
+ return Buffer.from(value);
+ }
+ throw new ERR_INVALID_ARG_TYPE(
+ name, ['Buffer', 'TypedArray', 'DataView', 'ArrayBuffer'], value);
+}
+
+module.exports = {
+ validateArchiveRange,
+ readSafeUint64,
+ writeSafeUint64,
+ toBuffer,
+};
diff --git a/lib/internal/zip/buffer.js b/lib/internal/zip/buffer.js
new file mode 100644
index 00000000000000..582d8ae799afb9
--- /dev/null
+++ b/lib/internal/zip/buffer.js
@@ -0,0 +1,186 @@
+'use strict';
+
+// `ZipBuffer`: an in-memory, writable view over the entries of an archive
+// held in a `Buffer`, serializing the current set back out with
+// `toBuffer()`/`toBufferSync()`.
+
+const {
+ ArrayPrototypePush,
+ FunctionPrototypeCall,
+ Map,
+ MapPrototypeClear,
+ MapPrototypeDelete,
+ MapPrototypeEntries,
+ MapPrototypeGet,
+ MapPrototypeGetSize,
+ MapPrototypeHas,
+ MapPrototypeKeys,
+ MapPrototypeSet,
+ SymbolDispose,
+ SymbolIterator,
+ SymbolToStringTag,
+} = primordials;
+
+const {
+ codes: {
+ ERR_INVALID_ARG_TYPE,
+ ERR_ZIP_ENTRY_NOT_FOUND,
+ },
+} = require('internal/errors');
+const {
+ validateFunction,
+ validateString,
+} = require('internal/validators');
+const { Buffer } = require('buffer');
+const { toBuffer } = require('internal/zip/binary');
+const { decodeZipText } = require('internal/zip/dos');
+const { findArchiveEnd } = require('internal/zip/headers');
+const {
+ readArchiveEntries,
+ ZipEntry,
+} = require('internal/zip/entry');
+const {
+ createZipArchive,
+ createZipArchiveSync,
+ normalizeArchiveOptions,
+} = require('internal/zip/archive');
+
+/**
+ * An in-memory view over the entries of a ZIP archive, writable in place:
+ * entries can be added or removed, and `toBuffer()` serializes the current
+ * set of entries into a fresh archive.
+ */
+class ZipBuffer {
+ #entries = new Map();
+ #comment;
+
+ /**
+ * Parses an existing archive's central directory into an in-memory,
+ * editable map of entries keyed by name.
+ * @param {Buffer | TypedArray | DataView | ArrayBuffer} buffer
+ */
+ constructor(buffer) {
+ const buf = toBuffer(buffer, 'buffer');
+ // Locate the archive end once; it supplies both the comment and the
+ // central-directory bounds for the entry walk.
+ const end = findArchiveEnd(buf);
+ this.#comment = end.comment;
+ for (const entry of readArchiveEntries(buf, end)) {
+ MapPrototypeSet(this.#entries, entry.name, entry);
+ }
+ }
+ get writable() { return true; }
+ // The EOCD comment has no encoding flag; apply the same UTF-8/CP437
+ // heuristic as unflagged member names and comments.
+ get comment() { return decodeZipText(this.#comment, 0); }
+ has(name) {
+ validateString(name, 'name');
+ return MapPrototypeHas(this.#entries, name);
+ }
+ get(name) {
+ validateString(name, 'name');
+ const entry = MapPrototypeGet(this.#entries, name);
+ if (entry === undefined) throw new ERR_ZIP_ENTRY_NOT_FOUND(name);
+ return entry;
+ }
+ /**
+ * Adds an already-built entry, keyed by its own name (replacing any
+ * existing entry of that name).
+ * @param {ZipEntry} entry
+ * @returns {ZipEntry}
+ */
+ addEntry(entry) {
+ if (!(entry instanceof ZipEntry)) {
+ throw new ERR_INVALID_ARG_TYPE('entry', 'ZipEntry', entry);
+ }
+ MapPrototypeSet(this.#entries, entry.name, entry);
+ return entry;
+ }
+ /**
+ * Builds an entry from in-memory `data` and adds it (replacing any entry of
+ * the same name).
+ * @param {string} filename
+ * @param {Buffer | TypedArray | DataView | ArrayBuffer} data
+ * @param {{ comment?: string, mode?: number, modified?: Date, method?: 'deflate' | 'store' | 'zstd' }} [options]
+ * @returns {Promise}
+ */
+ async add(filename, data, options) {
+ return this.addEntry(await ZipEntry.create(filename, data, options));
+ }
+ /**
+ * The synchronous counterpart of `add()`. Blocks the event loop and
+ * further JavaScript execution until done; see `zipEntry.contentSync()`.
+ * @param {string} filename
+ * @param {Buffer | TypedArray | DataView | ArrayBuffer} data
+ * @param {{ comment?: string, mode?: number, modified?: Date, method?: 'deflate' | 'store' | 'zstd' }} [options]
+ * @returns {ZipEntry}
+ */
+ addSync(filename, data, options) {
+ return this.addEntry(ZipEntry.createSync(filename, data, options));
+ }
+ /**
+ * @param {string} name
+ * @returns {boolean}
+ */
+ delete(name) {
+ validateString(name, 'name');
+ return MapPrototypeDelete(this.#entries, name);
+ }
+ clear() {
+ MapPrototypeClear(this.#entries);
+ }
+ keys() { return MapPrototypeKeys(this.#entries); }
+ *values() {
+ for (const name of this.keys()) yield this.get(name);
+ }
+ *entries() {
+ for (const name of this.keys()) yield [name, this.get(name)];
+ }
+ get size() { return MapPrototypeGetSize(this.#entries); }
+ [SymbolIterator]() { return this.entries(); }
+ get [SymbolToStringTag]() { return 'ZipBuffer'; }
+ forEach(callback, thisArg) {
+ validateFunction(callback, 'callback');
+ for (const { 0: key, 1: value } of MapPrototypeEntries(this.#entries)) {
+ FunctionPrototypeCall(callback, thisArg === undefined ? this : thisArg, value, key, this);
+ }
+ }
+ /**
+ * Serializes the current set of entries into a fresh archive.
+ * @param {string | { comment?: string, baseOffset?: number }} [options]
+ * @returns {Promise}
+ */
+ async toBuffer(options) {
+ const { comment, baseOffset } = normalizeArchiveOptions(options);
+ const chunks = [];
+ // Defaulting to the raw #comment bytes (not the decoded string)
+ // round-trips a non-UTF-8 archive comment unchanged.
+ for await (const chunk of createZipArchive(this.values(), { comment: comment ?? this.#comment, baseOffset })) {
+ ArrayPrototypePush(chunks, chunk);
+ }
+ return Buffer.concat(chunks);
+ }
+ /**
+ * The synchronous counterpart of `toBuffer()`. Blocks the event loop and
+ * further JavaScript execution until the whole archive has been
+ * serialized; see `zipEntry.contentSync()`.
+ * @param {string | { comment?: string, baseOffset?: number }} [options]
+ * @returns {Buffer}
+ */
+ toBufferSync(options) {
+ const { comment, baseOffset } = normalizeArchiveOptions(options);
+ const chunks = [];
+ for (const chunk of createZipArchiveSync(this.values(), { comment: comment ?? this.#comment, baseOffset })) {
+ ArrayPrototypePush(chunks, chunk);
+ }
+ return Buffer.concat(chunks);
+ }
+ // Dispose: drop all entries (this view holds no fd of its own).
+ [SymbolDispose]() {
+ MapPrototypeClear(this.#entries);
+ }
+}
+
+module.exports = {
+ ZipBuffer,
+};
diff --git a/lib/internal/zip/compression.js b/lib/internal/zip/compression.js
new file mode 100644
index 00000000000000..fd9a7373c365c1
--- /dev/null
+++ b/lib/internal/zip/compression.js
@@ -0,0 +1,303 @@
+'use strict';
+
+// Compression/decompression plumbing over the lazily-required `zlib` facade:
+// one-shot (async and sync) and streaming deflate/inflate/zstd helpers, plus
+// the member decoders that add method dispatch, size bounding, and CRC-32
+// verification on top.
+
+const {
+ JSONStringify,
+ MathMin,
+ Promise,
+} = primordials;
+
+const {
+ codes: {
+ ERR_ZIP_ENTRY_CORRUPT,
+ ERR_ZIP_ENTRY_TOO_LARGE,
+ ERR_ZIP_UNSUPPORTED_FEATURE,
+ },
+} = require('internal/errors');
+const { kMaxLength } = require('buffer');
+const { compose } = require('stream');
+const { crc32: crc32Native } = internalBinding('zlib');
+const {
+ FLAG_ENCRYPTED,
+ METHOD_STORE,
+ METHOD_DEFLATE,
+ METHOD_ZSTD,
+} = require('internal/zip/constants');
+
+// `internal/zip` is required from `lib/zlib.js`, so it must not require the
+// public `zlib` facade at load time (its module.exports is not yet
+// populated). Compression is only needed once an entry is actually read or
+// written, well after `zlib.js` has finished loading, so a lazy reference is
+// enough to break the cycle.
+let zlib;
+function lazyZlib() {
+ zlib ??= require('zlib');
+ return zlib;
+}
+
+// -- compression plumbing ------------------------------------------------------
+
+// The one-shot (async/sync) and streaming helpers below are thin adapters that
+// promisify or stream-wrap the lazy `zlib` facade; individually trivial, they
+// exist only so the rest of the module never touches `zlib` directly.
+
+function deflateRawAsync(buffer) {
+ return new Promise((resolve, reject) => {
+ lazyZlib().deflateRaw(buffer, (err, result) => {
+ if (err) reject(err);
+ else resolve(result);
+ });
+ });
+}
+
+function inflateRawAsync(buffer, options) {
+ return new Promise((resolve, reject) => {
+ lazyZlib().inflateRaw(buffer, options, (err, result) => {
+ if (err) reject(err);
+ else resolve(result);
+ });
+ });
+}
+
+// Drive `source` through a zlib transform stream, returning an async-iterable
+// stream of its output. `compose()` (pipeline-backed) wires error propagation
+// both ways and tears the whole chain down when the consumer errors or stops
+// early, so an abandoned iteration cannot leak the pipeline.
+function pumpThroughTransform(source, transform) {
+ return compose(source, transform);
+}
+
+function deflateRawStream(source) {
+ return pumpThroughTransform(source, lazyZlib().createDeflateRaw());
+}
+
+function inflateRawStream(source) {
+ return pumpThroughTransform(source, lazyZlib().createInflateRaw());
+}
+
+function zstdCompressAsync(buffer) {
+ return new Promise((resolve, reject) => {
+ lazyZlib().zstdCompress(buffer, (err, result) => {
+ if (err) reject(err);
+ else resolve(result);
+ });
+ });
+}
+
+function zstdDecompressAsync(buffer, options) {
+ return new Promise((resolve, reject) => {
+ lazyZlib().zstdDecompress(buffer, options, (err, result) => {
+ if (err) reject(err);
+ else resolve(result);
+ });
+ });
+}
+
+function zstdCompressStream(source) {
+ return pumpThroughTransform(source, lazyZlib().createZstdCompress());
+}
+
+function zstdDecompressStream(source) {
+ return pumpThroughTransform(source, lazyZlib().createZstdDecompress());
+}
+
+
+function deflateRawSync(buffer) {
+ return lazyZlib().deflateRawSync(buffer);
+}
+
+function zstdCompressSync(buffer) {
+ return lazyZlib().zstdCompressSync(buffer);
+}
+
+/**
+ * @typedef {{
+ * name: string,
+ * flags: number,
+ * method: number,
+ * crc32: number,
+ * uncompressedSize: number,
+ * }} ZipMemberInfo
+ */
+
+// Shared entry guards for the member decoders below: encryption and
+// unsupported compression methods are rejected up front, and when the caller
+// bounds the output, a declared size beyond that bound fails before anything
+// is decompressed or allocated.
+function assertDecodable(info, options) {
+ if (info.flags & FLAG_ENCRYPTED) {
+ throw new ERR_ZIP_UNSUPPORTED_FEATURE(
+ `entry ${JSONStringify(info.name)} is encrypted`);
+ }
+ if (info.method !== METHOD_STORE && info.method !== METHOD_DEFLATE && info.method !== METHOD_ZSTD) {
+ throw new ERR_ZIP_UNSUPPORTED_FEATURE(
+ `entry ${JSONStringify(info.name)} uses compression method ${info.method}`);
+ }
+ if (options?.maxSize !== undefined && info.uncompressedSize > options.maxSize) {
+ throw new ERR_ZIP_ENTRY_TOO_LARGE(
+ `entry ${JSONStringify(info.name)} declares ${info.uncompressedSize} bytes, ` +
+ `exceeding the ${options.maxSize} byte limit`);
+ }
+}
+
+// Bound one-shot decompression by the declared size, not just the caller's
+// limit: a member that decompresses to more than it declares is corrupt by
+// definition, so there is no reason to materialize more than `declared + 1`
+// bytes (the +1 makes an overrun detectable) no matter how generous `maxSize`
+// is. This keeps a tiny archive from forcing a `maxSize`-sized allocation.
+function outputCap(info, options) {
+ return MathMin(
+ info.uncompressedSize + 1, options?.maxSize ?? kMaxLength, kMaxLength);
+}
+
+// Map a one-shot decompression failure to a corrupt-entry error.
+// `assertDecodable()` has already ensured `declared <= maxSize`, so hitting
+// the output cap always means the stream produced more than the member
+// declared.
+function rethrowDecodeFailure(err, info, method) {
+ if (err?.code === 'ERR_BUFFER_TOO_LARGE') {
+ throw new ERR_ZIP_ENTRY_CORRUPT(
+ `entry ${JSONStringify(info.name)} ` +
+ `${method === METHOD_DEFLATE ? 'inflates' : 'decompresses'} beyond its ` +
+ `declared size of ${info.uncompressedSize} bytes`);
+ }
+ throw new ERR_ZIP_ENTRY_CORRUPT(
+ `entry ${JSONStringify(info.name)} failed to ` +
+ `${method === METHOD_DEFLATE ? 'inflate' : 'decompress'}: ${err.message}`);
+}
+
+// Enforce the declared size and (unless opted out) the CRC-32 on a fully
+// decoded member.
+function checkDecoded(data, info, verify) {
+ if (data.length !== info.uncompressedSize) {
+ throw new ERR_ZIP_ENTRY_CORRUPT(
+ `entry ${JSONStringify(info.name)} produced ${data.length} bytes, expected ` +
+ `${info.uncompressedSize}`);
+ }
+ if (verify && crc32Native(data, 0) !== info.crc32) {
+ throw new ERR_ZIP_ENTRY_CORRUPT(
+ `entry ${JSONStringify(info.name)} failed CRC-32 verification`);
+ }
+}
+
+/**
+ * Decodes one member's compressed byte stream: rejects encrypted entries and
+ * unsupported compression methods, inflates method 8 or decompresses method
+ * 93 (Zstandard), enforces the declared uncompressed size and verifies
+ * CRC-32 (on by default).
+ * @param {AsyncIterable} source
+ * @param {ZipMemberInfo} info
+ * @param {{ verify?: boolean, maxSize?: number }} [options]
+ * @yields {Buffer}
+ */
+async function* decodeMemberStream(source, info, options) {
+ assertDecodable(info, options);
+ const verify = options?.verify !== false;
+ let produced = 0;
+ let state = 0;
+ const decoded = info.method === METHOD_DEFLATE ? inflateRawStream(source) :
+ info.method === METHOD_ZSTD ? zstdDecompressStream(source) : source;
+ for await (const chunk of decoded) {
+ produced += chunk.length;
+ if (produced > info.uncompressedSize) {
+ throw new ERR_ZIP_ENTRY_CORRUPT(
+ `entry ${JSONStringify(info.name)} inflates beyond its declared size of ` +
+ `${info.uncompressedSize} bytes`);
+ }
+ if (verify) state = crc32Native(chunk, state);
+ yield chunk;
+ }
+ if (produced !== info.uncompressedSize) {
+ throw new ERR_ZIP_ENTRY_CORRUPT(
+ `entry ${JSONStringify(info.name)} is truncated: got ${produced} of ` +
+ `${info.uncompressedSize} bytes`);
+ }
+ if (verify && state !== info.crc32) {
+ throw new ERR_ZIP_ENTRY_CORRUPT(
+ `entry ${JSONStringify(info.name)} failed CRC-32 verification`);
+ }
+}
+
+/**
+ * Decodes one member held completely in `compressed`, in one shot: the same
+ * guards, declared-size bounding, and CRC-32 verification as
+ * `decodeMemberStream()`, returning the whole decoded member. For the store
+ * method the input buffer itself is returned - callers that must hand out
+ * caller-owned memory copy it (see `zipEntry.content()`).
+ * @param {Buffer} compressed
+ * @param {ZipMemberInfo} info
+ * @param {{ verify?: boolean, maxSize?: number }} [options]
+ * @returns {Promise}
+ */
+async function decodeMemberAsync(compressed, info, options) {
+ assertDecodable(info, options);
+ const cap = outputCap(info, options);
+ let data;
+ if (info.method === METHOD_DEFLATE) {
+ try {
+ data = await inflateRawAsync(compressed, { maxOutputLength: cap });
+ } catch (err) {
+ rethrowDecodeFailure(err, info, METHOD_DEFLATE);
+ }
+ } else if (info.method === METHOD_ZSTD) {
+ try {
+ data = await zstdDecompressAsync(compressed, { maxOutputLength: cap });
+ } catch (err) {
+ rethrowDecodeFailure(err, info, METHOD_ZSTD);
+ }
+ } else {
+ data = compressed;
+ }
+ checkDecoded(data, info, options?.verify !== false);
+ return data;
+}
+
+/**
+ * The synchronous counterpart of `decodeMemberAsync()`. There is no public
+ * synchronous incremental inflate API, so - unlike the streaming path -
+ * `compressed` must already be the member's complete compressed byte
+ * stream, and the whole result is produced (and verified) in one call
+ * rather than yielded incrementally.
+ * @param {Buffer} compressed
+ * @param {ZipMemberInfo} info
+ * @param {{ verify?: boolean, maxSize?: number }} [options]
+ * @returns {Buffer}
+ */
+function decodeMemberSync(compressed, info, options) {
+ assertDecodable(info, options);
+ const cap = outputCap(info, options);
+ let data;
+ if (info.method === METHOD_DEFLATE) {
+ try {
+ data = lazyZlib().inflateRawSync(compressed, { maxOutputLength: cap });
+ } catch (err) {
+ rethrowDecodeFailure(err, info, METHOD_DEFLATE);
+ }
+ } else if (info.method === METHOD_ZSTD) {
+ try {
+ data = lazyZlib().zstdDecompressSync(compressed, { maxOutputLength: cap });
+ } catch (err) {
+ rethrowDecodeFailure(err, info, METHOD_ZSTD);
+ }
+ } else {
+ data = compressed;
+ }
+ checkDecoded(data, info, options?.verify !== false);
+ return data;
+}
+
+module.exports = {
+ deflateRawAsync,
+ deflateRawSync,
+ zstdCompressAsync,
+ zstdCompressSync,
+ deflateRawStream,
+ zstdCompressStream,
+ decodeMemberStream,
+ decodeMemberAsync,
+ decodeMemberSync,
+};
diff --git a/lib/internal/zip/constants.js b/lib/internal/zip/constants.js
new file mode 100644
index 00000000000000..2fe9d684b7f4e7
--- /dev/null
+++ b/lib/internal/zip/constants.js
@@ -0,0 +1,109 @@
+'use strict';
+
+// Shared constants, symbols, and tiny shared values for the ZIP
+// implementation. This module is a leaf: it requires nothing from the rest of
+// `internal/zip`, so every other zip module can require it without cycles.
+
+const {
+ BigInt,
+ NumberMAX_SAFE_INTEGER,
+ Symbol,
+} = primordials;
+
+const { FastBuffer } = require('internal/buffer');
+
+const EMPTY_BUFFER = new FastBuffer();
+const BIGINT_MAX_SAFE_INTEGER = BigInt(NumberMAX_SAFE_INTEGER);
+
+// ZIP record signatures (APPNOTE.TXT, PKWARE Inc.)
+const SIG_LOCAL_FILE_HEADER = 0x04034b50; // sec. 4.3.7
+const SIG_DATA_DESCRIPTOR = 0x08074b50; // sec. 4.3.9
+const SIG_CENTRAL_FILE_HEADER = 0x02014b50; // sec. 4.3.12
+const SIG_ZIP64_EOCD_RECORD = 0x06064b50; // sec. 4.3.14
+const SIG_ZIP64_EOCD_LOCATOR = 0x07064b50; // sec. 4.3.15
+const SIG_EOCD = 0x06054b50; // sec. 4.3.16
+
+const MADE_BY_UNIX = 3; // sec. 4.4.2
+const ZIP64_EXTRA_ID = 0x0001; // sec. 4.5.3
+
+const SENTINEL16 = 0xffff;
+const SENTINEL32 = 0xffffffff;
+
+const FLAG_ENCRYPTED = 0x0001; // sec. 4.4.4 bit 0
+const FLAG_DATA_DESCRIPTOR = 0x0008; // bit 3
+const FLAG_UTF8 = 0x0800; // bit 11: name/comment are UTF-8 (EFS)
+
+const METHOD_STORE = 0; // sec. 4.4.5
+const METHOD_DEFLATE = 8; // sec. 4.4.5
+const METHOD_ZSTD = 93; // sec. 4.4.5
+
+const VERSION_DEFAULT = 20; // 2.0: deflate + directories (sec. 4.4.3)
+const VERSION_ZIP64 = 45; // 4.5: Zip64 structures
+const VERSION_ZSTD = 63; // 6.3: Zstandard compression (method 93)
+
+// The Zip64 EOCD record may carry an extensible data sector of arbitrary
+// length between its fixed part and the locator, so it can start before any
+// fixed-size tail read. When the locator points further back than the bytes
+// at hand, callers re-read from the recorded offset - but only within this
+// bound, so a hostile locator cannot demand an unbounded allocation.
+const ZIP64_EOCD_MAX_LENGTH = 56 + 1024 * 1024;
+
+const S_IFREG = 0o100000; // Unix mode type bits: regular file
+const S_IFDIR = 0o040000; // Unix mode type bits: directory
+const S_IFLNK = 0o120000; // Unix mode type bits: symbolic link
+const S_IFMT = 0o170000; // Unix mode type mask
+
+// Extra-field header IDs we consult on read (sec. 4.5).
+const EXTRA_ID_NTFS = 0x000a; // NTFS times (100 ns since 1601)
+const EXTRA_ID_EXT_TIMESTAMP = 0x5455; // Info-ZIP extended timestamp ("UT")
+const EXTRA_ID_UNIX_OLD = 0x5855; // Info-ZIP Unix, original ("UX")
+const EXTRA_ID_UNICODE_PATH = 0x7075; // Info-ZIP Unicode Path ("up")
+
+// Passed between `ZipEntry` and the archive writers/`ZipFile`: `kFinalize`
+// asks an entry for its central-directory header at a known local offset;
+// `kPromote` rebinds a just-serialized streaming entry to its on-disk copy.
+const kFinalize = Symbol('kFinalize');
+const kPromote = Symbol('kPromote');
+
+// The chunk size for reading a file-backed member's compressed bytes.
+const READ_CHUNK_SIZE = 4 * 1024 * 1024;
+// EOCD + max comment + Zip64 locator + Zip64 record + slack for an
+// extensible data sector: the fixed-size tail `ZipFile.open()` reads first.
+const TAIL_LENGTH = 22 + SENTINEL16 + 20 + 56 + 4096;
+
+module.exports = {
+ EMPTY_BUFFER,
+ BIGINT_MAX_SAFE_INTEGER,
+ SIG_LOCAL_FILE_HEADER,
+ SIG_DATA_DESCRIPTOR,
+ SIG_CENTRAL_FILE_HEADER,
+ SIG_ZIP64_EOCD_RECORD,
+ SIG_ZIP64_EOCD_LOCATOR,
+ SIG_EOCD,
+ MADE_BY_UNIX,
+ ZIP64_EXTRA_ID,
+ SENTINEL16,
+ SENTINEL32,
+ FLAG_ENCRYPTED,
+ FLAG_DATA_DESCRIPTOR,
+ FLAG_UTF8,
+ METHOD_STORE,
+ METHOD_DEFLATE,
+ METHOD_ZSTD,
+ VERSION_DEFAULT,
+ VERSION_ZIP64,
+ VERSION_ZSTD,
+ ZIP64_EOCD_MAX_LENGTH,
+ S_IFREG,
+ S_IFDIR,
+ S_IFLNK,
+ S_IFMT,
+ EXTRA_ID_NTFS,
+ EXTRA_ID_EXT_TIMESTAMP,
+ EXTRA_ID_UNIX_OLD,
+ EXTRA_ID_UNICODE_PATH,
+ kFinalize,
+ kPromote,
+ READ_CHUNK_SIZE,
+ TAIL_LENGTH,
+};
diff --git a/lib/internal/zip/content-size.js b/lib/internal/zip/content-size.js
new file mode 100644
index 00000000000000..0693e86f15685b
--- /dev/null
+++ b/lib/internal/zip/content-size.js
@@ -0,0 +1,41 @@
+'use strict';
+
+// The module-global default ceiling on in-memory member decompression, and
+// its public getter/setter. Kept in its own module so every read path sees
+// the one mutable value.
+
+const { validateInteger } = require('internal/validators');
+
+// A default ceiling on the uncompressed size that the buffering read paths
+// (`ZipEntry.prototype.content()`, and therefore `ZipBuffer`/`ZipFile`
+// `get()`) will materialize in memory when the caller does not pass an
+// explicit `maxSize`. An archive whose central directory declares a member
+// larger than this is rejected before any large allocation happens. Callers
+// that need larger members can either pass a per-call `maxSize` or raise the
+// module default with `setMaxZipContentSize()`. The streaming read paths
+// (`contentIterator()`, `ZipFile.prototype.stream()`) are bounded-memory by
+// design and are not subject to this default.
+const DEFAULT_MAX_ZIP_CONTENT_SIZE = 256 * 1024 * 1024; // 256 MiB
+let maxZipContentSize = DEFAULT_MAX_ZIP_CONTENT_SIZE;
+
+/**
+ * @returns {number}
+ */
+function getMaxZipContentSize() {
+ return maxZipContentSize;
+}
+
+/**
+ * @param {number} size
+ * @returns {void}
+ */
+function setMaxZipContentSize(size) {
+ validateInteger(size, 'size', 0);
+ maxZipContentSize = size;
+}
+
+module.exports = {
+ DEFAULT_MAX_ZIP_CONTENT_SIZE,
+ getMaxZipContentSize,
+ setMaxZipContentSize,
+};
diff --git a/lib/internal/zip/dos.js b/lib/internal/zip/dos.js
new file mode 100644
index 00000000000000..d3a41323b87eeb
--- /dev/null
+++ b/lib/internal/zip/dos.js
@@ -0,0 +1,138 @@
+'use strict';
+
+// DOS/IBM legacy decoding: MS-DOS date/time fields (sec. 4.4.6) and the
+// historical Code Page 437 name/comment encoding, plus the modern
+// UTF-8/Unicode-Path handling layered on top of them.
+
+const {
+ Date,
+ NumberIsNaN,
+ StringFromCharCode,
+} = primordials;
+
+const {
+ codes: {
+ ERR_INVALID_ARG_VALUE,
+ },
+} = require('internal/errors');
+const { crc32: crc32Native } = internalBinding('zlib');
+const { isUtf8 } = internalBinding('buffer');
+const {
+ FLAG_UTF8,
+ EXTRA_ID_UNICODE_PATH,
+} = require('internal/zip/constants');
+const { forEachExtraField } = require('internal/zip/extra-fields');
+
+// DOS date/time (sec. 4.4.6): local time by convention.
+// time: bits 0-4 seconds/2, 5-10 minutes, 11-15 hours
+// date: bits 0-4 day, 5-8 month, 9-15 years since 1980
+function decodeDosDateTime(time, date) {
+ // A zeroed/absent date field has month 0 and day 0, both invalid; the DOS
+ // epoch is 1980-01-01. Month/day 0 are treated as 1 so a zero field decodes
+ // to 1980-01-01 (and re-encodes to the same value).
+ return new Date(
+ ((date >>> 9) & 0x7f) + 1980,
+ ((date >>> 5) & 0x0f || 1) - 1,
+ (date & 0x1f) || 1,
+ (time >>> 11) & 0x1f,
+ (time >>> 5) & 0x3f,
+ (time & 0x1f) * 2,
+ );
+}
+
+// Encode a Date into packed MS-DOS time/date fields (sec. 4.4.6), clamping to
+// the representable range 1980-01-01 .. 2107-12-31.
+function encodeDosDateTime(value) {
+ const year = value.getFullYear();
+ if (NumberIsNaN(year)) {
+ throw new ERR_INVALID_ARG_VALUE('modified', value, 'must be a valid Date');
+ }
+ if (year < 1980) return { time: 0, date: (1 << 5) | 1 }; // Clamp to 1980-01-01 00:00:00
+ if (year > 2107) {
+ // Clamp to 2107-12-31 23:59:58
+ return {
+ time: (23 << 11) | (59 << 5) | 29,
+ date: (127 << 9) | (12 << 5) | 31,
+ };
+ }
+ const date =
+ ((year - 1980) << 9) | ((value.getMonth() + 1) << 5) | value.getDate();
+ const time =
+ (value.getHours() << 11) |
+ (value.getMinutes() << 5) |
+ (value.getSeconds() >>> 1);
+ return { time, date };
+}
+
+// Code Page 437 high half (0x80-0xFF) -> Unicode. Names without the UTF-8
+// language-encoding flag (bit 11) are historically CP437, which every real
+// tool (Info-ZIP, Windows Explorer) assumes; bytes 0x00-0x7F are ASCII.
+const CP437_HIGH = [
+ 0x00c7, 0x00fc, 0x00e9, 0x00e2, 0x00e4, 0x00e0, 0x00e5, 0x00e7,
+ 0x00ea, 0x00eb, 0x00e8, 0x00ef, 0x00ee, 0x00ec, 0x00c4, 0x00c5,
+ 0x00c9, 0x00e6, 0x00c6, 0x00f4, 0x00f6, 0x00f2, 0x00fb, 0x00f9,
+ 0x00ff, 0x00d6, 0x00dc, 0x00a2, 0x00a3, 0x00a5, 0x20a7, 0x0192,
+ 0x00e1, 0x00ed, 0x00f3, 0x00fa, 0x00f1, 0x00d1, 0x00aa, 0x00ba,
+ 0x00bf, 0x2310, 0x00ac, 0x00bd, 0x00bc, 0x00a1, 0x00ab, 0x00bb,
+ 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556,
+ 0x2555, 0x2563, 0x2551, 0x2557, 0x255d, 0x255c, 0x255b, 0x2510,
+ 0x2514, 0x2534, 0x252c, 0x251c, 0x2500, 0x253c, 0x255e, 0x255f,
+ 0x255a, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256c, 0x2567,
+ 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256b,
+ 0x256a, 0x2518, 0x250c, 0x2588, 0x2584, 0x258c, 0x2590, 0x2580,
+ 0x03b1, 0x00df, 0x0393, 0x03c0, 0x03a3, 0x03c3, 0x00b5, 0x03c4,
+ 0x03a6, 0x0398, 0x03a9, 0x03b4, 0x221e, 0x03c6, 0x03b5, 0x2229,
+ 0x2261, 0x00b1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00f7, 0x2248,
+ 0x00b0, 0x2219, 0x00b7, 0x221a, 0x207f, 0x00b2, 0x25a0, 0x00a0,
+];
+
+// Decode a CP437-encoded name/comment to a JS string via the table above.
+function decodeCp437(buffer) {
+ let out = '';
+ for (let i = 0; i < buffer.length; i++) {
+ const b = buffer[i];
+ out += StringFromCharCode(b < 0x80 ? b : CP437_HIGH[b - 0x80]);
+ }
+ return out;
+}
+
+// The UTF-8 name from an Info-ZIP Unicode Path extra field (sec. 4.6.9), but
+// only when its version is 1 and its CRC-32 matches the standard-field name
+// bytes (so a stale extra left over from a rename is ignored). Otherwise null.
+function unicodePathName(extra, standardNameBuffer) {
+ let result = null;
+ forEachExtraField(extra, (id, body) => {
+ if (result !== null || id !== EXTRA_ID_UNICODE_PATH || body.length < 5) return;
+ if (body[0] !== 1) return;
+ // The crc32 binding returns an unsigned uint32, directly comparable.
+ if (body.readUInt32LE(1) !== crc32Native(standardNameBuffer, 0)) return;
+ result = body.toString('utf8', 5);
+ });
+ return result;
+}
+
+// Decode an entry name: prefer a valid Unicode Path extra field, else the
+// UTF-8/CP437 heuristic below.
+function decodeZipName(nameBuffer, flags, extra) {
+ if (extra?.length) {
+ const unicode = unicodePathName(extra, nameBuffer);
+ if (unicode !== null) return unicode;
+ }
+ return decodeZipText(nameBuffer, flags);
+}
+
+// Decode a name/comment: UTF-8 when bit 11 says so, or when the bytes are
+// valid UTF-8 anyway - plenty of real tools (pre-JDK7 java.util.zip among
+// them) wrote UTF-8 names without ever setting the flag. Only genuinely
+// non-UTF-8 bytes take the historical CP437 default.
+function decodeZipText(buffer, flags) {
+ if ((flags & FLAG_UTF8) || isUtf8(buffer)) return buffer.toString('utf8');
+ return decodeCp437(buffer);
+}
+
+module.exports = {
+ decodeDosDateTime,
+ encodeDosDateTime,
+ decodeZipName,
+ decodeZipText,
+};
diff --git a/lib/internal/zip/entry.js b/lib/internal/zip/entry.js
new file mode 100644
index 00000000000000..acdbfbacebf87f
--- /dev/null
+++ b/lib/internal/zip/entry.js
@@ -0,0 +1,901 @@
+'use strict';
+
+// `ZipEntry`: a single archive member. Reads (buffered and streaming),
+// (de)serialization, and the `create()`/`createSync()`/`createStream()`/
+// `createSymlink()` builders, plus the `createEntryMeta()` helper that
+// normalizes their options into the internal metadata record.
+
+const {
+ ArrayPrototypePush,
+ ArrayPrototypeSlice,
+ ArrayPrototypeSort,
+ Date,
+ DateNow,
+ JSONStringify,
+ MathFloor,
+ MathMin,
+ NumberMAX_SAFE_INTEGER,
+ StringPrototypeEndsWith,
+ SymbolAsyncIterator,
+ SymbolIterator,
+} = primordials;
+
+const {
+ codes: {
+ ERR_INVALID_ARG_TYPE,
+ ERR_INVALID_ARG_VALUE,
+ ERR_INVALID_STATE,
+ ERR_ZIP_ENTRY_TOO_LARGE,
+ ERR_ZIP_INVALID_ARCHIVE,
+ ERR_ZIP_UNSUPPORTED_FEATURE,
+ },
+} = require('internal/errors');
+const {
+ validateInteger,
+ validateString,
+ validateUint32,
+} = require('internal/validators');
+const {
+ isDate,
+ isUint8Array,
+} = require('internal/util/types');
+const { Buffer, kMaxLength } = require('buffer');
+const { crc32: crc32Native } = internalBinding('zlib');
+const {
+ EMPTY_BUFFER,
+ SIG_LOCAL_FILE_HEADER,
+ SENTINEL16,
+ FLAG_DATA_DESCRIPTOR,
+ FLAG_UTF8,
+ MADE_BY_UNIX,
+ METHOD_STORE,
+ METHOD_DEFLATE,
+ METHOD_ZSTD,
+ S_IFREG,
+ S_IFDIR,
+ S_IFLNK,
+ S_IFMT,
+ READ_CHUNK_SIZE,
+ kFinalize,
+ kPromote,
+} = require('internal/zip/constants');
+const {
+ toBuffer,
+ validateArchiveRange,
+} = require('internal/zip/binary');
+const {
+ extraFieldMtime,
+ stripZip64Extra,
+} = require('internal/zip/extra-fields');
+const {
+ decodeZipName,
+ decodeZipText,
+} = require('internal/zip/dos');
+const {
+ CentralFileHeader,
+ LocalFileHeader,
+ findArchiveEnd,
+} = require('internal/zip/headers');
+const {
+ buildLocalHeader,
+ buildCentralHeader,
+ buildDataDescriptor64,
+} = require('internal/zip/header-builders');
+const {
+ deflateRawAsync,
+ deflateRawSync,
+ zstdCompressAsync,
+ zstdCompressSync,
+ deflateRawStream,
+ zstdCompressStream,
+ decodeMemberStream,
+ decodeMemberAsync,
+ decodeMemberSync,
+} = require('internal/zip/compression');
+const {
+ readFdFully,
+ readFdFullySync,
+} = require('internal/zip/fs-util');
+const { getMaxZipContentSize } = require('internal/zip/content-size');
+
+// The whole (UTC) second to record in an extended-timestamp extra field when
+// `mtimeMs` cannot be represented exactly by the 2-second-resolution,
+// local-time DOS date/time fields (sub-second parts and odd seconds alike),
+// or null when the DOS fields suffice or the value does not fit the extra
+// field's signed 32-bit Unix-seconds range (through 2038). One rule, shared
+// by createEntryMeta() and #finalizeMeta() so the two cannot drift.
+function extendedMtimeSeconds(mtimeMs) {
+ const seconds = MathFloor(mtimeMs / 1000);
+ return (mtimeMs % 2000 !== 0 && seconds >= -2147483648 && seconds <= 2147483647) ?
+ seconds : null;
+}
+
+// Normalize the public builder options into the internal metadata record:
+// name/comment bytes, the UTF-8 flag, the Unix mode packed into the external
+// attributes (sec. 4.4.15), and the DOS/extended-timestamp fields.
+function createEntryMeta(filename, options) {
+ validateString(filename, 'filename');
+ const name = Buffer.from(filename, 'utf8');
+ if (name.length === 0) {
+ throw new ERR_INVALID_ARG_VALUE('filename', filename, 'must not be empty');
+ }
+ if (name.length > SENTINEL16) {
+ throw new ERR_ZIP_ENTRY_TOO_LARGE(
+ 'the entry name must not exceed 65535 bytes when encoded as UTF-8');
+ }
+ let comment = EMPTY_BUFFER;
+ if (options?.comment !== undefined) {
+ validateString(options.comment, 'options.comment');
+ comment = Buffer.from(options.comment, 'utf8');
+ if (comment.length > SENTINEL16) {
+ throw new ERR_ZIP_ENTRY_TOO_LARGE(
+ 'the entry comment must not exceed 65535 bytes when encoded as UTF-8');
+ }
+ }
+ const isSymlink = options?.symlink === true;
+ const isDirectory = !isSymlink && StringPrototypeEndsWith(filename, '/');
+ const mode = options?.mode ?? (isSymlink ? 0o777 : isDirectory ? 0o755 : 0o644);
+ validateUint32(mode, 'options.mode');
+ // Default to the current time at the DOS fields' 2-second resolution, so a
+ // default entry needs no extended-timestamp extra field (see below).
+ const modified = options?.modified ?? new Date(MathFloor(DateNow() / 2000) * 2000);
+ if (!isDate(modified)) {
+ throw new ERR_INVALID_ARG_TYPE('options.modified', 'Date', modified);
+ }
+ if (options?.method !== undefined &&
+ options.method !== 'deflate' && options.method !== 'store' && options.method !== 'zstd') {
+ throw new ERR_INVALID_ARG_VALUE(
+ 'options.method', options.method, "must be 'deflate', 'store', or 'zstd'");
+ }
+ const typeBits = isSymlink ? S_IFLNK : isDirectory ? S_IFDIR : S_IFREG;
+ const unixAttrs = (typeBits | (mode & 0o7777)) & SENTINEL16;
+ const external = ((unixAttrs << 16) | (isDirectory ? 0x10 : 0)) >>> 0;
+ // Record the whole (UTC) second in an extended-timestamp extra field when
+ // the DOS fields cannot represent the time exactly; see
+ // extendedMtimeSeconds().
+ const extendedMtime = extendedMtimeSeconds(modified.getTime());
+ return {
+ name,
+ comment,
+ extra: EMPTY_BUFFER,
+ flags: FLAG_UTF8,
+ method: 0,
+ crc: 0,
+ compressedSize: 0,
+ uncompressedSize: 0,
+ modified,
+ extendedMtime,
+ external,
+ internal: 0,
+ madeBy: MADE_BY_UNIX,
+ pending: true,
+ };
+}
+
+/**
+ * A single file or directory inside a ZIP archive: reading, writing, and
+ * (de)serializing one archive member.
+ */
+class ZipEntry {
+ #central;
+ #local;
+ #content;
+ #source = null;
+ #meta = null;
+ #serialized = false;
+ // When #fd is non-null the entry is "file-backed": it holds no content
+ // buffer, only a descriptor and the local-header offset, and reads its
+ // compressed bytes from disk on demand (see #compressedBytes/#rawChunks).
+ // #contentOffset caches the resolved start of the compressed data (a
+ // number, not a buffer) once the local header has been read.
+ #fd = null;
+ #localOffset = -1;
+ #contentOffset = -1;
+
+ /**
+ * @private
+ */
+ constructor(central, local, content, fd = null, localOffset = -1) {
+ this.#central = central;
+ this.#local = local;
+ this.#content = content;
+ this.#fd = fd;
+ this.#localOffset = localOffset;
+ }
+
+ // Whether the content is stored in compressed form, whatever the method.
+ get compressed() { return this.method !== METHOD_STORE; }
+ get rawContent() { return this.#content; }
+ get method() {
+ return this.#meta ? this.#meta.method : this.#central.compressionMethod;
+ }
+ get flags() {
+ return this.#meta ? this.#meta.flags : (this.#local ?? this.#central).flags;
+ }
+ get crc32() {
+ if (this.#meta) {
+ this.#assertNotPending();
+ return this.#meta.crc;
+ }
+ return this.#central.crc32;
+ }
+ get name() {
+ // The central directory is authoritative; a mismatched local-header name
+ // is deliberately ignored (defends against parser-confusion attacks).
+ // Both branches use the same decoding (Unicode Path extra, then
+ // UTF-8/CP437), so serialization - which snapshots the raw bytes into
+ // #meta - never changes what this getter reports.
+ return this.#meta ?
+ decodeZipName(this.#meta.name, this.#meta.flags, this.#meta.extra) :
+ this.#central.fileName;
+ }
+ get nameBuffer() {
+ return this.#meta ? this.#meta.name : this.#central.fileNameBuffer;
+ }
+ get comment() {
+ return this.#meta ?
+ decodeZipText(this.#meta.comment, this.#meta.flags) :
+ this.#central.fileComment;
+ }
+ get size() {
+ if (this.#meta) {
+ this.#assertNotPending();
+ return this.#meta.uncompressedSize;
+ }
+ return this.#central.uncompressedSize;
+ }
+ get compressedSize() {
+ if (this.#meta) {
+ this.#assertNotPending();
+ return this.#meta.compressedSize;
+ }
+ return this.#central.compressedSize;
+ }
+ get modified() {
+ if (this.#meta) return this.#meta.modified;
+ // Prefer an extra-field timestamp (absolute, higher-resolution) over the
+ // coarse local-time DOS date/time fields when a foreign archive carries
+ // one; consult both the central and local headers. A file-backed entry
+ // starts out with only its central header, but some tools (7-Zip, for
+ // one) write their high-fidelity timestamp only into the local header -
+ // resolve it lazily (a small, one-time positioned read) rather than
+ // silently reporting a coarser time than the archive carries, and fall
+ // back to the central data when the local header cannot be read.
+ if (this.#fd !== null && this.#local === null) {
+ try {
+ this.#resolveLocalHeaderSync();
+ } catch {
+ // A malformed local header fails loudly on the content read paths;
+ // for metadata, the central directory alone has to do.
+ }
+ }
+ return extraFieldMtime(this.#central.extraField, this.#local?.extraField) ??
+ this.#central.lastModified;
+ }
+ get mode() {
+ // The external attributes' high 16 bits hold Unix permissions only when
+ // the entry was made by a Unix host (sec. 4.4.2/4.4.15) - the same rule
+ // as `CentralFileHeader.prototype.mode`, which the non-meta branch
+ // defers to.
+ if (this.#meta) {
+ return this.#meta.madeBy === MADE_BY_UNIX ?
+ (this.#meta.external >>> 16) & 0o7777 : 0;
+ }
+ return this.#central.mode;
+ }
+ get isSymlink() {
+ // Derived from the made-by host and the external attributes' Unix type
+ // bits in both branches, so it survives serialization (which preserves
+ // both verbatim); this also makes a fresh `createSymlink()` entry report
+ // itself as one.
+ if (this.#meta) {
+ return this.#meta.madeBy === MADE_BY_UNIX &&
+ ((this.#meta.external >>> 16) & S_IFMT) === S_IFLNK;
+ }
+ return this.#central.isSymlink;
+ }
+ get isFile() { return !this.isDirectory && !this.isSymlink; }
+ get isDirectory() { return StringPrototypeEndsWith(this.name, '/'); }
+
+ // Guard: reject metadata/content reads on a write-streaming entry whose
+ // sizes and CRC are not yet known (still pending serialization).
+ #assertNotPending() {
+ if (this.#meta?.pending) {
+ throw new ERR_INVALID_STATE(
+ 'this streaming entry has not finished serializing yet');
+ }
+ }
+
+ // Snapshot this (parsed) entry's central-directory data into a
+ // re-serializable #meta record, memoized. Needed so a round-tripped entry
+ // can be re-emitted from stable, extra-field-aware values rather than raw
+ // header bytes; clears the data-descriptor flag (see below).
+ #finalizeMeta() {
+ if (this.#meta) {
+ this.#assertNotPending();
+ return this.#meta;
+ }
+ const central = this.#central;
+ // Descriptor entries (bit 3) are re-emitted with known sizes/CRC and bit
+ // 3 cleared: full re-serialization emits a fresh local header from this
+ // same record, so it never reproduces a bit-3 local header without a
+ // data descriptor. (This invariant does NOT hold for `ZipFile`'s
+ // in-place central-directory rewrite, which leaves local headers on disk
+ // untouched - that path re-asserts bit 3 via `[kFinalize]`; see there.)
+ // Sizes come from the central directory
+ // (Zip64-aware); Zip64 extras are regenerated as needed, and all other
+ // extra-field records (Unicode Path, NTFS/UT timestamps, ...) are
+ // preserved so a re-serialized entry keeps its name encoding and
+ // timestamps.
+ const extra = stripZip64Extra(central.extraField);
+ // Snapshot the resolved (extra-field-aware) time, not the raw DOS field,
+ // so serialization does not degrade what `modified` reports. When that
+ // time cannot be represented exactly in the DOS fields and no preserved
+ // extra record carries it, record it in an extended-timestamp extra
+ // (see extendedMtimeSeconds(); same rule as createEntryMeta()).
+ const modified = this.modified;
+ const extendedMtime = extraFieldMtime(extra) === null ?
+ extendedMtimeSeconds(modified.getTime()) : null;
+ const meta = {
+ name: central.fileNameBuffer,
+ comment: central.fileCommentBuffer,
+ extra,
+ flags: central.flags & ~FLAG_DATA_DESCRIPTOR,
+ method: central.compressionMethod,
+ crc: central.crc32,
+ compressedSize: central.compressedSize,
+ uncompressedSize: central.uncompressedSize,
+ modified,
+ extendedMtime,
+ external: central.externalFileAttributes,
+ internal: central.internalFileAttributes,
+ // Preserve the creator's host byte (sec. 4.4.2): the external
+ // attributes are only interpretable relative to it.
+ madeBy: central.version >>> 8,
+ pending: false,
+ };
+ this.#meta = meta;
+ return meta;
+ }
+
+ // Read (and cache) this file-backed entry's local file header - whose
+ // length (fixed 30 bytes plus variable name/extra fields) is only known
+ // from the file itself, not from the central directory. Resolving it also
+ // yields the offset where the compressed data begins, and gives the
+ // `modified` getter access to a local-header-only timestamp extra.
+ async #resolveLocalHeader() {
+ if (this.#local !== null) return this.#local;
+ const fixed = Buffer.allocUnsafe(30);
+ await readFdFully(this.#fd, fixed, this.#localOffset);
+ if (fixed.readUInt32LE(0) !== SIG_LOCAL_FILE_HEADER) {
+ throw new ERR_ZIP_INVALID_ARCHIVE(
+ `entry ${JSONStringify(this.name)} has an invalid local file header`);
+ }
+ const length = LocalFileHeader.length(fixed, 0);
+ const full = Buffer.allocUnsafe(length);
+ fixed.copy(full, 0);
+ if (length > 30) {
+ await readFdFully(this.#fd, full.subarray(30), this.#localOffset + 30);
+ }
+ this.#local = new LocalFileHeader(full, 0);
+ this.#contentOffset = this.#localOffset + length;
+ return this.#local;
+ }
+ // Sync counterpart of #resolveLocalHeader().
+ #resolveLocalHeaderSync() {
+ if (this.#local !== null) return this.#local;
+ const fixed = Buffer.allocUnsafe(30);
+ readFdFullySync(this.#fd, fixed, this.#localOffset);
+ if (fixed.readUInt32LE(0) !== SIG_LOCAL_FILE_HEADER) {
+ throw new ERR_ZIP_INVALID_ARCHIVE(
+ `entry ${JSONStringify(this.name)} has an invalid local file header`);
+ }
+ const length = LocalFileHeader.length(fixed, 0);
+ const full = Buffer.allocUnsafe(length);
+ fixed.copy(full, 0);
+ if (length > 30) {
+ readFdFullySync(this.#fd, full.subarray(30), this.#localOffset + 30);
+ }
+ this.#local = new LocalFileHeader(full, 0);
+ this.#contentOffset = this.#localOffset + length;
+ return this.#local;
+ }
+ // The offset where this file-backed entry's compressed data begins, reading
+ // the local header first if that has not been resolved yet.
+ async #resolveContentOffset() {
+ if (this.#contentOffset < 0) await this.#resolveLocalHeader();
+ return this.#contentOffset;
+ }
+ // Sync counterpart of #resolveContentOffset().
+ #resolveContentOffsetSync() {
+ if (this.#contentOffset < 0) this.#resolveLocalHeaderSync();
+ return this.#contentOffset;
+ }
+ // The in-memory raw bytes, or a clean state error when there are none - a
+ // write-streaming entry (`createStream()`) has no readable content until it
+ // has been serialized into a backing archive (after which `addEntry()`
+ // promotes it to file-backed; see [kPromote]).
+ #inMemoryCompressed() {
+ if (this.#content === null) {
+ throw new ERR_INVALID_STATE(
+ 'the content of a streaming entry is not available for reading');
+ }
+ return this.#content;
+ }
+ // The entry's raw (still-compressed) bytes. For an in-memory entry this is
+ // the entry's retained buffer - shared memory, NOT a copy: it may alias
+ // the source archive (`ZipEntry.read()`) or the caller's original `data`
+ // (`create()` when storing). For a file-backed entry it is freshly read
+ // from disk and caller-owned. Paths that hand these bytes out undecoded
+ // (the store method) must copy the in-memory case; see `content()`.
+ async #compressedBytes() {
+ if (this.#fd === null) return this.#inMemoryCompressed();
+ const size = this.compressedSize;
+ // A member at or beyond the maximum Buffer length cannot be materialized
+ // in one allocation; stream it instead. (kMaxLength equals the safe
+ // integer ceiling on 64-bit, so a larger size fails to parse anyway.)
+ if (size >= kMaxLength) {
+ throw new ERR_ZIP_ENTRY_TOO_LARGE(
+ `entry ${JSONStringify(this.name)} is too large to buffer ` +
+ `(${size} compressed bytes); use contentIterator() instead`);
+ }
+ const start = await this.#resolveContentOffset();
+ const compressed = Buffer.allocUnsafe(size);
+ await readFdFully(this.#fd, compressed, start);
+ return compressed;
+ }
+ // Sync counterpart of #compressedBytes().
+ #compressedBytesSync() {
+ if (this.#fd === null) return this.#inMemoryCompressed();
+ const size = this.compressedSize;
+ if (size >= kMaxLength) {
+ throw new ERR_ZIP_ENTRY_TOO_LARGE(
+ `entry ${JSONStringify(this.name)} is too large to buffer ` +
+ `(${size} compressed bytes); use contentIterator() instead`);
+ }
+ const start = this.#resolveContentOffsetSync();
+ const compressed = Buffer.allocUnsafe(size);
+ readFdFullySync(this.#fd, compressed, start);
+ return compressed;
+ }
+ // The entry's raw compressed bytes as a bounded-memory chunk stream, read
+ // straight from disk (file-backed entries only). Nothing is retained.
+ async *#rawChunks() {
+ const fd = this.#fd;
+ let pos = await this.#resolveContentOffset();
+ let remaining = this.compressedSize;
+ while (remaining > 0) {
+ const take = MathMin(READ_CHUNK_SIZE, remaining);
+ const chunk = Buffer.allocUnsafe(take);
+ await readFdFully(fd, chunk, pos);
+ pos += take;
+ remaining -= take;
+ yield chunk;
+ }
+ }
+ // Sync counterpart of #rawChunks().
+ *#rawChunksSync() {
+ const fd = this.#fd;
+ let pos = this.#resolveContentOffsetSync();
+ let remaining = this.compressedSize;
+ while (remaining > 0) {
+ const take = MathMin(READ_CHUNK_SIZE, remaining);
+ const chunk = Buffer.allocUnsafe(take);
+ readFdFullySync(fd, chunk, pos);
+ pos += take;
+ remaining -= take;
+ yield chunk;
+ }
+ }
+
+ /**
+ * Reads, decompresses, and (by default) CRC-32-verifies the whole entry
+ * into a single `Buffer`, enforcing the declared-size and `maxSize` limits.
+ * @param {{ verify?: boolean, maxSize?: number }} [options]
+ * @returns {Promise}
+ */
+ async content(options) {
+ const declared = this.size;
+ const maxSize = options?.maxSize ?? getMaxZipContentSize();
+ if (declared > maxSize) {
+ throw new ERR_ZIP_ENTRY_TOO_LARGE(
+ `entry ${JSONStringify(this.name)} declares ${declared} bytes, ` +
+ `exceeding the ${maxSize} byte limit`);
+ }
+ const compressed = await this.#compressedBytes();
+ const data = await decodeMemberAsync(compressed, {
+ name: this.name,
+ flags: this.flags,
+ method: this.method,
+ crc32: this.crc32,
+ uncompressedSize: declared,
+ }, { verify: options?.verify, maxSize });
+ // `data === compressed` only on the store path; copy the in-memory case
+ // (the entry's retained buffer, see #compressedBytes()) so the result is
+ // caller-owned on every path.
+ return data === compressed && this.#fd === null ? Buffer.from(data) : data;
+ }
+
+ /**
+ * The synchronous counterpart of `content()`. Blocks the event loop and
+ * further JavaScript execution until the whole entry has been read and, if
+ * applicable, inflated - use only where synchronous I/O is appropriate
+ * (for example, short-lived scripts or startup code), not in code that
+ * must stay responsive.
+ * @param {{ verify?: boolean, maxSize?: number }} [options]
+ * @returns {Buffer}
+ */
+ contentSync(options) {
+ const declared = this.size;
+ const maxSize = options?.maxSize ?? getMaxZipContentSize();
+ if (declared > maxSize) {
+ throw new ERR_ZIP_ENTRY_TOO_LARGE(
+ `entry ${JSONStringify(this.name)} declares ${declared} bytes, ` +
+ `exceeding the ${maxSize} byte limit`);
+ }
+ const compressed = this.#compressedBytesSync();
+ const data = decodeMemberSync(compressed, {
+ name: this.name,
+ flags: this.flags,
+ method: this.method,
+ crc32: this.crc32,
+ uncompressedSize: declared,
+ }, { verify: options?.verify, maxSize });
+ // `data === compressed` only on the store path; copy the in-memory case
+ // so the result is caller-owned on every path (see content()).
+ return data === compressed && this.#fd === null ? Buffer.from(data) : data;
+ }
+
+ // The raw (still-compressed) bytes as an async iterable, without buffering
+ // the whole member: straight from disk for a file-backed entry, or the
+ // single in-memory buffer otherwise. Throws synchronously for a pending
+ // write-streaming entry, whose content is not yet available for reading.
+ #rawSource() {
+ if (this.#fd !== null) return this.#rawChunks();
+ const content = this.#inMemoryCompressed();
+ return (async function* () {
+ if (content.length) yield content;
+ })();
+ }
+
+ /**
+ * Yields the entry's decompressed content as a bounded-memory async
+ * iterator of `Buffer` chunks, decompressing on the way and (by default)
+ * verifying CRC-32. For a file-backed entry (from `ZipFile.get()`) the
+ * compressed bytes are read from disk as they are consumed and nothing is
+ * retained.
+ * @param {{ verify?: boolean, maxSize?: number }} [options]
+ * @returns {AsyncGenerator}
+ */
+ contentIterator(options) {
+ return decodeMemberStream(this.#rawSource(), {
+ name: this.name,
+ flags: this.flags,
+ method: this.method,
+ crc32: this.crc32,
+ uncompressedSize: this.size,
+ }, options);
+ }
+
+ // Synchronous serialization: emit the local file header (sec. 4.3.7)
+ // followed by the raw (already-compressed) content bytes. Streaming
+ // (source-backed) entries cannot be serialized this way.
+ *[SymbolIterator]() {
+ if (this.#source) {
+ throw new ERR_INVALID_STATE('a streaming entry cannot be serialized synchronously');
+ }
+ const meta = this.#finalizeMeta();
+ yield buildLocalHeader(meta);
+ if (this.#fd !== null) {
+ yield* this.#rawChunksSync();
+ } else if (this.#content?.length) {
+ yield this.#content;
+ }
+ }
+
+ // Asynchronous serialization. For a write-streaming entry, drain the
+ // source once, computing CRC-32 and sizes on the fly and emitting a Zip64
+ // data descriptor after the content (sec. 4.3.9); otherwise defer to the
+ // buffered/file-backed paths.
+ async *[SymbolAsyncIterator]() {
+ const source = this.#source;
+ if (!source) {
+ if (this.#fd !== null) {
+ yield buildLocalHeader(this.#finalizeMeta());
+ yield* this.#rawChunks();
+ return;
+ }
+ yield* this[SymbolIterator]();
+ return;
+ }
+ if (this.#serialized) {
+ throw new ERR_INVALID_STATE('a streaming entry can only be serialized once');
+ }
+ this.#serialized = true;
+ const meta = this.#meta;
+ yield buildLocalHeader(meta);
+ let state = 0;
+ let uncompressedSize = 0;
+ let compressedSize = 0;
+ const counted = (async function* () {
+ for await (const chunk of source) {
+ if (!isUint8Array(chunk)) {
+ throw new ERR_INVALID_ARG_TYPE('chunk', 'Uint8Array', chunk);
+ }
+ if (!chunk.length) continue;
+ state = crc32Native(chunk, state);
+ uncompressedSize += chunk.length;
+ yield chunk;
+ }
+ })();
+ const output = meta.method === METHOD_DEFLATE ? deflateRawStream(counted) :
+ meta.method === METHOD_ZSTD ? zstdCompressStream(counted) : counted;
+ for await (const chunk of output) {
+ compressedSize += chunk.length;
+ yield chunk;
+ }
+ meta.crc = state;
+ meta.uncompressedSize = uncompressedSize;
+ meta.compressedSize = compressedSize;
+ meta.pending = false;
+ yield buildDataDescriptor64(meta.crc, compressedSize, uncompressedSize);
+ }
+
+ /**
+ * Builds this entry's central-directory header (sec. 4.3.12) recording its
+ * local-header start; called by the archive writer once the offset is fixed.
+ *
+ * `preserveDescriptorFlag` is set by `ZipFile`'s in-place
+ * central-directory rewrite, which never regenerates local headers: when
+ * the on-disk local header advertises a data descriptor (bit 3, which
+ * `#finalizeMeta()` clears for full re-serialization), the rebuilt central
+ * header must keep advertising it too, or the two headers would contradict
+ * each other (sec. 4.3.12 expects them to agree). Full re-serialization
+ * emits a fresh, bit-3-free local header from the same meta, so there the
+ * cleared flag is the consistent one.
+ * @private
+ * @param {number} localOffset
+ * @param {boolean} [preserveDescriptorFlag]
+ * @returns {Buffer}
+ */
+ [kFinalize](localOffset, preserveDescriptorFlag = false) {
+ validateInteger(localOffset, 'localOffset', 0, NumberMAX_SAFE_INTEGER);
+ const meta = this.#finalizeMeta();
+ if (preserveDescriptorFlag && this.#central !== null &&
+ (this.#central.flags & FLAG_DATA_DESCRIPTOR) !== 0) {
+ return buildCentralHeader(
+ { ...meta, flags: meta.flags | FLAG_DATA_DESCRIPTOR }, localOffset);
+ }
+ return buildCentralHeader(meta, localOffset);
+ }
+
+ // Rebind a just-serialized write-streaming entry to its on-disk copy so it
+ // stops being dead weight: after `addEntry()`/`addEntrySync()` writes the
+ // entry into `fd` at `localOffset` (its local-header start), the spent
+ // source is dropped and the entry becomes a readable, re-serializable
+ // file-backed entry (valid while `fd` stays open). Only a spent stream
+ // entry - one with neither an in-memory buffer nor an existing backing fd -
+ // is promoted; in-memory and already-file-backed entries are left as they
+ // are. The kept `#meta` still supplies the (now-final) name/sizes/crc.
+ [kPromote](fd, localOffset) {
+ if (this.#content !== null || this.#fd !== null) return;
+ this.#fd = fd;
+ this.#localOffset = localOffset;
+ this.#contentOffset = -1;
+ this.#source = null;
+ this.#serialized = false;
+ // The descriptor flag has served its purpose: the sizes and CRC are
+ // known now, and the fd-backed serialization path emits plain headers
+ // and never writes a descriptor. Left set, a re-serialization would
+ // advertise (bit 3) a data descriptor that never follows - a corrupt
+ // archive for any reader that honors the flag.
+ this.#meta.flags &= ~FLAG_DATA_DESCRIPTOR;
+ }
+
+ /**
+ * Parses an in-memory archive, walking the central directory (sec. 4.3.12)
+ * and each referenced local header (sec. 4.3.7), and yields one read-only
+ * `ZipEntry` per member.
+ * @param {Buffer | TypedArray | DataView | ArrayBuffer} buffer
+ * @yields {ZipEntry}
+ */
+ static *read(buffer) {
+ const buf = toBuffer(buffer, 'buffer');
+ yield* readArchiveEntries(buf, findArchiveEnd(buf));
+ }
+
+ /**
+ * Builds a ready-to-serialize entry from in-memory `data`, compressing it
+ * with the chosen method (falling back to store when that does not shrink
+ * it) and recording the CRC-32 and sizes. When the entry ends up stored
+ * (explicitly or via the fallback) it retains `data`'s memory rather than
+ * copying it, and the CRC-32 is recorded now - mutating `data` afterwards
+ * would corrupt the entry on write.
+ * @param {string} filename
+ * @param {Buffer | TypedArray | DataView | ArrayBuffer} data
+ * @param {{
+ * comment?: string,
+ * mode?: number,
+ * modified?: Date,
+ * method?: 'deflate' | 'store' | 'zstd',
+ * }} [options]
+ * @returns {Promise}
+ */
+ // Shared head of create()/createSync(): validate, snapshot the CRC-32 and
+ // uncompressed size, and pick the compression method (store is forced for
+ // directories and empty content). The compression pass itself is the only
+ // thing the async/sync builders do differently.
+ static #prepareCreate(filename, data, options) {
+ const meta = createEntryMeta(filename, options);
+ const content = toBuffer(data, 'data');
+ const isDirectory = StringPrototypeEndsWith(filename, '/');
+ if (isDirectory && content.length) {
+ throw new ERR_INVALID_ARG_VALUE('data', data, 'must be empty for a directory entry');
+ }
+ meta.crc = crc32Native(content, 0);
+ meta.uncompressedSize = content.length;
+ const method =
+ isDirectory || content.length === 0 || options?.method === 'store' ? METHOD_STORE :
+ options?.method === 'zstd' ? METHOD_ZSTD : METHOD_DEFLATE;
+ return { meta, content, method };
+ }
+
+ // Shared tail of create()/createSync(): keep the compressed bytes only
+ // when the pass actually shrank the content (otherwise store the original,
+ // which also retains the caller's memory - see create()'s JSDoc), fill in
+ // the final sizes, and construct the entry. `compressed` is null when
+ // `method` is store.
+ static #finishCreate(meta, content, method, compressed) {
+ let finalContent = content;
+ if (compressed !== null && compressed.length < content.length) {
+ finalContent = compressed;
+ } else if (compressed !== null) {
+ method = METHOD_STORE; // Compression did not help; fall back to storing
+ }
+ meta.method = method;
+ meta.compressedSize = finalContent.length;
+ meta.pending = false;
+ const entry = new ZipEntry(null, null, finalContent);
+ entry.#meta = meta;
+ return entry;
+ }
+
+ static async create(filename, data, options) {
+ const { meta, content, method } = ZipEntry.#prepareCreate(filename, data, options);
+ const compressed =
+ method === METHOD_DEFLATE ? await deflateRawAsync(content) :
+ method === METHOD_ZSTD ? await zstdCompressAsync(content) : null;
+ return ZipEntry.#finishCreate(meta, content, method, compressed);
+ }
+
+ /**
+ * The synchronous counterpart of `create()`. Blocks the event loop and
+ * further JavaScript execution until done (including the deflate pass);
+ * see `contentSync()`. Retains `data`'s memory when storing, same as
+ * `create()`.
+ * @param {string} filename
+ * @param {Buffer | TypedArray | DataView | ArrayBuffer} data
+ * @param {{
+ * comment?: string,
+ * mode?: number,
+ * modified?: Date,
+ * method?: 'deflate' | 'store' | 'zstd',
+ * }} [options]
+ * @returns {ZipEntry}
+ */
+ static createSync(filename, data, options) {
+ const { meta, content, method } = ZipEntry.#prepareCreate(filename, data, options);
+ const compressed =
+ method === METHOD_DEFLATE ? deflateRawSync(content) :
+ method === METHOD_ZSTD ? zstdCompressSync(content) : null;
+ return ZipEntry.#finishCreate(meta, content, method, compressed);
+ }
+
+ /**
+ * Builds a write-streaming entry whose content is drained from `source`
+ * only at serialization time; its sizes/CRC are unknown until then, so it
+ * sets the data-descriptor flag (bit 3, sec. 4.3.9) and stays pending.
+ * @param {string} filename
+ * @param {AsyncIterable} source
+ * @param {{ comment?: string, mode?: number, modified?: Date, method?: 'deflate' | 'store' | 'zstd' }} [options]
+ * @returns {ZipEntry}
+ */
+ static createStream(filename, source, options) {
+ const meta = createEntryMeta(filename, options);
+ if (StringPrototypeEndsWith(filename, '/')) {
+ throw new ERR_INVALID_ARG_VALUE('filename', filename, 'a directory entry cannot be streamed');
+ }
+ meta.flags |= FLAG_DATA_DESCRIPTOR;
+ meta.method = options?.method === 'store' ? METHOD_STORE :
+ options?.method === 'zstd' ? METHOD_ZSTD : METHOD_DEFLATE;
+ meta.pending = true;
+ const entry = new ZipEntry(null, null, null);
+ entry.#meta = meta;
+ entry.#source = source;
+ return entry;
+ }
+
+ /**
+ * Creates a symbolic-link entry: a stored entry whose content is the link
+ * target and whose Unix mode type bits are `S_IFLNK`.
+ * @param {string} filename
+ * @param {string} target The link target path.
+ * @param {{ comment?: string, mode?: number, modified?: Date }} [options]
+ * @returns {ZipEntry}
+ */
+ static createSymlink(filename, target, options) {
+ validateString(target, 'target');
+ const meta = createEntryMeta(filename, {
+ __proto__: null,
+ comment: options?.comment,
+ mode: options?.mode,
+ modified: options?.modified,
+ symlink: true,
+ });
+ const content = Buffer.from(target, 'utf8');
+ meta.crc = crc32Native(content, 0);
+ meta.uncompressedSize = content.length;
+ meta.method = METHOD_STORE;
+ meta.compressedSize = content.length;
+ meta.pending = false;
+ const entry = new ZipEntry(null, null, content);
+ entry.#meta = meta;
+ return entry;
+ }
+}
+
+// Walk the central directory described by `end` (a findArchiveEnd() result
+// for `buf`), yielding one read-only ZipEntry per member. Split from
+// `ZipEntry.read()` so `ZipBuffer` - which also needs the archive-end record
+// for its comment - can locate the archive end once and share the result
+// instead of scanning for it twice. All records are parsed and their member
+// ranges cross-checked before the first entry is yielded: members must be
+// disjoint and precede the central directory, or one small file could quote
+// the same data region from N records - the "quoted overlap" zip-bomb shape
+// (CVE-2024-0450 in other implementations). Here the local headers have
+// been read, so the check uses each member's exact end (`ZipFile` applies
+// the same rule with a lower bound; see `validateMemberBounds()`).
+function* readArchiveEntries(buf, end) {
+ let pos = end.centralDirectoryOffset;
+ const cdEnd = end.centralDirectoryOffset + end.centralDirectorySize;
+ const parsed = [];
+ for (let index = 0; index < end.totalRecords; index++) {
+ const central = new CentralFileHeader(buf, pos);
+ if (pos + central.byteLength > cdEnd) {
+ throw new ERR_ZIP_INVALID_ARCHIVE('central directory header is out of bounds');
+ }
+ if (central.diskNumber !== 0) {
+ throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported');
+ }
+ const localOffset = central.localFileHeaderOffset + end.prefix;
+ const local = new LocalFileHeader(buf, localOffset);
+ const dataStart = localOffset + local.byteLength;
+ const length = central.compressedSize;
+ validateArchiveRange(buf, dataStart, length, 'entry data');
+ const content = length ? buf.subarray(dataStart, dataStart + length) : EMPTY_BUFFER;
+ ArrayPrototypePush(parsed, {
+ entry: new ZipEntry(central, local, content),
+ start: localOffset,
+ dataEnd: dataStart + length,
+ });
+ pos = central.byteOffset + central.byteLength;
+ }
+ // Sort a separate range list; entries themselves are yielded in central
+ // directory order.
+ const ranges = ArrayPrototypeSort(ArrayPrototypeSlice(parsed), (a, b) => a.start - b.start);
+ for (let i = 0; i < ranges.length; i++) {
+ const bound = i + 1 < ranges.length ? ranges[i + 1].start : end.centralDirectoryOffset;
+ if (ranges[i].dataEnd > bound) {
+ throw new ERR_ZIP_INVALID_ARCHIVE(
+ `entry ${JSONStringify(ranges[i].entry.name)} overlaps the next ` +
+ 'entry or the central directory (possible zip bomb)');
+ }
+ }
+ for (let i = 0; i < parsed.length; i++) yield parsed[i].entry;
+}
+
+module.exports = {
+ createEntryMeta,
+ readArchiveEntries,
+ ZipEntry,
+};
diff --git a/lib/internal/zip/extra-fields.js b/lib/internal/zip/extra-fields.js
new file mode 100644
index 00000000000000..0816268411f1de
--- /dev/null
+++ b/lib/internal/zip/extra-fields.js
@@ -0,0 +1,199 @@
+'use strict';
+
+// TLV extra-field parsing and building (sec. 4.5): the generic record walker,
+// the Zip64 extended-information field, modification-time extras
+// (NTFS/UT/UX), the round-trip-preserving strip of Zip64 records, and the
+// extended-timestamp builder used on the write path.
+
+const {
+ ArrayPrototypePush,
+ Date,
+ Number,
+} = primordials;
+
+const {
+ codes: {
+ ERR_ZIP_INVALID_ARCHIVE,
+ },
+} = require('internal/errors');
+const { Buffer } = require('buffer');
+const {
+ EMPTY_BUFFER,
+ ZIP64_EXTRA_ID,
+ EXTRA_ID_NTFS,
+ EXTRA_ID_EXT_TIMESTAMP,
+ EXTRA_ID_UNIX_OLD,
+} = require('internal/zip/constants');
+const { readSafeUint64 } = require('internal/zip/binary');
+
+// Zip64 extended information extra field (sec. 4.5.3). Walks the TLV records
+// itself, and unlike forEachExtraField() below it throws on a malformed
+// record: it only runs when a classic header field holds an overflow
+// sentinel, so the Zip64 record is required and a malformed extra field
+// hides required data.
+function parseZip64Extra(extra, want) {
+ const wanted =
+ want.uncompressedSize ||
+ want.compressedSize ||
+ want.localFileHeaderOffset ||
+ want.diskNumber;
+ if (!wanted) return {};
+ let pos = 0;
+ while (pos + 4 <= extra.length) {
+ const id = extra.readUInt16LE(pos);
+ const size = extra.readUInt16LE(pos + 2);
+ if (pos + 4 + size > extra.length) {
+ throw new ERR_ZIP_INVALID_ARCHIVE('extra field is malformed');
+ }
+ if (id === ZIP64_EXTRA_ID) {
+ const result = {};
+ const body = pos + 4;
+ const end = body + size;
+ // APPNOTE 4.5.3: the field order is fixed - uncompressed size (8),
+ // compressed size (8), local header offset (8), disk number (4) - and
+ // each field MUST appear only when the corresponding classic field
+ // holds its overflow sentinel. When the record length matches exactly
+ // the fields the sentinels call for, parse them packed per spec.
+ // Real-world writers violate the "only" rule and emit fields for
+ // non-sentinel values too (commonly all four); such a record is longer
+ // than the wanted set, and is instead parsed positionally against the
+ // full layout - the fixed order makes both readings unambiguous.
+ const wantedSize =
+ (want.uncompressedSize ? 8 : 0) +
+ (want.compressedSize ? 8 : 0) +
+ (want.localFileHeaderOffset ? 8 : 0) +
+ (want.diskNumber ? 4 : 0);
+ const packed = size === wantedSize;
+ let cursor = body;
+ const take = (fullLayoutOffset, bytes) => {
+ const at = packed ? cursor : body + fullLayoutOffset;
+ if (at + bytes > end) {
+ throw new ERR_ZIP_INVALID_ARCHIVE(
+ 'the Zip64 extended information extra field is truncated');
+ }
+ cursor += bytes;
+ return bytes === 8 ? readSafeUint64(extra, at) : extra.readUInt32LE(at);
+ };
+ if (want.uncompressedSize) result.uncompressedSize = take(0, 8);
+ if (want.compressedSize) result.compressedSize = take(8, 8);
+ if (want.localFileHeaderOffset) result.localFileHeaderOffset = take(16, 8);
+ if (want.diskNumber) result.diskNumber = take(24, 4);
+ return result;
+ }
+ pos += 4 + size;
+ }
+ throw new ERR_ZIP_INVALID_ARCHIVE(
+ 'a field is 0xFFFFFFFF but the Zip64 extended information extra field is missing');
+}
+
+// Walk TLV extra-field records - id(2), size(2), body(size) - invoking `cb`
+// per record. Stops silently at the first malformed/overrunning record -
+// deliberately laxer than parseZip64Extra() above: this walker only feeds
+// advisory metadata (timestamps, Unicode names), and a malformed extra
+// should not make the archive unreadable.
+function forEachExtraField(extra, cb) {
+ let pos = 0;
+ while (pos + 4 <= extra.length) {
+ const id = extra.readUInt16LE(pos);
+ const size = extra.readUInt16LE(pos + 2);
+ if (pos + 4 + size > extra.length) break;
+ cb(id, extra.subarray(pos + 4, pos + 4 + size));
+ pos += 4 + size;
+ }
+}
+
+// The mtime from an NTFS extra field (id 0x000a, sec. 4.5.5): a reserved
+// dword then tagged sub-records; tag 1 carries the FILETIME mtime/atime/ctime.
+function parseNtfsMtime(body) {
+ let result = null;
+ let pos = 4; // Skip the reserved dword.
+ while (pos + 4 <= body.length) {
+ const tag = body.readUInt16LE(pos);
+ const size = body.readUInt16LE(pos + 2);
+ if (pos + 4 + size > body.length) break;
+ if (tag === 1 && size >= 8) {
+ // Windows FILETIME: 100 ns ticks since 1601-01-01 UTC.
+ const ticks = body.readBigUInt64LE(pos + 4);
+ result = new Date(Number(ticks / 10000n) - 11644473600000);
+ }
+ pos += 4 + size;
+ }
+ return result;
+}
+
+// The mtime from an Info-ZIP extended timestamp extra field ("UT", 0x5455;
+// listed in APPNOTE's third-party ID table sec. 4.6.1, format defined in
+// Info-ZIP's extrafld.txt).
+function parseExtTimestampMtime(body) {
+ // flags(1) then present times; bit 0 => mtime present (signed Unix seconds).
+ if (body.length < 5 || (body[0] & 1) === 0) return null;
+ return new Date(body.readInt32LE(1) * 1000);
+}
+
+// The mtime from an Info-ZIP original Unix extra field ("UX", id 0x5855).
+function parseUnixOldMtime(body) {
+ // atime(4), mtime(4) as signed Unix seconds (uid/gid follow only locally).
+ if (body.length < 8) return null;
+ return new Date(body.readInt32LE(4) * 1000);
+}
+
+// The highest-fidelity modification time carried in the given extra fields, or
+// null when none is present. NTFS (100 ns) beats the extended timestamp (1 s,
+// UTC) beats Info-ZIP Unix (1 s); all are absolute instants, unlike the coarse
+// local-time DOS date/time fields.
+function extraFieldMtime(...extras) {
+ let ntfs = null;
+ let ext = null;
+ let unix = null;
+ for (const extra of extras) {
+ if (!extra?.length) continue;
+ forEachExtraField(extra, (id, body) => {
+ if (id === EXTRA_ID_NTFS) ntfs ??= parseNtfsMtime(body);
+ else if (id === EXTRA_ID_EXT_TIMESTAMP) ext ??= parseExtTimestampMtime(body);
+ else if (id === EXTRA_ID_UNIX_OLD) unix ??= parseUnixOldMtime(body);
+ });
+ }
+ return ntfs ?? ext ?? unix;
+}
+
+// A copy of `extra` without any Zip64 record (id 0x0001): Zip64 data is
+// regenerated from the final sizes/offsets on serialization, but every other
+// record (Unicode Path, NTFS/UT timestamps, ...) must survive a round trip -
+// dropping them silently renames entries whose display name lives in the
+// Unicode Path extra and discards high-fidelity timestamps.
+function stripZip64Extra(extra) {
+ if (!extra.length) return EMPTY_BUFFER;
+ const parts = [];
+ let total = 0;
+ forEachExtraField(extra, (id, body) => {
+ if (id === ZIP64_EXTRA_ID) return;
+ const record = Buffer.allocUnsafe(4 + body.length);
+ record.writeUInt16LE(id, 0);
+ record.writeUInt16LE(body.length, 2);
+ body.copy(record, 4);
+ ArrayPrototypePush(parts, record);
+ total += record.length;
+ });
+ if (parts.length === 0) return EMPTY_BUFFER;
+ return Buffer.concat(parts, total);
+}
+
+// Info-ZIP extended timestamp extra field ("UT", 0x5455; see
+// parseExtTimestampMtime() above for the format's provenance): a flags byte
+// (bit 0 = modification time present) followed by the whole (UTC) second.
+function buildExtTimestampExtra(seconds) {
+ const buffer = Buffer.allocUnsafe(9);
+ buffer.writeUInt16LE(EXTRA_ID_EXT_TIMESTAMP, 0);
+ buffer.writeUInt16LE(5, 2);
+ buffer.writeUInt8(0x01, 4);
+ buffer.writeInt32LE(seconds, 5);
+ return buffer;
+}
+
+module.exports = {
+ parseZip64Extra,
+ forEachExtraField,
+ extraFieldMtime,
+ stripZip64Extra,
+ buildExtTimestampExtra,
+};
diff --git a/lib/internal/zip/file.js b/lib/internal/zip/file.js
new file mode 100644
index 00000000000000..a28ef0108c0147
--- /dev/null
+++ b/lib/internal/zip/file.js
@@ -0,0 +1,695 @@
+'use strict';
+
+// `ZipFile`: random-access, in-place-writable view over an archive on disk
+// (a raw fd), plus `buildCentralDirectoryChunks()` which rebuilds the central
+// directory (and its Zip64/EOCD trailer) for the in-place rewrite path.
+
+const {
+ ArrayPrototypePush,
+ ArrayPrototypeSort,
+ FunctionPrototypeCall,
+ JSONStringify,
+ Map,
+ MapPrototypeClear,
+ MapPrototypeDelete,
+ MapPrototypeEntries,
+ MapPrototypeGet,
+ MapPrototypeGetSize,
+ MapPrototypeHas,
+ MapPrototypeKeys,
+ MapPrototypeSet,
+ MathMin,
+ PromisePrototypeThen,
+ PromiseResolve,
+ SymbolAsyncDispose,
+ SymbolAsyncIterator,
+ SymbolDispose,
+ SymbolIterator,
+ SymbolToStringTag,
+} = primordials;
+
+const {
+ codes: {
+ ERR_INVALID_ARG_TYPE,
+ ERR_INVALID_STATE,
+ ERR_ZIP_ARCHIVE_TOO_LARGE,
+ ERR_ZIP_ENTRY_NOT_FOUND,
+ ERR_ZIP_INVALID_ARCHIVE,
+ ERR_ZIP_NOT_WRITABLE,
+ },
+} = require('internal/errors');
+const {
+ validateBoolean,
+ validateFunction,
+ validateString,
+} = require('internal/validators');
+const { Buffer, kMaxLength } = require('buffer');
+const { Readable } = require('stream');
+const fs = require('fs');
+const {
+ TAIL_LENGTH,
+ kFinalize,
+ kPromote,
+} = require('internal/zip/constants');
+const {
+ buildArchiveTrailer,
+} = require('internal/zip/header-builders');
+const {
+ CentralFileHeader,
+ findArchiveEnd,
+ readCentralDirectory,
+} = require('internal/zip/headers');
+const { decodeZipText } = require('internal/zip/dos');
+const {
+ fsOpenAsync,
+ fsCloseAsync,
+ fsFstatAsync,
+ fsFtruncateAsync,
+ readFdFully,
+ readFdFullySync,
+ writeFdFully,
+ writeFdFullySync,
+} = require('internal/zip/fs-util');
+const { ZipEntry } = require('internal/zip/entry');
+const {
+ createZipArchive,
+ createZipArchiveSync,
+} = require('internal/zip/archive');
+
+/**
+ * Builds a fresh central directory (sec. 4.3.12) plus its trailer (the Zip64
+ * end record/locator when needed and the end-of-central-directory record;
+ * see `buildArchiveTrailer()`) for `records`, an array of
+ * `{ entry, localOffset }` pairs already in their final order and at their
+ * final (possibly pre-existing, possibly freshly written) offsets.
+ * @param {Array<{ entry: ZipEntry, localOffset: number }>} records
+ * @param {number} centralDirectoryOffset
+ * @param {Buffer} comment
+ * @returns {{ centralHeaders: Buffer[], chunks: Buffer[] }}
+ */
+function buildCentralDirectoryChunks(records, centralDirectoryOffset, comment) {
+ const centralHeaders = [];
+ let centralDirectorySize = 0;
+ for (let i = 0; i < records.length; i++) {
+ // `true`: this rewrite leaves local headers on disk untouched, so an
+ // entry whose local header advertises a data descriptor (bit 3) must
+ // keep advertising it in the rebuilt central header; see [kFinalize].
+ const header = records[i].entry[kFinalize](records[i].localOffset, true);
+ ArrayPrototypePush(centralHeaders, header);
+ centralDirectorySize += header.length;
+ }
+ const count = records.length;
+ const chunks = [];
+ for (let i = 0; i < centralHeaders.length; i++) ArrayPrototypePush(chunks, centralHeaders[i]);
+ const trailer = buildArchiveTrailer(count, centralDirectorySize, centralDirectoryOffset, comment);
+ for (let i = 0; i < trailer.length; i++) ArrayPrototypePush(chunks, trailer[i]);
+ return { centralHeaders, chunks };
+}
+
+// Shared post-location validation for open()/openSync(): the archive tail
+// has been found; make sure the central directory it describes can actually
+// be buffered and lies inside the file.
+function checkArchiveEnd(end, size) {
+ if (end.centralDirectorySize > kMaxLength) {
+ throw new ERR_ZIP_ARCHIVE_TOO_LARGE('the central directory is too large to buffer');
+ }
+ if (end.centralDirectoryOffset + end.centralDirectorySize > size) {
+ throw new ERR_ZIP_INVALID_ARCHIVE('central directory is out of bounds');
+ }
+}
+
+// Reject any member whose declared compressed bytes cannot lie inside the
+// file, and any pair of members whose data ranges overlap each other or the
+// central directory. The buffered read paths (`ZipEntry.prototype.content()`
+// and friends) allocate `compressedSize` bytes before reading, so without
+// the bounds check a tiny file whose central directory lies about a
+// member's size could force an allocation of up to `kMaxLength` bytes. The
+// overlap check counters the "quoted overlap" zip-bomb shape (CVE-2024-0450
+// in other implementations): N central records quoting the same data region
+// turn one small file into N full-size extractions, while real archives lay
+// members out disjointly. The local header's exact length is not known
+// without reading it, but it is at least 30 bytes, so
+// `offset + 30 + compressedSize` is a safe lower bound for where the next
+// member may start; trailing data descriptors only widen the true gap. The
+// in-memory reader applies the same rules in `readArchiveEntries()`.
+function validateMemberBounds(headers, prefix, size, centralDirectoryOffset) {
+ const members = [];
+ for (let i = 0; i < headers.length; i++) {
+ const header = headers[i];
+ const offset = header.localFileHeaderOffset + prefix;
+ if (offset + header.compressedSize > size) {
+ throw new ERR_ZIP_INVALID_ARCHIVE(
+ `entry ${JSONStringify(header.fileName)} data is out of bounds`);
+ }
+ ArrayPrototypePush(members,
+ { header, offset, end: offset + 30 + header.compressedSize });
+ }
+ ArrayPrototypeSort(members, (a, b) => a.offset - b.offset);
+ for (let i = 0; i < members.length; i++) {
+ const bound = i + 1 < members.length ? members[i + 1].offset : centralDirectoryOffset;
+ if (members[i].end > bound) {
+ throw new ERR_ZIP_INVALID_ARCHIVE(
+ `entry ${JSONStringify(members[i].header.fileName)} overlaps the next ` +
+ 'entry or the central directory (possible zip bomb)');
+ }
+ }
+}
+
+/**
+ * A random-access view over the entries of a ZIP archive on disk. Only the
+ * archive tail and central directory are read up front; individual member
+ * content is read lazily and on demand. Writable when opened with
+ * `{ writable: true }`: adding or deleting an entry rewrites the central
+ * directory in place, appending new entry content where the old central
+ * directory used to be.
+ *
+ * Every method has a `*Sync` counterpart. The synchronous methods block the
+ * Node.js event loop and further JavaScript execution until the operation
+ * completes - use them only where synchronous I/O is appropriate (for
+ * example, short-lived scripts or startup code), never in code that must
+ * stay responsive. A synchronous method throws `ERR_INVALID_STATE` if called
+ * while an asynchronous `addEntry()`/`add()`/`delete()`/`close()` on the same
+ * `ZipFile` has not settled yet, since letting the two interleave could
+ * corrupt the archive.
+ */
+class ZipFile {
+ #fd;
+ #writable;
+ #comment;
+ #centralDirectoryOffset;
+ #entries = new Map();
+ #queue = PromiseResolve();
+ #pendingAsyncOps = 0;
+
+ /**
+ * Builds the by-name entry map from the already-parsed central headers,
+ * recording each member's local-header offset (adjusted by any prefix).
+ * @private
+ */
+ constructor(fd, centralHeaders, prefix, centralDirectoryOffset, comment, writable) {
+ this.#fd = fd;
+ this.#writable = writable;
+ this.#comment = comment;
+ this.#centralDirectoryOffset = centralDirectoryOffset;
+ for (let i = 0; i < centralHeaders.length; i++) {
+ const central = centralHeaders[i];
+ MapPrototypeSet(this.#entries, central.fileName, {
+ central,
+ entry: undefined,
+ localOffset: central.localFileHeaderOffset + prefix,
+ });
+ }
+ }
+ get writable() { return this.#writable; }
+ // The EOCD comment has no encoding flag; apply the same UTF-8/CP437
+ // heuristic as unflagged member names and comments.
+ get comment() { return decodeZipText(this.#comment, 0); }
+ // Guard: throw unless this archive was opened writable.
+ #assertWritable() {
+ if (!this.#writable) throw new ERR_ZIP_NOT_WRITABLE();
+ }
+ // Guard: reject a synchronous call while an async mutation is still in
+ // flight, since interleaving the two could corrupt the archive.
+ #assertNotBusy() {
+ if (this.#pendingAsyncOps > 0) {
+ throw new ERR_INVALID_STATE(
+ 'cannot call a synchronous ZipFile method while an asynchronous ' +
+ 'add(), addEntry(), delete(), or close() call has not settled yet');
+ }
+ }
+ // Serialize async mutations (add/delete/close) through a promise chain so
+ // they never overlap and corrupt the archive; the in-flight counter backs
+ // #assertNotBusy(). Failures do not break the chain (both arms continue it).
+ #enqueue(fn) {
+ this.#pendingAsyncOps++;
+ const run = async () => {
+ try {
+ return await fn();
+ } finally {
+ this.#pendingAsyncOps--;
+ }
+ };
+ const result = PromisePrototypeThen(this.#queue, run, run);
+ this.#queue = PromisePrototypeThen(result, () => undefined, () => undefined);
+ return result;
+ }
+ has(name) {
+ validateString(name, 'name');
+ return MapPrototypeHas(this.#entries, name);
+ }
+ // Return the lazy, file-backed ZipEntry handle for `info`, creating and
+ // caching it on first access. The handle stores only a descriptor and the
+ // local-header offset - never the member's content - so repeated `get()`s
+ // return the same lightweight object and no content buffer is retained by
+ // the ZipFile. Any read (`content()`, `contentIterator()`) goes to disk.
+ #handleFor(info) {
+ info.entry ??= new ZipEntry(info.central, null, null, this.#fd, info.localOffset);
+ return info.entry;
+ }
+ /**
+ * Returns a lazy, file-backed `ZipEntry` for `name`. Nothing is read from
+ * disk here and no content is buffered; the entry reads (and, for
+ * `content()`, decompresses) straight from the file on each access. The
+ * returned entry is valid only while this `ZipFile` is open.
+ * @param {string} name
+ * @returns {Promise}
+ */
+ async get(name) {
+ validateString(name, 'name');
+ const info = MapPrototypeGet(this.#entries, name);
+ if (info === undefined) throw new ERR_ZIP_ENTRY_NOT_FOUND(name);
+ return this.#handleFor(info);
+ }
+ /**
+ * The synchronous counterpart of `get()`. Like `get()`, it reads nothing
+ * up front and buffers no content - it only builds the lazy handle - so it
+ * does not itself block on I/O; see the class-level note on synchronous
+ * methods for reads performed later through the returned entry.
+ * @param {string} name
+ * @returns {ZipEntry}
+ */
+ getSync(name) {
+ this.#assertNotBusy();
+ validateString(name, 'name');
+ const info = MapPrototypeGet(this.#entries, name);
+ if (info === undefined) throw new ERR_ZIP_ENTRY_NOT_FOUND(name);
+ return this.#handleFor(info);
+ }
+ /**
+ * Streams a member's decoded content without buffering the whole member,
+ * as a `Readable` (verifying CRC-32 by default; `{ verify: false }` to opt
+ * out). Sugar for wrapping `get(name).contentIterator(options)`; the
+ * compressed bytes are read from disk as the stream is consumed.
+ * @param {string} name
+ * @param {{ verify?: boolean, maxSize?: number }} [options]
+ * @returns {Promise}
+ */
+ async stream(name, options) {
+ const entry = await this.get(name);
+ return Readable.from(entry.contentIterator(options), { objectMode: false });
+ }
+ /**
+ * Writes `entry`'s serialized bytes where the central directory currently
+ * starts, then rewrites the central directory to include it. Replaces any
+ * existing entry of the same name (its bytes become dead space, reclaimed
+ * by `compact()`).
+ * @param {ZipEntry} entry
+ * @returns {Promise}
+ */
+ async addEntry(entry) {
+ this.#assertWritable();
+ if (!(entry instanceof ZipEntry)) {
+ throw new ERR_INVALID_ARG_TYPE('entry', 'ZipEntry', entry);
+ }
+ return this.#enqueue(() => this.#doAdd(entry));
+ }
+ /**
+ * Builds an entry from in-memory `data` and appends it; see `addEntry()`.
+ * @param {string} filename
+ * @param {Buffer | TypedArray | DataView | ArrayBuffer} data
+ * @param {{ comment?: string, mode?: number, modified?: Date, method?: 'deflate' | 'store' | 'zstd' }} [options]
+ * @returns {Promise}
+ */
+ async add(filename, data, options) {
+ this.#assertWritable();
+ return this.addEntry(await ZipEntry.create(filename, data, options));
+ }
+ // Append the entry's bytes where the central directory currently starts,
+ // then rewrite the directory to include it. On write failure, restore the
+ // original directory (the partial write may have clobbered it) and rethrow;
+ // on success, promote a spent stream entry to its on-disk copy.
+ async #doAdd(entry) {
+ const localOffset = this.#centralDirectoryOffset;
+ let written = 0;
+ try {
+ for await (const chunk of entry) {
+ await writeFdFully(this.#fd, chunk, localOffset + written);
+ written += chunk.length;
+ }
+ } catch (err) {
+ // The failed entry's bytes start where the old central directory
+ // started, so part of it may already be overwritten - while the EOCD
+ // still points at it. Nothing has been adopted into memory yet, so
+ // rebuild and rewrite the directory (and EOCD) at its original offset
+ // to leave the archive exactly as it was before the call.
+ try {
+ await this.#rewriteCentralDirectory();
+ } catch {
+ // Restoring failed too (the device is likely full or gone); the
+ // original error is the actionable one.
+ }
+ throw err;
+ }
+ this.#centralDirectoryOffset = localOffset + written;
+ MapPrototypeSet(this.#entries, entry.name, { central: null, entry, localOffset });
+ await this.#rewriteCentralDirectory();
+ // The entry now has a stable home in this archive; if it was a spent
+ // streaming entry, rebind it to that on-disk copy so it stays readable.
+ entry[kPromote](this.#fd, localOffset);
+ return entry;
+ }
+ /**
+ * The synchronous counterpart of `addEntry()`. `entry` must not be a
+ * pending streaming entry (one created with `ZipEntry.createStream()`) -
+ * there is no synchronous way to drain its asynchronous source. Blocks the
+ * event loop until done; see the class-level note on synchronous methods.
+ * @param {ZipEntry} entry
+ * @returns {ZipEntry}
+ */
+ addEntrySync(entry) {
+ this.#assertWritable();
+ this.#assertNotBusy();
+ if (!(entry instanceof ZipEntry)) {
+ throw new ERR_INVALID_ARG_TYPE('entry', 'ZipEntry', entry);
+ }
+ const localOffset = this.#centralDirectoryOffset;
+ let written = 0;
+ try {
+ for (const chunk of entry) {
+ writeFdFullySync(this.#fd, chunk, localOffset + written);
+ written += chunk.length;
+ }
+ } catch (err) {
+ // See #doAdd(): restore the (partially overwritten) central directory
+ // before surfacing the failure.
+ try {
+ this.#rewriteCentralDirectorySync();
+ } catch {
+ // Restoring failed too; the original error is the actionable one.
+ }
+ throw err;
+ }
+ this.#centralDirectoryOffset = localOffset + written;
+ MapPrototypeSet(this.#entries, entry.name, { central: null, entry, localOffset });
+ this.#rewriteCentralDirectorySync();
+ entry[kPromote](this.#fd, localOffset);
+ return entry;
+ }
+ /**
+ * The synchronous counterpart of `add()`. Blocks the event loop until
+ * done (including the deflate pass); see the class-level note on
+ * synchronous methods.
+ * @param {string} filename
+ * @param {Buffer | TypedArray | DataView | ArrayBuffer} data
+ * @param {{ comment?: string, mode?: number, modified?: Date, method?: 'deflate' | 'store' | 'zstd' }} [options]
+ * @returns {ZipEntry}
+ */
+ addSync(filename, data, options) {
+ this.#assertWritable();
+ return this.addEntrySync(ZipEntry.createSync(filename, data, options));
+ }
+ /**
+ * Removes an entry by name. The central directory is rewritten in place
+ * (no new content is written, so the archive does not grow); the removed
+ * entry's bytes become dead space, reclaimed by `compact()`.
+ * @param {string} name
+ * @returns {Promise}
+ */
+ async delete(name) {
+ this.#assertWritable();
+ validateString(name, 'name');
+ return this.#enqueue(() => this.#doDelete(name));
+ }
+ // Drop the named entry and rewrite the central directory (no member bytes
+ // move, so the file does not grow); reports whether it existed.
+ async #doDelete(name) {
+ const existed = MapPrototypeDelete(this.#entries, name);
+ if (existed) await this.#rewriteCentralDirectory();
+ return existed;
+ }
+ /**
+ * The synchronous counterpart of `delete()`. Blocks the event loop until
+ * done; see the class-level note on synchronous methods.
+ * @param {string} name
+ * @returns {boolean}
+ */
+ deleteSync(name) {
+ this.#assertWritable();
+ this.#assertNotBusy();
+ validateString(name, 'name');
+ const existed = MapPrototypeDelete(this.#entries, name);
+ if (existed) this.#rewriteCentralDirectorySync();
+ return existed;
+ }
+ // Snapshot the live entries as ordered { entry, localOffset } records for a
+ // central-directory rebuild, materializing a plain ZipEntry for any member
+ // not yet handed out as a lazy handle.
+ #liveRecords() {
+ const records = [];
+ const names = [];
+ for (const { 0: name, 1: value } of MapPrototypeEntries(this.#entries)) {
+ ArrayPrototypePush(records, {
+ entry: value.entry ?? new ZipEntry(value.central, null, null),
+ localOffset: value.localOffset,
+ });
+ ArrayPrototypePush(names, name);
+ }
+ return { records, names };
+ }
+ // Rebuild the central directory and its trailer (sec. 4.3.12/4.3.16) for the
+ // current live set and overwrite it in place at its current offset,
+ // truncating any leftover tail, then adopt the freshly written headers.
+ async #rewriteCentralDirectory() {
+ const { records, names } = this.#liveRecords();
+ const { centralHeaders, chunks } = buildCentralDirectoryChunks(
+ records, this.#centralDirectoryOffset, this.#comment);
+ let pos = this.#centralDirectoryOffset;
+ for (let i = 0; i < chunks.length; i++) {
+ await writeFdFully(this.#fd, chunks[i], pos);
+ pos += chunks[i].length;
+ }
+ await fsFtruncateAsync(this.#fd, pos);
+ this.#adoptRewrittenCentralDirectory(names, centralHeaders, records);
+ }
+ // Sync counterpart of #rewriteCentralDirectory().
+ #rewriteCentralDirectorySync() {
+ const { records, names } = this.#liveRecords();
+ const { centralHeaders, chunks } = buildCentralDirectoryChunks(
+ records, this.#centralDirectoryOffset, this.#comment);
+ let pos = this.#centralDirectoryOffset;
+ for (let i = 0; i < chunks.length; i++) {
+ writeFdFullySync(this.#fd, chunks[i], pos);
+ pos += chunks[i].length;
+ }
+ fs.ftruncateSync(this.#fd, pos);
+ this.#adoptRewrittenCentralDirectory(names, centralHeaders, records);
+ }
+ // Re-derives fresh, disk-backed central headers from what was just
+ // written, so every entry - original or freshly added - is uniformly
+ // readable by offset from now on, regardless of whether its in-memory
+ // ZipEntry (e.g. a streaming entry, whose source can only be consumed
+ // once) is still around.
+ #adoptRewrittenCentralDirectory(names, centralHeaders, records) {
+ for (let i = 0; i < names.length; i++) {
+ MapPrototypeSet(this.#entries, names[i], {
+ central: new CentralFileHeader(centralHeaders[i], 0),
+ entry: undefined,
+ localOffset: records[i].localOffset,
+ });
+ }
+ }
+ /**
+ * Serializes the currently live entries into a fresh archive stream,
+ * leaving behind any dead space left by prior `addEntry()`/`delete()`
+ * calls. Does not modify the open file; pipe the result into a new one.
+ * @param {string} [comment]
+ * @returns {import('stream').Readable}
+ */
+ compact(comment) {
+ // Snapshot the live set now: a later addEntry()/delete() must not error
+ // out (or change) an archive stream that is already being produced.
+ // Reading the snapshot stays valid regardless of later mutations -
+ // neither addEntry() nor delete() moves existing member bytes.
+ const entries = [];
+ for (const { 1: info } of MapPrototypeEntries(this.#entries)) {
+ ArrayPrototypePush(entries, this.#handleFor(info));
+ }
+ return createZipArchive(entries, { comment: comment ?? this.#comment });
+ }
+ /**
+ * The synchronous counterpart of `compact()`. Blocks the event loop until
+ * the whole archive has been read and re-serialized; see the class-level
+ * note on synchronous methods.
+ * @param {string} [comment]
+ * @returns {Buffer}
+ */
+ compactSync(comment) {
+ this.#assertNotBusy();
+ const entries = [];
+ for (const { 1: info } of MapPrototypeEntries(this.#entries)) {
+ ArrayPrototypePush(entries, this.#handleFor(info));
+ }
+ const chunks = [];
+ for (const chunk of createZipArchiveSync(entries, { comment: comment ?? this.#comment })) {
+ ArrayPrototypePush(chunks, chunk);
+ }
+ return Buffer.concat(chunks);
+ }
+ keys() { return MapPrototypeKeys(this.#entries); }
+ *values() {
+ for (const name of this.keys()) yield this.get(name);
+ }
+ /**
+ * The synchronous counterpart of `values()`, yielding resolved `ZipEntry`
+ * values instead of `Promise`s.
+ * @yields {ZipEntry}
+ */
+ *valuesSync() {
+ for (const name of this.keys()) yield this.getSync(name);
+ }
+ *entries() {
+ for (const name of this.keys()) yield [name, this.get(name)];
+ }
+ /**
+ * The synchronous counterpart of `entries()`, yielding resolved `ZipEntry`
+ * values instead of `Promise`s.
+ * @yields {[string, ZipEntry]}
+ */
+ *entriesSync() {
+ for (const name of this.keys()) yield [name, this.getSync(name)];
+ }
+ // Async iteration yields each resolved ZipEntry (awaiting the lazy handles).
+ async *[SymbolAsyncIterator]() {
+ for (const promise of this.values()) yield await promise;
+ }
+ get size() { return MapPrototypeGetSize(this.#entries); }
+ [SymbolIterator]() { return this.entries(); }
+ get [SymbolToStringTag]() { return 'ZipFile'; }
+ forEach(callback, thisArg) {
+ validateFunction(callback, 'callback');
+ for (const { 0: key, 1: value } of this.entries()) {
+ FunctionPrototypeCall(callback, thisArg === undefined ? this : thisArg, value, key, this);
+ }
+ }
+ /**
+ * The synchronous counterpart of `forEach()`, invoking `callback` with a
+ * resolved `ZipEntry` instead of a `Promise`.
+ * @param {Function} callback
+ * @param {*} [thisArg]
+ */
+ forEachSync(callback, thisArg) {
+ validateFunction(callback, 'callback');
+ for (const { 0: key, 1: value } of this.entriesSync()) {
+ FunctionPrototypeCall(callback, thisArg === undefined ? this : thisArg, value, key, this);
+ }
+ }
+ // Drop all entries and close the fd, queued behind any pending async ops.
+ close() {
+ return this.#enqueue(async () => {
+ MapPrototypeClear(this.#entries);
+ await fsCloseAsync(this.#fd);
+ });
+ }
+ /**
+ * The synchronous counterpart of `close()`; see the class-level note on
+ * synchronous methods.
+ */
+ closeSync() {
+ this.#assertNotBusy();
+ MapPrototypeClear(this.#entries);
+ fs.closeSync(this.#fd);
+ }
+ async [SymbolAsyncDispose]() {
+ await this.close();
+ }
+ [SymbolDispose]() {
+ this.closeSync();
+ }
+ /**
+ * Opens an archive, reading only its tail and central directory up front
+ * (member content stays on disk). Pass `{ writable: true }` for in-place
+ * editing.
+ * @param {string} filename
+ * @param {{ writable?: boolean }} [options]
+ * @returns {Promise}
+ */
+ static async open(filename, options) {
+ validateString(filename, 'filename');
+ const writable = options?.writable ?? false;
+ validateBoolean(writable, 'options.writable');
+ const fd = await fsOpenAsync(filename, writable ? 'r+' : 'r');
+ try {
+ const stat = await fsFstatAsync(fd);
+ const size = stat.size;
+ const tailLength = MathMin(size, TAIL_LENGTH);
+ const tail = Buffer.allocUnsafe(tailLength);
+ await readFdFully(fd, tail, size - tailLength);
+ let end = findArchiveEnd(tail, size - tailLength);
+ if (end.needTailFrom !== undefined) {
+ // The Zip64 EOCD record's extensible data sector pushes the record
+ // start beyond the fixed-size tail; re-read from the recorded
+ // offset (bounded by ZIP64_EOCD_MAX_LENGTH inside findArchiveEnd).
+ const retry = Buffer.allocUnsafe(size - end.needTailFrom);
+ await readFdFully(fd, retry, end.needTailFrom);
+ end = findArchiveEnd(retry, end.needTailFrom);
+ if (end.needTailFrom !== undefined) {
+ throw new ERR_ZIP_INVALID_ARCHIVE(
+ 'Zip64 end of central directory record not found');
+ }
+ }
+ checkArchiveEnd(end, size);
+ const directory = Buffer.allocUnsafe(end.centralDirectorySize);
+ await readFdFully(fd, directory, end.centralDirectoryOffset);
+ const headers = readCentralDirectory(directory, end.totalRecords);
+ validateMemberBounds(headers, end.prefix, size, end.centralDirectoryOffset);
+ return new ZipFile(fd, headers, end.prefix, end.centralDirectoryOffset, end.comment, writable);
+ } catch (err) {
+ try {
+ await fsCloseAsync(fd);
+ } catch {
+ // The archive failed to parse; the close error is not actionable.
+ }
+ throw err;
+ }
+ }
+ /**
+ * The synchronous counterpart of `open()`. Blocks the event loop and
+ * further JavaScript execution until the archive's tail and central
+ * directory have been read; see the class-level note on synchronous
+ * methods.
+ * @param {string} filename
+ * @param {{ writable?: boolean }} [options]
+ * @returns {ZipFile}
+ */
+ static openSync(filename, options) {
+ validateString(filename, 'filename');
+ const writable = options?.writable ?? false;
+ validateBoolean(writable, 'options.writable');
+ const fd = fs.openSync(filename, writable ? 'r+' : 'r');
+ try {
+ const size = fs.fstatSync(fd).size;
+ const tailLength = MathMin(size, TAIL_LENGTH);
+ const tail = Buffer.allocUnsafe(tailLength);
+ readFdFullySync(fd, tail, size - tailLength);
+ let end = findArchiveEnd(tail, size - tailLength);
+ if (end.needTailFrom !== undefined) {
+ // See open(): the Zip64 EOCD record starts before the fixed-size
+ // tail; re-read from the recorded offset.
+ const retry = Buffer.allocUnsafe(size - end.needTailFrom);
+ readFdFullySync(fd, retry, end.needTailFrom);
+ end = findArchiveEnd(retry, end.needTailFrom);
+ if (end.needTailFrom !== undefined) {
+ throw new ERR_ZIP_INVALID_ARCHIVE(
+ 'Zip64 end of central directory record not found');
+ }
+ }
+ checkArchiveEnd(end, size);
+ const directory = Buffer.allocUnsafe(end.centralDirectorySize);
+ readFdFullySync(fd, directory, end.centralDirectoryOffset);
+ const headers = readCentralDirectory(directory, end.totalRecords);
+ validateMemberBounds(headers, end.prefix, size, end.centralDirectoryOffset);
+ return new ZipFile(fd, headers, end.prefix, end.centralDirectoryOffset, end.comment, writable);
+ } catch (err) {
+ try {
+ fs.closeSync(fd);
+ } catch {
+ // The archive failed to parse; the close error is not actionable.
+ }
+ throw err;
+ }
+ }
+}
+
+module.exports = {
+ ZipFile,
+};
diff --git a/lib/internal/zip/fs-util.js b/lib/internal/zip/fs-util.js
new file mode 100644
index 00000000000000..cfd20069d00753
--- /dev/null
+++ b/lib/internal/zip/fs-util.js
@@ -0,0 +1,150 @@
+'use strict';
+
+// Promise wrappers over the callback `fs` primitives (`ZipFile` works on a
+// raw fd so one instance can serve both async and `Sync` methods), plus the
+// short-read/short-write loops that guarantee a full transfer.
+
+const {
+ Promise,
+} = primordials;
+
+const {
+ codes: {
+ ERR_INVALID_STATE,
+ ERR_ZIP_INVALID_ARCHIVE,
+ },
+} = require('internal/errors');
+const fs = require('fs');
+
+// Promisified adapters over the callback `fs` primitives; individually trivial,
+// they only exist so the async archive paths can `await` plain fd operations.
+//
+// `ZipFile` operates on a plain numeric file descriptor (rather than an
+// `fs.promises` `FileHandle`) so that a single instance can support both the
+// async and the `Sync` methods: `fs.read`/`fs.write`/`fs.fstat`/
+// `fs.ftruncate`/`fs.close` all accept a raw fd directly, same as their
+// `*Sync` counterparts, so both call sites share one open file underneath.
+function fsOpenAsync(path, flag) {
+ return new Promise((resolve, reject) => {
+ fs.open(path, flag, (err, fd) => (err ? reject(err) : resolve(fd)));
+ });
+}
+
+function fsStatAsync(path) {
+ return new Promise((resolve, reject) => {
+ fs.stat(path, (err, stats) => (err ? reject(err) : resolve(stats)));
+ });
+}
+
+function fsLstatAsync(path) {
+ return new Promise((resolve, reject) => {
+ fs.lstat(path, (err, stats) => (err ? reject(err) : resolve(stats)));
+ });
+}
+
+function fsReadlinkAsync(path) {
+ return new Promise((resolve, reject) => {
+ fs.readlink(path, 'utf8', (err, target) => (err ? reject(err) : resolve(target)));
+ });
+}
+
+function fsCloseAsync(fd) {
+ return new Promise((resolve, reject) => {
+ fs.close(fd, (err) => (err ? reject(err) : resolve()));
+ });
+}
+
+function fsFstatAsync(fd) {
+ return new Promise((resolve, reject) => {
+ fs.fstat(fd, (err, stats) => (err ? reject(err) : resolve(stats)));
+ });
+}
+
+function fsReadAsync(fd, buffer, offset, length, position) {
+ return new Promise((resolve, reject) => {
+ fs.read(fd, buffer, offset, length, position, (err, bytesRead) => (err ? reject(err) : resolve(bytesRead)));
+ });
+}
+
+function fsWriteAsync(fd, buffer, offset, length, position) {
+ return new Promise((resolve, reject) => {
+ fs.write(fd, buffer, offset, length, position, (err, bytesWritten) => (err ? reject(err) : resolve(bytesWritten)));
+ });
+}
+
+function fsFtruncateAsync(fd, len) {
+ return new Promise((resolve, reject) => {
+ fs.ftruncate(fd, len, (err) => (err ? reject(err) : resolve()));
+ });
+}
+
+// `read(2)` may return fewer bytes than requested without hitting EOF, so loop
+// until `buffer` is entirely filled; a genuine short read (0 bytes) means the
+// archive is truncated where a full header/record was expected.
+async function readFdFully(fd, buffer, position) {
+ let done = 0;
+ while (done < buffer.length) {
+ const bytesRead = await fsReadAsync(fd, buffer, done, buffer.length - done, position + done);
+ if (bytesRead <= 0) {
+ throw new ERR_ZIP_INVALID_ARCHIVE('unexpected end of file');
+ }
+ done += bytesRead;
+ }
+}
+
+// Synchronous counterpart of `readFdFully()`; same short-read loop.
+function readFdFullySync(fd, buffer, position) {
+ let done = 0;
+ while (done < buffer.length) {
+ const bytesRead = fs.readSync(fd, buffer, done, buffer.length - done, position + done);
+ if (bytesRead <= 0) {
+ throw new ERR_ZIP_INVALID_ARCHIVE('unexpected end of file');
+ }
+ done += bytesRead;
+ }
+}
+
+// `write(2)` may write fewer bytes than asked - most notably when an error
+// (ENOSPC, EIO, a full NFS commit) strikes after partial progress, which
+// surfaces as a short, error-free count. Advancing archive offsets by the
+// intended length would then silently corrupt the file, so every archive
+// write loops until the buffer is fully on disk; retrying the remainder
+// re-encounters and surfaces the underlying error.
+async function writeFdFully(fd, buffer, position) {
+ let done = 0;
+ while (done < buffer.length) {
+ const bytesWritten =
+ await fsWriteAsync(fd, buffer, done, buffer.length - done, position + done);
+ if (bytesWritten <= 0) {
+ throw new ERR_INVALID_STATE('a write to the archive made no progress');
+ }
+ done += bytesWritten;
+ }
+}
+
+// Synchronous counterpart of `writeFdFully()`; same short-write loop.
+function writeFdFullySync(fd, buffer, position) {
+ let done = 0;
+ while (done < buffer.length) {
+ const bytesWritten =
+ fs.writeSync(fd, buffer, done, buffer.length - done, position + done);
+ if (bytesWritten <= 0) {
+ throw new ERR_INVALID_STATE('a write to the archive made no progress');
+ }
+ done += bytesWritten;
+ }
+}
+
+module.exports = {
+ fsOpenAsync,
+ fsStatAsync,
+ fsLstatAsync,
+ fsReadlinkAsync,
+ fsCloseAsync,
+ fsFstatAsync,
+ fsFtruncateAsync,
+ readFdFully,
+ readFdFullySync,
+ writeFdFully,
+ writeFdFullySync,
+};
diff --git a/lib/internal/zip/header-builders.js b/lib/internal/zip/header-builders.js
new file mode 100644
index 00000000000000..4b48fbc53180cc
--- /dev/null
+++ b/lib/internal/zip/header-builders.js
@@ -0,0 +1,251 @@
+'use strict';
+
+// Write-path header builders: local/central file headers, the Zip64 data
+// descriptor, and the (Zip64) end-of-central-directory records, plus the
+// version-needed and extra-length helpers they share.
+
+const {
+ ArrayPrototypePush,
+ MathMax,
+ MathMin,
+} = primordials;
+
+const {
+ codes: {
+ ERR_ZIP_ENTRY_TOO_LARGE,
+ },
+} = require('internal/errors');
+const { Buffer } = require('buffer');
+const {
+ EMPTY_BUFFER,
+ SIG_LOCAL_FILE_HEADER,
+ SIG_DATA_DESCRIPTOR,
+ SIG_CENTRAL_FILE_HEADER,
+ SIG_ZIP64_EOCD_RECORD,
+ SIG_ZIP64_EOCD_LOCATOR,
+ SIG_EOCD,
+ MADE_BY_UNIX,
+ ZIP64_EXTRA_ID,
+ SENTINEL16,
+ SENTINEL32,
+ FLAG_DATA_DESCRIPTOR,
+ METHOD_ZSTD,
+ VERSION_DEFAULT,
+ VERSION_ZIP64,
+ VERSION_ZSTD,
+} = require('internal/zip/constants');
+const { encodeDosDateTime } = require('internal/zip/dos');
+const { writeSafeUint64 } = require('internal/zip/binary');
+const { buildExtTimestampExtra } = require('internal/zip/extra-fields');
+
+// The "version needed to extract" (sec. 4.4.3): the highest version any
+// feature of the member demands - 6.3 for Zstandard, 4.5 for Zip64
+// structures, 2.0 otherwise.
+function versionNeeded(meta, zip64) {
+ return MathMax(
+ zip64 ? VERSION_ZIP64 : VERSION_DEFAULT,
+ meta.method === METHOD_ZSTD ? VERSION_ZSTD : 0);
+}
+
+// Guard the combined extra-field length against the 16-bit field it is stored
+// in (sec. 4.4.11).
+function checkExtraLength(extraLength) {
+ if (extraLength > SENTINEL16) {
+ throw new ERR_ZIP_ENTRY_TOO_LARGE(
+ 'the entry extra fields must not exceed 65535 bytes');
+ }
+}
+
+// Build a local file header (sec. 4.3.7). Streamed entries (unknown sizes/CRC)
+// zero those fields and always carry a Zip64 extra so the trailing data
+// descriptor can hold 64-bit sizes; oversized non-streamed entries get one too.
+function buildLocalHeader(meta) {
+ const streaming = (meta.flags & FLAG_DATA_DESCRIPTOR) !== 0;
+ const zip64 =
+ streaming ||
+ meta.compressedSize >= SENTINEL32 ||
+ meta.uncompressedSize >= SENTINEL32;
+ const ts = meta.extendedMtime !== null ? buildExtTimestampExtra(meta.extendedMtime) : EMPTY_BUFFER;
+ const extraLength = (zip64 ? 20 : 0) + ts.length + meta.extra.length;
+ checkExtraLength(extraLength);
+ const buffer = Buffer.allocUnsafe(30 + meta.name.length + extraLength);
+ buffer.writeUInt32LE(SIG_LOCAL_FILE_HEADER, 0);
+ buffer.writeUInt16LE(versionNeeded(meta, zip64), 4);
+ buffer.writeUInt16LE(meta.flags, 6);
+ buffer.writeUInt16LE(meta.method, 8);
+ const { time, date } = encodeDosDateTime(meta.modified);
+ buffer.writeUInt16LE(time, 10);
+ buffer.writeUInt16LE(date, 12);
+ buffer.writeUInt32LE(streaming ? 0 : meta.crc, 14);
+ buffer.writeUInt32LE(zip64 ? SENTINEL32 : meta.compressedSize, 18);
+ buffer.writeUInt32LE(zip64 ? SENTINEL32 : meta.uncompressedSize, 22);
+ buffer.writeUInt16LE(meta.name.length, 26);
+ buffer.writeUInt16LE(extraLength, 28);
+ meta.name.copy(buffer, 30);
+ let pos = 30 + meta.name.length;
+ if (zip64) {
+ buffer.writeUInt16LE(ZIP64_EXTRA_ID, pos);
+ buffer.writeUInt16LE(16, pos + 2);
+ writeSafeUint64(buffer, pos + 4, streaming ? 0 : meta.uncompressedSize);
+ writeSafeUint64(buffer, pos + 12, streaming ? 0 : meta.compressedSize);
+ pos += 20;
+ }
+ ts.copy(buffer, pos);
+ pos += ts.length;
+ meta.extra.copy(buffer, pos);
+ return buffer;
+}
+
+// Build a central directory file header (sec. 4.3.12). Each of the
+// uncompressed size, compressed size, and local-header offset that overflows
+// 32 bits is moved into the Zip64 extra field (sec. 4.5.3) in that order.
+function buildCentralHeader(meta, localOffset) {
+ const zip64Streaming = (meta.flags & FLAG_DATA_DESCRIPTOR) !== 0;
+ const u64 = meta.uncompressedSize >= SENTINEL32;
+ const c64 = meta.compressedSize >= SENTINEL32;
+ const o64 = localOffset >= SENTINEL32;
+ const zip64Fields = (u64 ? 1 : 0) + (c64 ? 1 : 0) + (o64 ? 1 : 0);
+ const ts = meta.extendedMtime !== null ? buildExtTimestampExtra(meta.extendedMtime) : EMPTY_BUFFER;
+ const extraLength = (zip64Fields ? 4 + 8 * zip64Fields : 0) + ts.length + meta.extra.length;
+ checkExtraLength(extraLength);
+ const zip64 = zip64Streaming || zip64Fields > 0;
+ const version = versionNeeded(meta, zip64);
+ const buffer = Buffer.allocUnsafe(
+ 46 + meta.name.length + extraLength + meta.comment.length);
+ buffer.writeUInt32LE(SIG_CENTRAL_FILE_HEADER, 0);
+ // "Version made by" (sec. 4.4.2): the upper byte is the creator's host
+ // system, which determines how the external attributes (sec. 4.4.15) are
+ // interpreted. Preserve the original host for round-tripped entries -
+ // stamping everything as Unix would turn, say, a DOS entry's zeroed high
+ // bits into Unix mode 0000. Entries built by `createEntryMeta()` are Unix.
+ buffer.writeUInt16LE((meta.madeBy << 8) | version, 4);
+ buffer.writeUInt16LE(version, 6);
+ buffer.writeUInt16LE(meta.flags, 8);
+ buffer.writeUInt16LE(meta.method, 10);
+ const { time, date } = encodeDosDateTime(meta.modified);
+ buffer.writeUInt16LE(time, 12);
+ buffer.writeUInt16LE(date, 14);
+ buffer.writeUInt32LE(meta.crc, 16);
+ buffer.writeUInt32LE(c64 ? SENTINEL32 : meta.compressedSize, 20);
+ buffer.writeUInt32LE(u64 ? SENTINEL32 : meta.uncompressedSize, 24);
+ buffer.writeUInt16LE(meta.name.length, 28);
+ buffer.writeUInt16LE(extraLength, 30);
+ buffer.writeUInt16LE(meta.comment.length, 32);
+ buffer.writeUInt16LE(0, 34); // disk number
+ buffer.writeUInt16LE(meta.internal, 36);
+ buffer.writeUInt32LE(meta.external, 38);
+ buffer.writeUInt32LE(o64 ? SENTINEL32 : localOffset, 42);
+ meta.name.copy(buffer, 46);
+ let pos = 46 + meta.name.length;
+ if (zip64Fields) {
+ buffer.writeUInt16LE(ZIP64_EXTRA_ID, pos);
+ buffer.writeUInt16LE(8 * zip64Fields, pos + 2);
+ pos += 4;
+ if (u64) {
+ writeSafeUint64(buffer, pos, meta.uncompressedSize);
+ pos += 8;
+ }
+ if (c64) {
+ writeSafeUint64(buffer, pos, meta.compressedSize);
+ pos += 8;
+ }
+ if (o64) {
+ writeSafeUint64(buffer, pos, localOffset);
+ pos += 8;
+ }
+ }
+ ts.copy(buffer, pos);
+ pos += ts.length;
+ meta.extra.copy(buffer, pos);
+ pos += meta.extra.length;
+ meta.comment.copy(buffer, pos);
+ return buffer;
+}
+
+// Zip64 data descriptor (sec. 4.3.9): emitted after a streamed entry, whose
+// local header always carries a Zip64 extra field.
+function buildDataDescriptor64(crc, compressedSize, uncompressedSize) {
+ const buffer = Buffer.allocUnsafe(24);
+ buffer.writeUInt32LE(SIG_DATA_DESCRIPTOR, 0);
+ buffer.writeUInt32LE(crc, 4);
+ writeSafeUint64(buffer, 8, compressedSize);
+ writeSafeUint64(buffer, 16, uncompressedSize);
+ return buffer;
+}
+
+// Build the end of central directory record (sec. 4.3.16); fields that
+// overflow their 16/32-bit slots are written as sentinels and the true values
+// live in the Zip64 EOCD record.
+function buildEndOfCentralDirectory(count, size, offset, comment) {
+ const buffer = Buffer.allocUnsafe(22 + comment.length);
+ buffer.writeUInt32LE(SIG_EOCD, 0);
+ buffer.writeUInt16LE(0, 4); // disk number
+ buffer.writeUInt16LE(0, 6); // Central directory disk number
+ buffer.writeUInt16LE(MathMin(count, SENTINEL16), 8);
+ buffer.writeUInt16LE(MathMin(count, SENTINEL16), 10);
+ buffer.writeUInt32LE(MathMin(size, SENTINEL32), 12);
+ buffer.writeUInt32LE(MathMin(offset, SENTINEL32), 16);
+ buffer.writeUInt16LE(comment.length, 20);
+ comment.copy(buffer, 22);
+ return buffer;
+}
+
+// Build the Zip64 end of central directory record (sec. 4.3.14): the 64-bit
+// counterpart to the EOCD, holding the real record count/size/offset.
+function buildZip64EndRecord(count, size, offset) {
+ const buffer = Buffer.allocUnsafe(56);
+ buffer.writeUInt32LE(SIG_ZIP64_EOCD_RECORD, 0);
+ writeSafeUint64(buffer, 4, 44); // Size of the remainder of this record
+ buffer.writeUInt16LE((MADE_BY_UNIX << 8) | VERSION_ZIP64, 12);
+ buffer.writeUInt16LE(VERSION_ZIP64, 14);
+ buffer.writeUInt32LE(0, 16); // disk number
+ buffer.writeUInt32LE(0, 20); // Central directory disk number
+ writeSafeUint64(buffer, 24, count);
+ writeSafeUint64(buffer, 32, count);
+ writeSafeUint64(buffer, 40, size);
+ writeSafeUint64(buffer, 48, offset);
+ return buffer;
+}
+
+// Build the Zip64 end of central directory locator (sec. 4.3.15): points the
+// reader from just before the EOCD to the Zip64 EOCD record.
+function buildZip64EndLocator(recordOffset) {
+ const buffer = Buffer.allocUnsafe(20);
+ buffer.writeUInt32LE(SIG_ZIP64_EOCD_LOCATOR, 0);
+ buffer.writeUInt32LE(0, 4); // Disk with the Zip64 EOCD record
+ writeSafeUint64(buffer, 8, recordOffset);
+ buffer.writeUInt32LE(1, 16); // total disks
+ return buffer;
+}
+
+// The archive trailer: the Zip64 EOCD record and locator
+// (sec. 4.3.14/4.3.15) when the record count, central directory offset, or
+// central directory size overflows its classic 16-/32-bit field, followed
+// by the end of central directory record (sec. 4.3.16). Shared by the
+// streaming serializers and ZipFile's in-place rewrite so the Zip64
+// switchover rule lives in exactly one place.
+function buildArchiveTrailer(count, centralDirectorySize, centralDirectoryOffset, comment) {
+ const chunks = [];
+ const zip64 =
+ count >= SENTINEL16 ||
+ centralDirectoryOffset >= SENTINEL32 ||
+ centralDirectorySize >= SENTINEL32;
+ if (zip64) {
+ const recordOffset = centralDirectoryOffset + centralDirectorySize;
+ ArrayPrototypePush(chunks, buildZip64EndRecord(count, centralDirectorySize, centralDirectoryOffset));
+ ArrayPrototypePush(chunks, buildZip64EndLocator(recordOffset));
+ }
+ ArrayPrototypePush(chunks,
+ buildEndOfCentralDirectory(count, centralDirectorySize, centralDirectoryOffset, comment));
+ return chunks;
+}
+
+module.exports = {
+ buildLocalHeader,
+ buildCentralHeader,
+ buildDataDescriptor64,
+ buildEndOfCentralDirectory,
+ buildZip64EndRecord,
+ buildZip64EndLocator,
+ buildArchiveTrailer,
+};
diff --git a/lib/internal/zip/headers.js b/lib/internal/zip/headers.js
new file mode 100644
index 00000000000000..edc1214baa47df
--- /dev/null
+++ b/lib/internal/zip/headers.js
@@ -0,0 +1,495 @@
+'use strict';
+
+// Reader-side header structures (sec. 4.3): the end-of-central-directory
+// record, the Zip64 EOCD record/locator, and the central/local file headers,
+// plus `findArchiveEnd()` which locates them and `readCentralDirectory()`
+// which walks the central directory into an array of headers.
+
+const {
+ ArrayPrototypePush,
+ MathMax,
+ Number,
+} = primordials;
+
+const {
+ codes: {
+ ERR_ZIP_INVALID_ARCHIVE,
+ ERR_ZIP_UNSUPPORTED_FEATURE,
+ },
+} = require('internal/errors');
+const {
+ BIGINT_MAX_SAFE_INTEGER,
+ SIG_LOCAL_FILE_HEADER,
+ SIG_CENTRAL_FILE_HEADER,
+ SIG_ZIP64_EOCD_RECORD,
+ SIG_ZIP64_EOCD_LOCATOR,
+ SIG_EOCD,
+ MADE_BY_UNIX,
+ SENTINEL16,
+ SENTINEL32,
+ ZIP64_EOCD_MAX_LENGTH,
+ S_IFLNK,
+ S_IFMT,
+} = require('internal/zip/constants');
+const {
+ validateArchiveRange,
+ readSafeUint64,
+} = require('internal/zip/binary');
+const { parseZip64Extra } = require('internal/zip/extra-fields');
+const {
+ decodeDosDateTime,
+ decodeZipName,
+ decodeZipText,
+} = require('internal/zip/dos');
+
+// End of central directory record (sec. 4.3.16).
+// Offset Bytes Description
+// 0 4 Signature = 0x06054b50
+// 4 2 Number of this disk
+// 6 2 Disk where central directory starts
+// 8 2 Number of central directory records on this disk
+// 10 2 Total number of central directory records
+// 12 4 Size of central directory (bytes)
+// 16 4 Offset of start of central directory
+// 20 2 Comment length (n)
+// 22 n Comment
+class CentralEndHeader {
+ #buffer;
+ #offset;
+ constructor(buffer, offset = 0) {
+ validateArchiveRange(buffer, offset, 22, 'end of central directory record');
+ if (buffer.readUInt32LE(offset) !== SIG_EOCD) {
+ throw new ERR_ZIP_INVALID_ARCHIVE('end of central directory signature is invalid');
+ }
+ this.#buffer = buffer;
+ this.#offset = offset;
+ if (offset + this.byteLength > buffer.length) {
+ throw new ERR_ZIP_INVALID_ARCHIVE('end of central directory record is truncated');
+ }
+ }
+ get byteLength() { return 22 + this.commentLength; }
+ get diskNumber() { return this.#buffer.readUInt16LE(this.#offset + 4); }
+ get centralDirectoryDiskNumber() { return this.#buffer.readUInt16LE(this.#offset + 6); }
+ get centralDirectoryDiskRecords() { return this.#buffer.readUInt16LE(this.#offset + 8); }
+ get centralDirectoryTotalRecords() { return this.#buffer.readUInt16LE(this.#offset + 10); }
+ get centralDirectorySize() { return this.#buffer.readUInt32LE(this.#offset + 12); }
+ get centralDirectoryOffset() { return this.#buffer.readUInt32LE(this.#offset + 16); }
+ get commentLength() { return this.#buffer.readUInt16LE(this.#offset + 20); }
+ get commentBuffer() {
+ const start = this.#offset + 22;
+ return this.#buffer.subarray(start, start + this.commentLength);
+ }
+}
+
+// Zip64 end of central directory record (sec. 4.3.14).
+// 0 4 Signature = 0x06064b50
+// 4 8 Size of remainder of this record
+// 12 2 Version made by
+// 14 2 Version needed to extract
+// 16 4 Number of this disk
+// 20 4 Disk where central directory starts
+// 24 8 Number of central directory records on this disk
+// 32 8 Total number of central directory records
+// 40 8 Size of central directory
+// 48 8 Offset of start of central directory
+class Zip64EndRecord {
+ #buffer;
+ #offset;
+ constructor(buffer, offset = 0) {
+ validateArchiveRange(buffer, offset, 56, 'Zip64 end of central directory record');
+ if (buffer.readUInt32LE(offset) !== SIG_ZIP64_EOCD_RECORD) {
+ throw new ERR_ZIP_INVALID_ARCHIVE(
+ 'Zip64 end of central directory signature is invalid');
+ }
+ this.#buffer = buffer;
+ this.#offset = offset;
+ }
+ get diskNumber() { return this.#buffer.readUInt32LE(this.#offset + 16); }
+ get centralDirectoryDiskNumber() { return this.#buffer.readUInt32LE(this.#offset + 20); }
+ get centralDirectoryDiskRecords() { return readSafeUint64(this.#buffer, this.#offset + 24); }
+ get centralDirectoryTotalRecords() { return readSafeUint64(this.#buffer, this.#offset + 32); }
+ get centralDirectorySize() { return readSafeUint64(this.#buffer, this.#offset + 40); }
+ get centralDirectoryOffset() { return readSafeUint64(this.#buffer, this.#offset + 48); }
+}
+
+// Zip64 end of central directory locator (sec. 4.3.15).
+// 0 4 Signature = 0x07064b50
+// 4 4 Disk with the Zip64 end of central directory record
+// 8 8 Offset of the Zip64 end of central directory record
+// 16 4 Total number of disks
+class Zip64EndLocator {
+ #buffer;
+ #offset;
+ constructor(buffer, offset = 0) {
+ validateArchiveRange(buffer, offset, 20, 'Zip64 end of central directory locator');
+ if (buffer.readUInt32LE(offset) !== SIG_ZIP64_EOCD_LOCATOR) {
+ throw new ERR_ZIP_INVALID_ARCHIVE(
+ 'Zip64 end of central directory locator signature is invalid');
+ }
+ this.#buffer = buffer;
+ this.#offset = offset;
+ }
+ get recordDiskNumber() { return this.#buffer.readUInt32LE(this.#offset + 4); }
+ // Spec field (sec. 4.3.15); not consumed as a getter - findArchiveEnd()
+ // reads the offset leniently via readBigUInt64LE instead, so a locator
+ // signature that is really comment data cannot turn into a hard parse
+ // error on an out-of-range value.
+ // get recordOffset() { return readSafeUint64(this.#buffer, this.#offset + 8); }
+ get totalDisks() { return this.#buffer.readUInt32LE(this.#offset + 16); }
+}
+
+// Central directory file header (sec. 4.3.12).
+// 0 4 Signature = 0x02014b50
+// 4 2 Version made by
+// 6 2 Version needed to extract
+// 8 2 General purpose bit flag
+// 10 2 Compression method
+// 12 2 Last modification time
+// 14 2 Last modification date
+// 16 4 CRC-32
+// 20 4 Compressed size
+// 24 4 Uncompressed size
+// 28 2 File name length (n)
+// 30 2 Extra field length (m)
+// 32 2 File comment length (k)
+// 34 2 Disk number where file starts
+// 36 2 Internal file attributes
+// 38 4 External file attributes
+// 42 4 Relative offset of local file header
+// 46 n File name
+// 46+n m Extra field
+// 46+n+m k File comment
+class CentralFileHeader {
+ #buffer;
+ #offset;
+ #zip64 = null;
+ constructor(buffer, offset = 0) {
+ validateArchiveRange(buffer, offset, 46, 'central directory header');
+ if (buffer.readUInt32LE(offset) !== SIG_CENTRAL_FILE_HEADER) {
+ throw new ERR_ZIP_INVALID_ARCHIVE('central directory header signature is invalid');
+ }
+ this.#buffer = buffer;
+ this.#offset = offset;
+ if (offset + this.byteLength > buffer.length) {
+ throw new ERR_ZIP_INVALID_ARCHIVE('central directory header is truncated');
+ }
+ }
+ get byteOffset() { return this.#offset; }
+ get byteLength() {
+ return 46 + this.fileNameLength + this.extraFieldLength + this.fileCommentLength;
+ }
+ get version() { return this.#buffer.readUInt16LE(this.#offset + 4); }
+ // Spec field "version needed to extract" (sec. 4.4.3); not consumed today.
+ // get versionNeeded() { return this.#buffer.readUInt16LE(this.#offset + 6); }
+ get flags() { return this.#buffer.readUInt16LE(this.#offset + 8); }
+ get compressionMethod() { return this.#buffer.readUInt16LE(this.#offset + 10); }
+ get lastModified() {
+ return decodeDosDateTime(
+ this.#buffer.readUInt16LE(this.#offset + 12),
+ this.#buffer.readUInt16LE(this.#offset + 14));
+ }
+ get crc32() { return this.#buffer.readUInt32LE(this.#offset + 16); }
+ // Lazily parse the Zip64 extended-information extra field (sec. 4.5.3),
+ // supplying the true 64-bit values only for the classic fields that hold an
+ // overflow sentinel. Cached across getters.
+ #resolveZip64() {
+ if (this.#zip64 === null) {
+ this.#zip64 = parseZip64Extra(this.extraField, {
+ uncompressedSize: this.#buffer.readUInt32LE(this.#offset + 24) === SENTINEL32,
+ compressedSize: this.#buffer.readUInt32LE(this.#offset + 20) === SENTINEL32,
+ localFileHeaderOffset: this.#buffer.readUInt32LE(this.#offset + 42) === SENTINEL32,
+ diskNumber: this.#buffer.readUInt16LE(this.#offset + 34) === SENTINEL16,
+ });
+ }
+ return this.#zip64;
+ }
+ get compressedSize() {
+ const value = this.#buffer.readUInt32LE(this.#offset + 20);
+ return value === SENTINEL32 ? this.#resolveZip64().compressedSize : value;
+ }
+ get uncompressedSize() {
+ const value = this.#buffer.readUInt32LE(this.#offset + 24);
+ return value === SENTINEL32 ? this.#resolveZip64().uncompressedSize : value;
+ }
+ get fileNameLength() { return this.#buffer.readUInt16LE(this.#offset + 28); }
+ get extraFieldLength() { return this.#buffer.readUInt16LE(this.#offset + 30); }
+ get fileCommentLength() { return this.#buffer.readUInt16LE(this.#offset + 32); }
+ get diskNumber() {
+ const value = this.#buffer.readUInt16LE(this.#offset + 34);
+ return value === SENTINEL16 ? this.#resolveZip64().diskNumber : value;
+ }
+ get internalFileAttributes() { return this.#buffer.readUInt16LE(this.#offset + 36); }
+ get externalFileAttributes() { return this.#buffer.readUInt32LE(this.#offset + 38); }
+ get localFileHeaderOffset() {
+ const value = this.#buffer.readUInt32LE(this.#offset + 42);
+ return value === SENTINEL32 ? this.#resolveZip64().localFileHeaderOffset : value;
+ }
+ get fileNameBuffer() {
+ const start = this.#offset + 46;
+ return this.#buffer.subarray(start, start + this.fileNameLength);
+ }
+ get fileName() { return decodeZipName(this.fileNameBuffer, this.flags, this.extraField); }
+ get extraField() {
+ const start = this.#offset + 46 + this.fileNameLength;
+ return this.#buffer.subarray(start, start + this.extraFieldLength);
+ }
+ get fileCommentBuffer() {
+ const start = this.#offset + 46 + this.fileNameLength + this.extraFieldLength;
+ return this.#buffer.subarray(start, start + this.fileCommentLength);
+ }
+ get fileComment() { return decodeZipText(this.fileCommentBuffer, this.flags); }
+ get isUnixMode() { return (this.version >>> 8) === MADE_BY_UNIX; }
+ get mode() {
+ // Low 12 bits: permissions plus setuid/setgid/sticky (sec. 4.4.15).
+ return this.isUnixMode ? (this.externalFileAttributes >>> 16) & 0o7777 : 0;
+ }
+ get isSymlink() {
+ return this.isUnixMode &&
+ ((this.externalFileAttributes >>> 16) & S_IFMT) === S_IFLNK;
+ }
+}
+
+// Local file header (sec. 4.3.7).
+// 0 4 Signature = 0x04034b50
+// 4 2 Version needed to extract
+// 6 2 General purpose bit flag
+// 8 2 Compression method
+// 10 2 Last modification time
+// 12 2 Last modification date
+// 14 4 CRC-32
+// 18 4 Compressed size
+// 22 4 Uncompressed size
+// 26 2 File name length (n)
+// 28 2 Extra field length (m)
+// 30 n File name
+// 30+n m Extra field
+class LocalFileHeader {
+ #buffer;
+ #offset;
+ constructor(buffer, offset = 0) {
+ validateArchiveRange(buffer, offset, 30, 'local file header');
+ if (buffer.readUInt32LE(offset) !== SIG_LOCAL_FILE_HEADER) {
+ throw new ERR_ZIP_INVALID_ARCHIVE('local file header signature is invalid');
+ }
+ this.#buffer = buffer;
+ this.#offset = offset;
+ if (offset + this.byteLength > buffer.length) {
+ throw new ERR_ZIP_INVALID_ARCHIVE('local file header is truncated');
+ }
+ }
+ get byteLength() { return 30 + this.fileNameLength + this.extraFieldLength; }
+ get flags() { return this.#buffer.readUInt16LE(this.#offset + 6); }
+ // Spec field (sec. 4.4.5); the central directory's method is authoritative,
+ // so the local copy is not consumed today.
+ // get compressionMethod() { return this.#buffer.readUInt16LE(this.#offset + 8); }
+ get fileNameLength() { return this.#buffer.readUInt16LE(this.#offset + 26); }
+ get extraFieldLength() { return this.#buffer.readUInt16LE(this.#offset + 28); }
+ get extraField() {
+ const start = this.#offset + 30 + this.fileNameLength;
+ return this.#buffer.subarray(start, start + this.extraFieldLength);
+ }
+ // The local header also carries the name and modification time (sec. 4.3.7),
+ // but the central directory is authoritative for both - a mismatching local
+ // name is ignored by design - so only the flags and the extra field (which
+ // may hold a higher-resolution timestamp) are consumed here.
+ // get fileName() {
+ // const start = this.#offset + 30;
+ // return decodeZipName(
+ // this.#buffer.subarray(start, start + this.fileNameLength), this.flags, this.extraField);
+ // }
+ // get lastModified() {
+ // return decodeDosDateTime(this.#buffer.readUInt16LE(this.#offset + 10),
+ // this.#buffer.readUInt16LE(this.#offset + 12));
+ // }
+ // Total on-disk length of the local header at `offset` (sec. 4.3.7), read
+ // straight from the length fields without constructing a header; 0 if the
+ // fixed part does not fit. Lets the reader skip to the entry data.
+ static length(buffer, offset) {
+ if (offset + 30 > buffer.length) return 0;
+ return 30 + buffer.readUInt16LE(offset + 26) + buffer.readUInt16LE(offset + 28);
+ }
+}
+
+/**
+ * Locates and validates the end-of-archive structures (EOCD, and the Zip64
+ * EOCD locator/record when present) in `buffer`. `base` is the absolute
+ * offset of `buffer[0]` when `buffer` is only the tail of a larger file; all
+ * returned offsets are absolute. `buffer` must extend to the end of the
+ * archive.
+ *
+ * When a required Zip64 EOCD record starts before `buffer[0]` (its
+ * extensible data sector can push it beyond any fixed-size tail read),
+ * returns `{ needTailFrom }` instead: the caller must retry with a buffer
+ * that starts at that absolute offset (still ending at the end of the
+ * archive). Never returned when `base` is 0.
+ * @returns {{
+ * prefix: number,
+ * totalRecords: number,
+ * centralDirectoryOffset: number,
+ * centralDirectorySize: number,
+ * comment: Buffer,
+ * } | { needTailFrom: number }}
+ */
+function findArchiveEnd(buffer, base = 0) {
+ if (buffer.length < 22) {
+ throw new ERR_ZIP_INVALID_ARCHIVE('no end of central directory record found');
+ }
+ const min = MathMax(0, buffer.length - (22 + SENTINEL16));
+ let eocdPos = -1;
+ // Pass 1: the comment must reach exactly to the end of the buffer (this
+ // rejects a stray EOCD-looking signature inside an earlier comment).
+ for (let pos = buffer.length - 22; pos >= min; pos--) {
+ if (buffer.readUInt32LE(pos) !== SIG_EOCD) continue;
+ if (pos + 22 + buffer.readUInt16LE(pos + 20) !== buffer.length) continue;
+ eocdPos = pos;
+ break;
+ }
+ if (eocdPos < 0) {
+ // Pass 2: tolerate trailing padding after the EOCD (some streaming
+ // writers pad their output to a fixed block size); take the last
+ // candidate found.
+ for (let pos = buffer.length - 22; pos >= min; pos--) {
+ if (buffer.readUInt32LE(pos) !== SIG_EOCD) continue;
+ if (pos + 22 + buffer.readUInt16LE(pos + 20) > buffer.length) continue;
+ eocdPos = pos;
+ break;
+ }
+ }
+ if (eocdPos < 0) {
+ throw new ERR_ZIP_INVALID_ARCHIVE('no end of central directory record found');
+ }
+ const eocd = new CentralEndHeader(buffer, eocdPos);
+ let totalRecords = eocd.centralDirectoryTotalRecords;
+ let centralDirectorySize = eocd.centralDirectorySize;
+ let centralDirectoryOffset = eocd.centralDirectoryOffset;
+ let prefix;
+ // A classic field at its maximum is an overflow sentinel that makes the
+ // Zip64 record mandatory. Otherwise the classic fields are authoritative,
+ // and a Zip64 locator signature in the preceding bytes is not proof of a
+ // Zip64 archive - it may be the tail of a file comment that happens to
+ // contain those four bytes - so a failed Zip64 lookup falls back to the
+ // classic fields instead of rejecting the archive.
+ const needsZip64 =
+ eocd.diskNumber === SENTINEL16 ||
+ eocd.centralDirectoryDiskNumber === SENTINEL16 ||
+ eocd.centralDirectoryDiskRecords === SENTINEL16 ||
+ totalRecords === SENTINEL16 ||
+ centralDirectorySize === SENTINEL32 ||
+ centralDirectoryOffset === SENTINEL32;
+ let zip64 = null;
+ let recordPos = -1;
+ const locatorPos = eocdPos - 20;
+ if (
+ locatorPos >= 0 &&
+ buffer.readUInt32LE(locatorPos) === SIG_ZIP64_EOCD_LOCATOR
+ ) {
+ const locator = new Zip64EndLocator(buffer, locatorPos);
+ // Read the recorded offset leniently: on a coincidental signature these
+ // eight bytes are arbitrary comment data, which must not turn into a
+ // hard parse error.
+ const rawRecordOffset = buffer.readBigUInt64LE(locatorPos + 8);
+ const recordOffset =
+ rawRecordOffset <= BIGINT_MAX_SAFE_INTEGER ? Number(rawRecordOffset) : -1;
+ recordPos = recordOffset >= 0 ? recordOffset - base : -1;
+ if (
+ !(recordPos >= 0 &&
+ recordPos + 56 <= locatorPos &&
+ buffer.readUInt32LE(recordPos) === SIG_ZIP64_EOCD_RECORD)
+ ) {
+ // Data was prepended to the archive, shifting the record; scan
+ // backward from the locator instead of trusting its recorded offset.
+ recordPos = -1;
+ const floor = MathMax(0, locatorPos - 56 - SENTINEL16);
+ for (let pos = locatorPos - 56; pos >= floor; pos--) {
+ if (buffer.readUInt32LE(pos) !== SIG_ZIP64_EOCD_RECORD) continue;
+ const size = buffer.readBigUInt64LE(pos + 4);
+ if (size >= 44n && pos + 12 + Number(size) === locatorPos) {
+ recordPos = pos;
+ break;
+ }
+ }
+ }
+ if (recordPos < 0 && needsZip64) {
+ // The record is required but does not lie inside `buffer`: its
+ // extensible data sector may extend it beyond any fixed-size tail
+ // read. When the locator points (plausibly) before the bytes at
+ // hand, ask the caller for a longer tail instead of failing.
+ if (
+ recordOffset >= 0 &&
+ recordOffset < base &&
+ base + locatorPos - recordOffset <= ZIP64_EOCD_MAX_LENGTH
+ ) {
+ return { needTailFrom: recordOffset };
+ }
+ throw new ERR_ZIP_INVALID_ARCHIVE('Zip64 end of central directory record not found');
+ }
+ if (recordPos >= 0) {
+ if (locator.totalDisks > 1 || locator.recordDiskNumber !== 0) {
+ throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported');
+ }
+ zip64 = new Zip64EndRecord(buffer, recordPos);
+ }
+ }
+ // The `prefix` math assumes nothing sits between the central directory and
+ // the archive-end records. APPNOTE sec. 4.3.13 allows a digital-signature
+ // record there; such an archive shifts `prefix` by the signature's length
+ // and fails with a central-directory signature error rather than a targeted
+ // message - signed archives are essentially extinct, so none is emitted.
+ if (zip64 !== null) {
+ if (zip64.diskNumber !== 0 || zip64.centralDirectoryDiskNumber !== 0) {
+ throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported');
+ }
+ if (zip64.centralDirectoryDiskRecords !== zip64.centralDirectoryTotalRecords) {
+ throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported');
+ }
+ totalRecords = zip64.centralDirectoryTotalRecords;
+ centralDirectorySize = zip64.centralDirectorySize;
+ centralDirectoryOffset = zip64.centralDirectoryOffset;
+ prefix = base + recordPos - (centralDirectoryOffset + centralDirectorySize);
+ } else {
+ if (eocd.diskNumber !== 0 || eocd.centralDirectoryDiskNumber !== 0) {
+ throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported');
+ }
+ if (eocd.centralDirectoryDiskRecords !== totalRecords) {
+ throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported');
+ }
+ prefix = base + eocdPos - (centralDirectoryOffset + centralDirectorySize);
+ }
+ if (prefix < 0) {
+ throw new ERR_ZIP_INVALID_ARCHIVE('central directory does not fit inside the archive');
+ }
+ if (totalRecords * 46 > centralDirectorySize) {
+ throw new ERR_ZIP_INVALID_ARCHIVE(
+ 'central directory record count is inconsistent with its size');
+ }
+ return {
+ prefix,
+ totalRecords,
+ centralDirectoryOffset: centralDirectoryOffset + prefix,
+ centralDirectorySize,
+ comment: eocd.commentBuffer,
+ };
+}
+
+// Walk the contiguous run of `count` central directory file headers
+// (sec. 4.3.12) into an array, rejecting multi-disk archives.
+function readCentralDirectory(buffer, count) {
+ const result = [];
+ let pos = 0;
+ for (let index = 0; index < count; index++) {
+ const header = new CentralFileHeader(buffer, pos);
+ if (header.diskNumber !== 0) {
+ throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported');
+ }
+ ArrayPrototypePush(result, header);
+ pos += header.byteLength;
+ }
+ return result;
+}
+
+module.exports = {
+ CentralFileHeader,
+ LocalFileHeader,
+ findArchiveEnd,
+ readCentralDirectory,
+};
diff --git a/lib/zlib.js b/lib/zlib.js
index fc970c306dc437..d0cea8fd4069b1 100644
--- a/lib/zlib.js
+++ b/lib/zlib.js
@@ -71,6 +71,16 @@ const {
validateFiniteNumber,
} = require('internal/validators');
const { FastBuffer } = require('internal/buffer');
+const {
+ ZipEntry,
+ ZipFile,
+ ZipBuffer,
+ createZipArchive,
+ createZipArchiveSync,
+ zipFiles,
+ getMaxZipContentSize,
+ setMaxZipContentSize,
+} = require('internal/zip');
const kFlushFlag = Symbol('kFlushFlag');
const kError = Symbol('kError');
@@ -1016,6 +1026,16 @@ module.exports = {
ZstdCompress,
ZstdDecompress,
+ // ZIP archive support.
+ ZipEntry,
+ ZipFile,
+ ZipBuffer,
+ createZipArchive,
+ createZipArchiveSync,
+ zipFiles,
+ getMaxZipContentSize,
+ setMaxZipContentSize,
+
// Convenience methods.
// compress/decompress a string or buffer in one step.
deflate: createConvenienceMethod(Deflate, false),
diff --git a/test/parallel/test-zlib-zip-coverage.js b/test/parallel/test-zlib-zip-coverage.js
new file mode 100644
index 00000000000000..c4be9648a927a2
--- /dev/null
+++ b/test/parallel/test-zlib-zip-coverage.js
@@ -0,0 +1,1314 @@
+'use strict';
+
+require('../common');
+
+const assert = require('node:assert');
+const tmpdir = require('../common/tmpdir');
+const fs = require('node:fs/promises');
+const zlib = require('node:zlib');
+const { test } = require('node:test');
+
+tmpdir.refresh();
+
+// Additional zip.js coverage beyond the other test-zlib-zip-*.js files:
+// DOS date/time edge cases, the Zip64 extra-field parser's normal and
+// out-of-range paths, the "data was prepended to the archive" central
+// directory recovery scan, buffer-coercion variants, entry-metadata
+// validation, streaming-entry state guards, the ZipBuffer/ZipFile
+// iteration protocols, and several ZipFile (on-disk) error paths that
+// aren't reachable through ZipBuffer alone.
+
+async function buildArchive(entries, comment) {
+ const chunks = [];
+ for await (const chunk of zlib.createZipArchive(entries, comment)) chunks.push(chunk);
+ return Buffer.concat(chunks);
+}
+
+async function drain(iterable) {
+ const chunks = [];
+ for await (const chunk of iterable) chunks.push(chunk);
+ return Buffer.concat(chunks);
+}
+
+// -- DOS date/time -----------------------------------------------------------
+
+test('a zeroed DOS date/time field decodes to the 1980-01-01 epoch', async () => {
+ const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hi'), { method: 'store' });
+ const archive = await buildArchive([entry]);
+ const tampered = Buffer.from(archive);
+ tampered.writeUInt16LE(0, 10); // local time
+ tampered.writeUInt16LE(0, 12); // local date
+ const centralStart = 30 + 'f.txt'.length + 'hi'.length;
+ tampered.writeUInt16LE(0, centralStart + 12); // central time
+ tampered.writeUInt16LE(0, centralStart + 14); // central date
+
+ const [read] = zlib.ZipEntry.read(tampered);
+ assert.strictEqual(read.modified.getTime(), new Date(1980, 0, 1, 0, 0, 0).getTime());
+});
+
+test('serializing an entry with an invalid modified Date is rejected', async () => {
+ const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hi'), { modified: new Date(NaN) });
+ await assert.rejects(buildArchive([entry]), { code: 'ERR_INVALID_ARG_VALUE' });
+});
+
+// -- Zip64 structures ---------------------------------------------------------
+
+function buildZip64Record({ diskNumber = 0, cdDiskNumber = 0, cdDiskRecords = 0n,
+ cdTotalRecords = 0n, cdSize = 0n, cdOffset = 0n } = {}) {
+ const buf = Buffer.allocUnsafe(56);
+ buf.writeUInt32LE(0x06064b50, 0);
+ buf.writeBigUInt64LE(44n, 4);
+ buf.writeUInt16LE((3 << 8) | 45, 12); // Made by Unix, version 4.5
+ buf.writeUInt16LE(45, 14);
+ buf.writeUInt32LE(diskNumber, 16);
+ buf.writeUInt32LE(cdDiskNumber, 20);
+ buf.writeBigUInt64LE(cdDiskRecords, 24);
+ buf.writeBigUInt64LE(cdTotalRecords, 32);
+ buf.writeBigUInt64LE(cdSize, 40);
+ buf.writeBigUInt64LE(cdOffset, 48);
+ return buf;
+}
+
+function buildZip64Locator({ recordDiskNumber = 0, recordOffset = 0n, totalDisks = 1 } = {}) {
+ const buf = Buffer.allocUnsafe(20);
+ buf.writeUInt32LE(0x07064b50, 0);
+ buf.writeUInt32LE(recordDiskNumber, 4);
+ buf.writeBigUInt64LE(recordOffset, 8);
+ buf.writeUInt32LE(totalDisks, 16);
+ return buf;
+}
+
+function buildEocd({ diskNumber = 0, cdDiskNumber = 0, cdDiskRecords = 0,
+ totalRecords = 0, cdSize = 0, cdOffset = 0, comment = Buffer.alloc(0) } = {}) {
+ const buf = Buffer.allocUnsafe(22 + comment.length);
+ buf.writeUInt32LE(0x06054b50, 0);
+ buf.writeUInt16LE(diskNumber, 4);
+ buf.writeUInt16LE(cdDiskNumber, 6);
+ buf.writeUInt16LE(cdDiskRecords, 8);
+ buf.writeUInt16LE(totalRecords, 10);
+ buf.writeUInt32LE(cdSize, 12);
+ buf.writeUInt32LE(cdOffset, 16);
+ buf.writeUInt16LE(comment.length, 20);
+ comment.copy(buf, 22);
+ return buf;
+}
+
+// A minimal (zero-entry) Zip64 archive: record + locator + classic EOCD,
+// with the locator pointing directly at the record.
+function buildMinimalZip64Archive({ record, locator, eocd } = {}) {
+ return Buffer.concat([
+ buildZip64Record(record),
+ buildZip64Locator({ recordOffset: 0n, ...locator }),
+ buildEocd(eocd),
+ ]);
+}
+
+test('a well-formed minimal Zip64 archive round-trips its comment', () => {
+ const buf = buildMinimalZip64Archive({ eocd: { comment: Buffer.from('hi') } });
+ const zip = new zlib.ZipBuffer(buf);
+ assert.strictEqual(zip.size, 0);
+ assert.strictEqual(zip.comment, 'hi');
+});
+
+test('a Zip64 locator declaring more than one disk is rejected', () => {
+ const buf = buildMinimalZip64Archive({ locator: { totalDisks: 2 } });
+ assert.throws(() => [...zlib.ZipEntry.read(buf)], { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' });
+});
+
+test('a Zip64 locator pointing at another disk is rejected', () => {
+ const buf = buildMinimalZip64Archive({ locator: { recordDiskNumber: 1 } });
+ assert.throws(() => [...zlib.ZipEntry.read(buf)], { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' });
+});
+
+test('a Zip64 record on another disk is rejected', () => {
+ const buf = buildMinimalZip64Archive({ record: { diskNumber: 1 } });
+ assert.throws(() => [...zlib.ZipEntry.read(buf)], { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' });
+});
+
+test('a Zip64 record with inconsistent disk record counts is rejected', () => {
+ const buf = buildMinimalZip64Archive({ record: { cdDiskRecords: 1n, cdTotalRecords: 2n } });
+ assert.throws(() => [...zlib.ZipEntry.read(buf)], { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' });
+});
+
+test('a Zip64 record is found by scanning backward when data was prepended', () => {
+ // The locator's declared offset is wrong (as if the archive had been
+ // prepended with extra bytes, e.g. a self-extractor stub, after the
+ // record/locator were written), but the record still physically sits
+ // immediately before the locator; findArchiveEnd() must recover it.
+ const buf = buildMinimalZip64Archive({
+ locator: { recordOffset: 999_999n },
+ eocd: { comment: Buffer.from('recovered') },
+ });
+ const zip = new zlib.ZipBuffer(buf);
+ assert.strictEqual(zip.comment, 'recovered');
+});
+
+test('a required Zip64 record that cannot be found anywhere is rejected', () => {
+ // An EOCD overflow sentinel makes the Zip64 record mandatory; with the
+ // record's signature corrupted and the locator pointing nowhere useful,
+ // the archive is unreadable.
+ const buf = buildMinimalZip64Archive({
+ locator: { recordOffset: 999_999n },
+ eocd: { totalRecords: 0xffff, cdDiskRecords: 0xffff },
+ });
+ buf.writeUInt32LE(0xdeadbeef, 0); // Also corrupt the record actually present
+ assert.throws(() => [...zlib.ZipEntry.read(buf)], {
+ code: 'ERR_ZIP_INVALID_ARCHIVE',
+ message: /Zip64 end of central directory record not found/,
+ });
+});
+
+test('a missing Zip64 record falls back to valid classic EOCD fields', () => {
+ // Same corrupted record, but no EOCD field carries an overflow sentinel:
+ // the classic fields fully describe the (empty) archive, so the stray
+ // locator signature - indistinguishable from comment bytes that happen to
+ // contain it - must not reject an otherwise valid archive.
+ const buf = buildMinimalZip64Archive({ locator: { recordOffset: 999_999n } });
+ buf.writeUInt32LE(0xdeadbeef, 0);
+ assert.strictEqual([...zlib.ZipEntry.read(buf)].length, 0);
+});
+
+// Patches a single-entry, single-record archive's central header to carry a
+// synthetic Zip64 extra field for whichever of {uncompressedSize,
+// compressedSize, localFileHeaderOffset, diskNumber} are provided, setting
+// the corresponding 32-/16-bit field to the sentinel value that tells
+// CentralFileHeader to resolve it from the extra field instead. A leading,
+// unrelated TLV record is always included ahead of the Zip64 one, so the
+// "skip past a foreign record" loop iteration is exercised too.
+function injectZip64Extra(archive, name, contentLength, fields) {
+ const centralHeaderStart = 30 + name.length + contentLength;
+ const nameStart = centralHeaderStart + 46;
+ const extraLengthOffset = centralHeaderStart + 30;
+ assert.strictEqual(archive.readUInt16LE(extraLengthOffset), 0); // sanity: no existing extra
+
+ const dummyTlv = Buffer.from([0x99, 0x99, 0x04, 0x00, 0xde, 0xad, 0xbe, 0xef]);
+ const order = ['uncompressedSize', 'compressedSize', 'localFileHeaderOffset', 'diskNumber'];
+ const dataLength = order.reduce(
+ (total, key) => total + (key in fields ? (key === 'diskNumber' ? 4 : 8) : 0), 0);
+ const zip64Tlv = Buffer.allocUnsafe(4 + dataLength);
+ zip64Tlv.writeUInt16LE(0x0001, 0);
+ zip64Tlv.writeUInt16LE(dataLength, 2);
+ let pos = 4;
+ for (const key of order) {
+ if (!(key in fields)) continue;
+ if (key === 'diskNumber') {
+ zip64Tlv.writeUInt32LE(Number(fields[key]), pos);
+ pos += 4;
+ } else {
+ zip64Tlv.writeBigUInt64LE(BigInt(fields[key]), pos);
+ pos += 8;
+ }
+ }
+ const extra = Buffer.concat([dummyTlv, zip64Tlv]);
+
+ const before = archive.subarray(0, nameStart + name.length);
+ const after = archive.subarray(nameStart + name.length); // comment + EOCD
+ const patched = Buffer.concat([before, extra, after]);
+
+ patched.writeUInt16LE(extra.length, extraLengthOffset);
+ if ('uncompressedSize' in fields) patched.writeUInt32LE(0xffffffff, centralHeaderStart + 24);
+ if ('compressedSize' in fields) patched.writeUInt32LE(0xffffffff, centralHeaderStart + 20);
+ if ('localFileHeaderOffset' in fields) patched.writeUInt32LE(0xffffffff, centralHeaderStart + 42);
+ if ('diskNumber' in fields) patched.writeUInt16LE(0xffff, centralHeaderStart + 34);
+
+ const eocdOffset = patched.length - 22;
+ assert.strictEqual(patched.readUInt32LE(eocdOffset), 0x06054b50);
+ const oldCdSize = patched.readUInt32LE(eocdOffset + 12);
+ patched.writeUInt32LE(oldCdSize + extra.length, eocdOffset + 12);
+
+ return patched;
+}
+
+test('a foreign Zip64 extra field naming only the fields it needs still resolves', async () => {
+ const name = 'f.txt';
+ const content = Buffer.from('hello');
+ const entry = await zlib.ZipEntry.create(name, content, { method: 'store' });
+ const archive = await buildArchive([entry]);
+
+ const patched = injectZip64Extra(archive, name, content.length, {
+ uncompressedSize: content.length,
+ compressedSize: content.length,
+ localFileHeaderOffset: 0,
+ diskNumber: 0,
+ });
+
+ const [read] = zlib.ZipEntry.read(patched);
+ assert.strictEqual(read.size, content.length);
+ assert.strictEqual(read.compressedSize, content.length);
+ assert.strictEqual((await read.content()).toString(), 'hello');
+});
+
+test('a foreign Zip64 extra field carrying every field regardless of sentinels still resolves', async () => {
+ const name = 'f.txt';
+ const content = Buffer.from('hello');
+ const entry = await zlib.ZipEntry.create(name, content, { method: 'store' });
+ const archive = await buildArchive([entry]);
+
+ // APPNOTE 4.5.3 says Zip64 fields MUST appear only for the classic fields
+ // holding the overflow sentinel, but plenty of real writers emit all four
+ // regardless. Only the compressed size is a sentinel here, so a strictly
+ // packed parse would misread the uncompressed-size slot as the compressed
+ // size; the parser must fall back to the full fixed layout instead.
+ const centralHeaderStart = 30 + name.length + content.length;
+ const nameStart = centralHeaderStart + 46;
+ const tlv = Buffer.allocUnsafe(4 + 28);
+ tlv.writeUInt16LE(0x0001, 0);
+ tlv.writeUInt16LE(28, 2);
+ tlv.writeBigUInt64LE(0x1111n, 4); // Uncompressed size (classic field wins)
+ tlv.writeBigUInt64LE(BigInt(content.length), 12); // Compressed size
+ tlv.writeBigUInt64LE(0n, 20); // Local header offset (classic field wins)
+ tlv.writeUInt32LE(0, 28); // Disk number (classic field wins)
+ const before = archive.subarray(0, nameStart + name.length);
+ const after = archive.subarray(nameStart + name.length);
+ const patched = Buffer.concat([before, tlv, after]);
+ patched.writeUInt16LE(tlv.length, centralHeaderStart + 30);
+ patched.writeUInt32LE(0xffffffff, centralHeaderStart + 20); // compressedSize sentinel only
+ const eocdOffset = patched.length - 22;
+ patched.writeUInt32LE(patched.readUInt32LE(eocdOffset + 12) + tlv.length, eocdOffset + 12);
+
+ const [read] = zlib.ZipEntry.read(patched);
+ assert.strictEqual(read.compressedSize, content.length);
+ assert.strictEqual(read.size, content.length);
+ assert.strictEqual((await read.content()).toString(), 'hello');
+
+ // Re-serialization drops the (now stale) Zip64 record - Zip64 data is
+ // regenerated from the final sizes - and the entry stays readable.
+ const rewritten = await buildArchive([read]);
+ const [reread] = zlib.ZipEntry.read(rewritten);
+ assert.strictEqual((await reread.content()).toString(), 'hello');
+});
+
+test('a Zip64 extra field value beyond Number.MAX_SAFE_INTEGER is rejected', async () => {
+ const name = 'f.txt';
+ const content = Buffer.from('hello');
+ const entry = await zlib.ZipEntry.create(name, content, { method: 'store' });
+ const archive = await buildArchive([entry]);
+
+ const patched = injectZip64Extra(archive, name, content.length, {
+ uncompressedSize: 0xffffffffffffffffn,
+ });
+
+ const [read] = zlib.ZipEntry.read(patched);
+ assert.throws(() => read.size, {
+ code: 'ERR_ZIP_INVALID_ARCHIVE',
+ message: /exceeds the safe integer range/,
+ });
+});
+
+// Injects a raw, already-built Zip64 extra-field TLV (rather than one built
+// via injectZip64Extra()'s field map), to exercise the parser's own
+// malformed/truncated-input rejections.
+function injectRawZip64Extra(archive, name, contentLength, extraBytes) {
+ const centralHeaderStart = 30 + name.length + contentLength;
+ const nameStart = centralHeaderStart + 46;
+ const extraLengthOffset = centralHeaderStart + 30;
+ assert.strictEqual(archive.readUInt16LE(extraLengthOffset), 0);
+ const before = archive.subarray(0, nameStart + name.length);
+ const after = archive.subarray(nameStart + name.length);
+ const patched = Buffer.concat([before, extraBytes, after]);
+ patched.writeUInt16LE(extraBytes.length, extraLengthOffset);
+ patched.writeUInt32LE(0xffffffff, centralHeaderStart + 24); // uncompressedSize sentinel
+ const eocdOffset = patched.length - 22;
+ const oldCdSize = patched.readUInt32LE(eocdOffset + 12);
+ patched.writeUInt32LE(oldCdSize + extraBytes.length, eocdOffset + 12);
+ return patched;
+}
+
+test('a Zip64 extra-field TLV whose declared size overflows the extra field is rejected', async () => {
+ const name = 'f.txt';
+ const content = Buffer.from('hello');
+ const entry = await zlib.ZipEntry.create(name, content, { method: 'store' });
+ const archive = await buildArchive([entry]);
+
+ const tlv = Buffer.allocUnsafe(8);
+ tlv.writeUInt16LE(0x0001, 0);
+ tlv.writeUInt16LE(100, 2); // claims 100 bytes of data, but none follow
+ const patched = injectRawZip64Extra(archive, name, content.length, tlv);
+
+ const [read] = zlib.ZipEntry.read(patched);
+ assert.throws(() => read.size, {
+ code: 'ERR_ZIP_INVALID_ARCHIVE',
+ message: /extra field is malformed/,
+ });
+});
+
+test('a Zip64 extra-field TLV too short for the field it claims to carry is rejected', async () => {
+ const name = 'f.txt';
+ const content = Buffer.from('hello');
+ const entry = await zlib.ZipEntry.create(name, content, { method: 'store' });
+ const archive = await buildArchive([entry]);
+
+ // Declares only 4 bytes of data, but a sentinel uncompressedSize needs 8.
+ const tlv = Buffer.allocUnsafe(8);
+ tlv.writeUInt16LE(0x0001, 0);
+ tlv.writeUInt16LE(4, 2);
+ tlv.writeUInt32LE(123, 4);
+ const patched = injectRawZip64Extra(archive, name, content.length, tlv);
+
+ const [read] = zlib.ZipEntry.read(patched);
+ assert.throws(() => read.size, {
+ code: 'ERR_ZIP_INVALID_ARCHIVE',
+ message: /Zip64 extended information extra field is truncated/,
+ });
+});
+
+// -- buffer coercion -----------------------------------------------------------
+
+test('create() accepts a DataView, a non-Uint8Array TypedArray, and an ArrayBuffer', async () => {
+ const ab = new ArrayBuffer(4);
+ new Uint8Array(ab).set([1, 2, 3, 4]);
+
+ const dv = new DataView(ab, 1, 2);
+ const fromDataView = await zlib.ZipEntry.create('dv.bin', dv);
+ assert.strictEqual((await fromDataView.content()).length, 2);
+
+ const i32 = new Int32Array([10, 20, 30]);
+ const fromTypedArray = await zlib.ZipEntry.create('i32.bin', i32);
+ assert.strictEqual((await fromTypedArray.content()).length, 12);
+
+ const fromArrayBuffer = await zlib.ZipEntry.create('ab.bin', ab);
+ assert.strictEqual((await fromArrayBuffer.content()).length, 4);
+});
+
+// -- entry-metadata validation -------------------------------------------------
+
+test('create() validates comment length, modified type, and method value', async () => {
+ await assert.rejects(
+ zlib.ZipEntry.create('f.txt', Buffer.alloc(0), { comment: 'x'.repeat(70000) }),
+ { code: 'ERR_ZIP_ENTRY_TOO_LARGE' },
+ );
+ await assert.rejects(
+ zlib.ZipEntry.create('f.txt', Buffer.alloc(0), { modified: 123 }),
+ { code: 'ERR_INVALID_ARG_TYPE' },
+ );
+ await assert.rejects(
+ zlib.ZipEntry.create('f.txt', Buffer.alloc(0), { method: 'bogus' }),
+ { code: 'ERR_INVALID_ARG_VALUE' },
+ );
+});
+
+test('a directory entry must have empty content, for create(), createSync(), and createStream()', async () => {
+ await assert.rejects(
+ zlib.ZipEntry.create('dir/', Buffer.from('x')), { code: 'ERR_INVALID_ARG_VALUE' });
+ assert.throws(
+ () => zlib.ZipEntry.createSync('dir/', Buffer.from('x')), { code: 'ERR_INVALID_ARG_VALUE' });
+ assert.throws(
+ () => zlib.ZipEntry.createStream('dir/', (async function* () {})()), { code: 'ERR_INVALID_ARG_VALUE' });
+});
+
+test('createZipArchive()/createZipArchiveSync() validate the archive comment length', async () => {
+ await assert.rejects(drain(zlib.createZipArchive([], 'x'.repeat(70000))),
+ { code: 'ERR_ZIP_ARCHIVE_TOO_LARGE' });
+ assert.throws(() => [...zlib.createZipArchiveSync([], 'x'.repeat(70000))],
+ { code: 'ERR_ZIP_ARCHIVE_TOO_LARGE' });
+});
+
+// -- streaming-entry state guards ----------------------------------------------
+
+test('a pending streaming entry rejects size/crc32/compressedSize/content access', () => {
+ const streaming = zlib.ZipEntry.createStream(
+ 'big.bin', (async function* () { yield Buffer.from('x'); })());
+ assert.throws(() => streaming.size, { code: 'ERR_INVALID_STATE' });
+ assert.throws(() => streaming.crc32, { code: 'ERR_INVALID_STATE' });
+ assert.throws(() => streaming.compressedSize, { code: 'ERR_INVALID_STATE' });
+ assert.throws(() => streaming.contentIterator(), { code: 'ERR_INVALID_STATE' });
+ assert.throws(() => streaming.contentSync(), { code: 'ERR_INVALID_STATE' });
+});
+
+// Once a streaming entry has been serialized on its own (via createZipArchive,
+// not into a writable ZipFile that would promote it), its source is spent and
+// there is nothing to read back. Reads must fail with a clean state error, not
+// silently decode an empty buffer and report ERR_ZIP_ENTRY_CORRUPT.
+test('a spent (serialized-but-unpromoted) streaming entry rejects reads cleanly', async () => {
+ const entry = zlib.ZipEntry.createStream('s.txt', (async function* () { yield Buffer.from('hello'); })());
+ await drain(entry);
+ await assert.rejects(entry.content(), { code: 'ERR_INVALID_STATE' });
+ assert.throws(() => entry.contentSync(), { code: 'ERR_INVALID_STATE' });
+ assert.throws(() => entry.contentIterator(), { code: 'ERR_INVALID_STATE' });
+});
+
+test('a streaming entry can only be serialized once', async () => {
+ const entry = zlib.ZipEntry.createStream('a.bin', (async function* () { yield Buffer.from('x'); })());
+ await drain(entry);
+ await assert.rejects(drain(entry), { code: 'ERR_INVALID_STATE' });
+});
+
+test('a streaming entry rejects a non-Uint8Array chunk from its source', async () => {
+ async function* badSource() {
+ yield Buffer.alloc(0); // An empty chunk is silently skipped
+ yield 'not a buffer';
+ }
+ const entry = zlib.ZipEntry.createStream('b.bin', badSource());
+ await assert.rejects(drain(entry), { code: 'ERR_INVALID_ARG_TYPE' });
+});
+
+test('a streaming entry can use zstd compression end-to-end', async () => {
+ const payload = 'zstd stream content '.repeat(50);
+ async function* source() { yield Buffer.from(payload); }
+ const entry = zlib.ZipEntry.createStream('c.bin', source(), { method: 'zstd' });
+ const archive = await buildArchive([entry]);
+
+ const [read] = zlib.ZipEntry.read(archive);
+ assert.strictEqual(read.method, 93);
+ assert.strictEqual((await read.content()).toString(), payload);
+});
+
+test('an error from a streaming entry\'s source propagates and cleans up (deflate and zstd)', async () => {
+ for (const method of ['deflate', 'zstd']) {
+ async function* badSource() {
+ yield Buffer.from('some data before the error');
+ throw new Error(`source blew up (${method})`);
+ }
+ const entry = zlib.ZipEntry.createStream('big.bin', badSource(), { method });
+ await assert.rejects(drain(entry), { message: `source blew up (${method})` });
+ }
+});
+
+// -- contentIterator() / decodeMemberStream() error paths ------------------------
+
+test('contentIterator() enforces the same guards as content() and contentSync()', async () => {
+ // Encrypted.
+ {
+ const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('secret'), { method: 'store' });
+ const archive = await buildArchive([entry]);
+ const tampered = Buffer.from(archive);
+ tampered.writeUInt16LE(tampered.readUInt16LE(6) | 0x0001, 6);
+ const centralStart = 30 + 'f.txt'.length + 'secret'.length;
+ tampered.writeUInt16LE(tampered.readUInt16LE(centralStart + 8) | 0x0001, centralStart + 8);
+ const [read] = zlib.ZipEntry.read(tampered);
+ await assert.rejects(drain(read.contentIterator()), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' });
+ }
+ // Unsupported compression method.
+ {
+ const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hi'), { method: 'store' });
+ const archive = await buildArchive([entry]);
+ const tampered = Buffer.from(archive);
+ tampered.writeUInt16LE(1, 8);
+ const centralStart = 30 + 'f.txt'.length + 'hi'.length;
+ tampered.writeUInt16LE(1, centralStart + 10);
+ const [read] = zlib.ZipEntry.read(tampered);
+ await assert.rejects(drain(read.contentIterator()), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' });
+ }
+ // maxSize enforced up front.
+ {
+ const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello world'));
+ await assert.rejects(drain(entry.contentIterator({ maxSize: 1 })), { code: 'ERR_ZIP_ENTRY_TOO_LARGE' });
+ }
+ // Declared-size mismatch.
+ {
+ const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello world'), { method: 'store' });
+ const archive = await buildArchive([entry]);
+ const tampered = Buffer.from(archive);
+ const centralStart = 30 + 'f.txt'.length + 'hello world'.length;
+ tampered.writeUInt32LE(1, centralStart + 24);
+ const [read] = zlib.ZipEntry.read(tampered);
+ await assert.rejects(drain(read.contentIterator()), { code: 'ERR_ZIP_ENTRY_CORRUPT' });
+ }
+ // CRC-32 mismatch, and disabling verification.
+ {
+ const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello world'), { method: 'store' });
+ const archive = await buildArchive([entry]);
+ const tampered = Buffer.from(archive);
+ tampered[30 + 'f.txt'.length] ^= 0xff;
+ const [read] = zlib.ZipEntry.read(tampered);
+ await assert.rejects(drain(read.contentIterator()), { code: 'ERR_ZIP_ENTRY_CORRUPT' });
+ const unverified = await drain(read.contentIterator({ verify: false }));
+ assert.strictEqual(unverified.length, 'hello world'.length);
+ }
+});
+
+// -- content()/contentSync() zstd-specific branches ----------------------------
+
+test('content() and contentSync() enforce maxSize and detect corruption for zstd entries', async () => {
+ {
+ const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('y'.repeat(5000)), { method: 'zstd' });
+ const archive = await buildArchive([entry]);
+ const [read] = zlib.ZipEntry.read(archive);
+ assert.strictEqual(read.method, 93);
+ await assert.rejects(read.content({ maxSize: 10 }), { code: 'ERR_ZIP_ENTRY_TOO_LARGE' });
+ }
+ {
+ const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('z'.repeat(200)), { method: 'zstd' });
+ const archive = await buildArchive([entry]);
+ const tampered = Buffer.from(archive);
+ const contentStart = 30 + 'f.txt'.length;
+ tampered.fill(0xff, contentStart, contentStart + 4); // Break the zstd frame itself
+ const [read] = zlib.ZipEntry.read(tampered);
+ await assert.rejects(read.content(), { code: 'ERR_ZIP_ENTRY_CORRUPT' });
+ assert.throws(() => read.contentSync(), { code: 'ERR_ZIP_ENTRY_CORRUPT' });
+ }
+});
+
+// `decodeMemberSync()` (used by `ZipEntry.prototype.contentSync()`) duplicates
+// `decodeMemberStream()`'s guards for its own, separate synchronous code
+// path; exercise them through a disk-backed ZipFile.
+test('ZipFile getSync().contentSync() enforces the same guards via decodeMemberSync()', async () => {
+ async function writeTempArchive(archive, suffix) {
+ const filePath = tmpdir.resolve(`zip-coverage-contentsync-${suffix}.zip`);
+ await fs.writeFile(filePath, archive);
+ return filePath;
+ }
+
+ // Encrypted.
+ {
+ const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('secret'), { method: 'store' });
+ const archive = await buildArchive([entry]);
+ const tampered = Buffer.from(archive);
+ tampered.writeUInt16LE(tampered.readUInt16LE(6) | 0x0001, 6);
+ const centralStart = 30 + 'f.txt'.length + 'secret'.length;
+ tampered.writeUInt16LE(tampered.readUInt16LE(centralStart + 8) | 0x0001, centralStart + 8);
+ const filePath = await writeTempArchive(tampered, 'encrypted');
+ const zf = zlib.ZipFile.openSync(filePath);
+ assert.throws(() => zf.getSync('f.txt').contentSync(), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' });
+ zf.closeSync();
+ await fs.unlink(filePath);
+ }
+ // maxSize, for both the deflate and the zstd decode branch.
+ for (const method of ['deflate', 'zstd']) {
+ const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('y'.repeat(5000)), { method });
+ const archive = await buildArchive([entry]);
+ const filePath = await writeTempArchive(archive, `maxsize-${method}`);
+ const zf = zlib.ZipFile.openSync(filePath);
+ assert.throws(() => zf.getSync('f.txt').contentSync({ maxSize: 10 }), { code: 'ERR_ZIP_ENTRY_TOO_LARGE' });
+ zf.closeSync();
+ await fs.unlink(filePath);
+ }
+ // A genuine decompression failure (not just a CRC mismatch after a
+ // successful decode).
+ {
+ const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('y'.repeat(500)), { method: 'deflate' });
+ const archive = await buildArchive([entry]);
+ const tampered = Buffer.from(archive);
+ const contentStart = 30 + 'f.txt'.length;
+ tampered.fill(0xff, contentStart, contentStart + 4);
+ const filePath = await writeTempArchive(tampered, 'deflate-corrupt');
+ const zf = zlib.ZipFile.openSync(filePath);
+ assert.throws(() => zf.getSync('f.txt').contentSync(), { code: 'ERR_ZIP_ENTRY_CORRUPT' });
+ zf.closeSync();
+ await fs.unlink(filePath);
+ }
+ // Declared-size mismatch ("produced N bytes, expected M").
+ {
+ const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello world'), { method: 'store' });
+ const archive = await buildArchive([entry]);
+ const tampered = Buffer.from(archive);
+ const centralStart = 30 + 'f.txt'.length + 'hello world'.length;
+ tampered.writeUInt32LE(1, centralStart + 24);
+ const filePath = await writeTempArchive(tampered, 'size-mismatch');
+ const zf = zlib.ZipFile.openSync(filePath);
+ assert.throws(() => zf.getSync('f.txt').contentSync(), { code: 'ERR_ZIP_ENTRY_CORRUPT' });
+ zf.closeSync();
+ await fs.unlink(filePath);
+ }
+});
+
+test('contentIterator() rejects an entry that inflates to less than its declared size', async () => {
+ const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hi'), { method: 'store' });
+ const archive = await buildArchive([entry]);
+ const tampered = Buffer.from(archive);
+ const centralStart = 30 + 'f.txt'.length + 'hi'.length;
+ tampered.writeUInt32LE(1000, centralStart + 24); // Declared size grown beyond reality
+ const [read] = zlib.ZipEntry.read(tampered);
+ await assert.rejects(drain(read.contentIterator()), {
+ code: 'ERR_ZIP_ENTRY_CORRUPT',
+ message: /is truncated/,
+ });
+});
+
+// -- ZipBuffer / ZipFile iteration protocols -----------------------------------
+
+test('ZipBuffer exposes Map-like forEach/values/entries/iteration/toStringTag', async () => {
+ const archive = await buildArchive([
+ await zlib.ZipEntry.create('a.txt', Buffer.from('1')),
+ await zlib.ZipEntry.create('b.txt', Buffer.from('2')),
+ ]);
+ const zip = new zlib.ZipBuffer(archive);
+
+ const seen = [];
+ zip.forEach((entry, key, self) => {
+ seen.push(key);
+ assert.strictEqual(self, zip);
+ assert.strictEqual(entry.name, key);
+ });
+ assert.deepStrictEqual(seen.sort(), ['a.txt', 'b.txt']);
+
+ assert.deepStrictEqual([...zip.values()].map((e) => e.name).sort(), ['a.txt', 'b.txt']);
+ assert.deepStrictEqual([...zip.entries()].map(([k]) => k).sort(), ['a.txt', 'b.txt']);
+ assert.deepStrictEqual([...zip].map(([k]) => k).sort(), ['a.txt', 'b.txt']);
+ assert.strictEqual(Object.prototype.toString.call(zip), '[object ZipBuffer]');
+ assert.throws(() => zip.addEntry({}), { code: 'ERR_INVALID_ARG_TYPE' });
+});
+
+test('ZipFile exposes the same iteration protocol, plus its Sync counterparts', async () => {
+ const archive = await buildArchive([
+ await zlib.ZipEntry.create('a.txt', Buffer.from('1')),
+ await zlib.ZipEntry.create('b.txt', Buffer.from('2')),
+ ]);
+ const filePath = tmpdir.resolve('zip-coverage-iteration.zip');
+ await fs.writeFile(filePath, archive);
+
+ const zf = await zlib.ZipFile.open(filePath);
+ try {
+ const pending = [];
+ zf.forEach((valuePromise) => pending.push(valuePromise));
+ await Promise.all(pending); // Let every dangling get() settle before closing
+
+ zf.forEachSync(() => {});
+ assert.deepStrictEqual([...zf.valuesSync()].map((e) => e.name).sort(), ['a.txt', 'b.txt']);
+ assert.deepStrictEqual([...zf.entriesSync()].map(([k]) => k).sort(), ['a.txt', 'b.txt']);
+ assert.deepStrictEqual([...zf.keys()].sort(), ['a.txt', 'b.txt']);
+ assert.strictEqual(zf.size, 2);
+ assert.strictEqual(Object.prototype.toString.call(zf), '[object ZipFile]');
+
+ const names = [];
+ for await (const entry of zf) names.push(entry.name);
+ assert.deepStrictEqual(names.sort(), ['a.txt', 'b.txt']);
+
+ // Synchronous iteration (Symbol.iterator) yields [name, Promise].
+ const syncPairs = [...zf];
+ assert.deepStrictEqual(syncPairs.map(([k]) => k).sort(), ['a.txt', 'b.txt']);
+ const resolved = await Promise.all(syncPairs.map(([, v]) => v));
+ assert.deepStrictEqual(resolved.map((e) => e.name).sort(), ['a.txt', 'b.txt']);
+ } finally {
+ await zf[Symbol.asyncDispose]();
+ }
+
+ const writable = await zlib.ZipFile.open(filePath, { writable: true });
+ await assert.rejects(writable.addEntry({}), { code: 'ERR_INVALID_ARG_TYPE' });
+ assert.throws(() => writable.addEntrySync({}), { code: 'ERR_INVALID_ARG_TYPE' });
+ await writable.close();
+
+ const zf2 = zlib.ZipFile.openSync(filePath);
+ zf2[Symbol.dispose]();
+
+ await fs.unlink(filePath);
+});
+
+// -- ZipFile (on-disk) error paths ---------------------------------------------
+
+test('ZipFile.open()/openSync() reject a file with no end-of-central-directory record', async () => {
+ const filePath = tmpdir.resolve('zip-coverage-garbage.zip');
+ await fs.writeFile(filePath, Buffer.from('not a zip file, just garbage bytes'));
+ try {
+ await assert.rejects(zlib.ZipFile.open(filePath), { code: 'ERR_ZIP_INVALID_ARCHIVE' });
+ assert.throws(() => zlib.ZipFile.openSync(filePath), { code: 'ERR_ZIP_INVALID_ARCHIVE' });
+ } finally {
+ await fs.unlink(filePath);
+ }
+});
+
+test('ZipFile get()/getSync()/stream() reject a missing entry name', async () => {
+ const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]);
+ const filePath = tmpdir.resolve('zip-coverage-notfound.zip');
+ await fs.writeFile(filePath, archive);
+ const zf = await zlib.ZipFile.open(filePath);
+ try {
+ await assert.rejects(zf.get('missing'), { code: 'ERR_ZIP_ENTRY_NOT_FOUND' });
+ assert.throws(() => zf.getSync('missing'), { code: 'ERR_ZIP_ENTRY_NOT_FOUND' });
+ await assert.rejects(zf.stream('missing'), { code: 'ERR_ZIP_ENTRY_NOT_FOUND' });
+ } finally {
+ await zf.close();
+ await fs.unlink(filePath);
+ }
+});
+
+test('a local file header offset pointing into the central directory is rejected at open', async () => {
+ const name = 'a.txt';
+ const content = Buffer.from('hello');
+ const archive = await buildArchive([await zlib.ZipEntry.create(name, content, { method: 'store' })]);
+ const centralHeaderStart = 30 + name.length + content.length;
+ const tampered = Buffer.from(archive);
+ // Point the local file header offset at the central directory itself: the
+ // member's claimed data range then crosses the directory, which the
+ // open-time overlap check rejects before anything is read.
+ tampered.writeUInt32LE(centralHeaderStart, centralHeaderStart + 42);
+ const filePath = tmpdir.resolve('zip-coverage-cd-offset.zip');
+ await fs.writeFile(filePath, tampered);
+ try {
+ await assert.rejects(zlib.ZipFile.open(filePath),
+ { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /overlaps/ });
+ assert.throws(() => zlib.ZipFile.openSync(filePath),
+ { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /overlaps/ });
+ } finally {
+ await fs.unlink(filePath);
+ }
+});
+
+test('a declared compressed size reaching past the end of the file is rejected', async () => {
+ const name = 'a.txt';
+ const content = Buffer.from('hello');
+ const archive = await buildArchive([await zlib.ZipEntry.create(name, content, { method: 'store' })]);
+ const centralHeaderStart = 30 + name.length + content.length;
+ const tampered = Buffer.from(archive);
+ tampered.writeUInt32LE(archive.length * 10, centralHeaderStart + 20); // compressedSize
+ const filePath = tmpdir.resolve('zip-coverage-eof.zip');
+ await fs.writeFile(filePath, tampered);
+
+ try {
+ // Member bounds are validated against the file size up front, so the lie
+ // is caught at open time - before any buffered read path could allocate
+ // `compressedSize` bytes for it.
+ await assert.rejects(zlib.ZipFile.open(filePath),
+ { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /out of bounds/ });
+ assert.throws(() => zlib.ZipFile.openSync(filePath),
+ { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /out of bounds/ });
+ } finally {
+ await fs.unlink(filePath);
+ }
+});
+
+test('a Zip64-declared compressed size of kMaxLength is rejected at open', async () => {
+ const name = 'f.txt';
+ const content = Buffer.from('hello');
+ const archive = await buildArchive([await zlib.ZipEntry.create(name, content, { method: 'store' })]);
+
+ // A Zip64-declared compressedSize equal to kMaxLength is the largest value
+ // that still parses (readSafeUint64 caps at the safe-integer ceiling, which
+ // is kMaxLength on 64-bit), but such a member cannot lie inside this tiny
+ // file, so the open-time member bounds check rejects the archive before
+ // the buffering read paths (whose own kMaxLength guard remains as defense
+ // in depth for 32-bit, where kMaxLength is smaller than real file sizes)
+ // could ever allocate for it.
+ const centralHeaderStart = 30 + name.length + content.length;
+ const nameStart = centralHeaderStart + 46;
+ const tlv = Buffer.allocUnsafe(4 + 8);
+ tlv.writeUInt16LE(0x0001, 0);
+ tlv.writeUInt16LE(8, 2);
+ tlv.writeBigUInt64LE(BigInt(require('node:buffer').kMaxLength), 4);
+ const before = archive.subarray(0, nameStart + name.length);
+ const after = archive.subarray(nameStart + name.length);
+ const patched = Buffer.concat([before, tlv, after]);
+ patched.writeUInt16LE(tlv.length, centralHeaderStart + 30);
+ patched.writeUInt32LE(0xffffffff, centralHeaderStart + 20); // compressedSize sentinel
+ const eocdOffset = patched.length - 22;
+ patched.writeUInt32LE(patched.readUInt32LE(eocdOffset + 12) + tlv.length, eocdOffset + 12);
+
+ const toolargeFilePath = tmpdir.resolve('zip-coverage-toolarge.zip');
+ await fs.writeFile(toolargeFilePath, patched);
+ try {
+ await assert.rejects(zlib.ZipFile.open(toolargeFilePath),
+ { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /out of bounds/ });
+ assert.throws(() => zlib.ZipFile.openSync(toolargeFilePath),
+ { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /out of bounds/ });
+ } finally {
+ await fs.unlink(toolargeFilePath);
+ }
+});
+
+// -- forcing Zip64 structures without a multi-gigabyte archive -----------------
+
+test('createZipArchiveSync() also switches to Zip64 structures at 0xFFFF entries', () => {
+ const ZIP64_EOCD_SIGNATURE = Buffer.from([0x50, 0x4b, 0x06, 0x06]);
+ const entries = [];
+ for (let i = 0; i < 0x10000; i++) {
+ entries.push(zlib.ZipEntry.createSync(`entry-${i}`, Buffer.alloc(0), { method: 'store' }));
+ }
+ const chunks = [];
+ for (const chunk of zlib.createZipArchiveSync(entries)) chunks.push(chunk);
+ const archive = Buffer.concat(chunks);
+ assert.ok(archive.includes(ZIP64_EOCD_SIGNATURE));
+ assert.strictEqual([...zlib.ZipEntry.read(archive)].length, 0x10000);
+}, { timeout: 120_000 });
+
+// -- createZipArchive()'s single options argument, baseOffset, and Readable return --
+
+const CENTRAL_FILE_HEADER_SIGNATURE = Buffer.from([0x50, 0x4b, 0x01, 0x02]);
+
+test('createZipArchive() returns a pipeable, async-iterable, non-object-mode Readable', async () => {
+ const { Readable } = require('node:stream');
+ const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hi'));
+ const stream = zlib.createZipArchive([entry]);
+ assert.ok(stream instanceof Readable);
+ assert.strictEqual(stream.readableObjectMode, false);
+ const chunks = [];
+ for await (const chunk of stream) chunks.push(chunk);
+ assert.strictEqual([...zlib.ZipEntry.read(Buffer.concat(chunks))][0].name, 'f.txt');
+});
+
+test('createZipArchive()/createZipArchiveSync() take a plain string as comment shorthand', async () => {
+ const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hi'));
+ const zip = new zlib.ZipBuffer(await drain(zlib.createZipArchive([entry], 'hello')));
+ assert.strictEqual(zip.comment, 'hello');
+
+ const entrySync = zlib.ZipEntry.createSync('f.txt', Buffer.from('hi'));
+ const chunks = [...zlib.createZipArchiveSync([entrySync], 'hello-sync')];
+ const zipSync = new zlib.ZipBuffer(Buffer.concat(chunks));
+ assert.strictEqual(zipSync.comment, 'hello-sync');
+});
+
+test('createZipArchive()/createZipArchiveSync() take an { comment, baseOffset } options object', async () => {
+ const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hi'));
+ const zip = new zlib.ZipBuffer(await drain(zlib.createZipArchive([entry], { comment: 'hi there' })));
+ assert.strictEqual(zip.comment, 'hi there');
+});
+
+test('createZipArchive()/createZipArchiveSync() reject a non-string, non-object options argument', async () => {
+ await assert.rejects(drain(zlib.createZipArchive([], 123)), { code: 'ERR_INVALID_ARG_TYPE' });
+ assert.throws(() => [...zlib.createZipArchiveSync([], 123)], { code: 'ERR_INVALID_ARG_TYPE' });
+ await assert.rejects(drain(zlib.createZipArchive([], null)), { code: 'ERR_INVALID_ARG_TYPE' });
+});
+
+test('createZipArchive()/createZipArchiveSync() validate options.baseOffset', async () => {
+ await assert.rejects(drain(zlib.createZipArchive([], { baseOffset: -1 })), { code: 'ERR_OUT_OF_RANGE' });
+ await assert.rejects(drain(zlib.createZipArchive([], { baseOffset: 1.5 })), { code: 'ERR_OUT_OF_RANGE' });
+ assert.throws(() => [...zlib.createZipArchiveSync([], { baseOffset: -1 })], { code: 'ERR_OUT_OF_RANGE' });
+});
+
+test('options.baseOffset shifts every recorded offset, so a prefixed archive is ' +
+ 'self-describing without relying on prefix auto-detection', async () => {
+ const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello offset'));
+ const prefix = Buffer.from('#!/bin/sh\nexit 0\n');
+
+ const shifted = await drain(zlib.createZipArchive([entry], { baseOffset: prefix.byteLength }));
+ const shiftedCentral = shifted.indexOf(CENTRAL_FILE_HEADER_SIGNATURE);
+ assert.strictEqual(shifted.readUInt32LE(shiftedCentral + 42), prefix.byteLength);
+
+ const entryUnshifted = await zlib.ZipEntry.create('f.txt', Buffer.from('hello offset'));
+ const unshifted = await drain(zlib.createZipArchive([entryUnshifted]));
+ const unshiftedCentral = unshifted.indexOf(CENTRAL_FILE_HEADER_SIGNATURE);
+ assert.strictEqual(unshifted.readUInt32LE(unshiftedCentral + 42), 0);
+
+ const combined = Buffer.concat([prefix, shifted]);
+ const zip = new zlib.ZipBuffer(combined);
+ assert.strictEqual((await zip.get('f.txt').content()).toString(), 'hello offset');
+});
+
+test('zipBuffer.toBuffer()/toBufferSync() forward the same string/options-object shorthand', async () => {
+ const zip = new zlib.ZipBuffer(await drain(zlib.createZipArchive([])));
+ await zip.add('f.txt', Buffer.from('hi'));
+
+ assert.strictEqual(new zlib.ZipBuffer(await zip.toBuffer('a comment')).comment, 'a comment');
+ assert.strictEqual(new zlib.ZipBuffer(await zip.toBuffer({ comment: 'an object comment' })).comment,
+ 'an object comment');
+ assert.strictEqual(new zlib.ZipBuffer(zip.toBufferSync('sync comment')).comment, 'sync comment');
+
+ const prefix = Buffer.from('junk\n');
+ const shifted = await zip.toBuffer({ baseOffset: prefix.byteLength });
+ const shiftedCentral = shifted.indexOf(CENTRAL_FILE_HEADER_SIGNATURE);
+ assert.strictEqual(shifted.readUInt32LE(shiftedCentral + 42), prefix.byteLength);
+});
+
+// -- round-trip fidelity of foreign metadata ----------------------------------
+
+test('a non-Unix "version made by" host survives re-serialization', async () => {
+ const name = 'f.txt';
+ const content = Buffer.from('hello');
+ const entry = await zlib.ZipEntry.create(name, content, { method: 'store' });
+ const archive = Buffer.from(await buildArchive([entry]));
+
+ // Rewrite the entry as DOS-made (host byte 0, sec. 4.4.2) with DOS-style
+ // external attributes (0x20, the archive bit): the high 16 bits are NOT
+ // Unix permissions for this host, so `mode` must read 0, and the host byte
+ // must survive re-serialization - stamping it as Unix would turn the
+ // zeroed high bits into Unix mode 0000 for downstream extractors.
+ const centralHeaderStart = 30 + name.length + content.length;
+ archive[centralHeaderStart + 5] = 0; // "version made by" host byte
+ archive.writeUInt32LE(0x20, centralHeaderStart + 38); // external attributes
+
+ const [read] = zlib.ZipEntry.read(archive);
+ assert.strictEqual(read.mode, 0);
+ assert.strictEqual(read.isSymlink, false);
+
+ const rewritten = await buildArchive([read]);
+ const rewrittenCentral = rewritten.indexOf(CENTRAL_FILE_HEADER_SIGNATURE);
+ assert.strictEqual(rewritten[rewrittenCentral + 5], 0); // host byte preserved
+ assert.strictEqual(rewritten.readUInt32LE(rewrittenCentral + 38), 0x20);
+ const [reread] = zlib.ZipEntry.read(rewritten);
+ assert.strictEqual(reread.mode, 0);
+ // The finalized entry itself now answers from its snapshotted metadata,
+ // which must apply the same made-by gate.
+ assert.strictEqual(read.mode, 0);
+ assert.strictEqual(read.isSymlink, false);
+});
+
+// -- content() ownership -------------------------------------------------------
+
+test('content()/contentSync() of a stored in-memory entry return caller-owned memory', async () => {
+ const archive = await buildArchive(
+ [await zlib.ZipEntry.create('s.txt', Buffer.from('hello'), { method: 'store' })]);
+ const zip = new zlib.ZipBuffer(archive);
+ const entry = zip.get('s.txt');
+
+ // If content() returned a view into the archive, zeroing it would corrupt
+ // the entry and the next read would fail CRC-32 verification.
+ (await entry.content()).fill(0);
+ assert.deepStrictEqual(await entry.content(), Buffer.from('hello'));
+ entry.contentSync().fill(0);
+ assert.deepStrictEqual(entry.contentSync(), Buffer.from('hello'));
+});
+
+// Injects a raw extra-field blob into a single-entry archive's central
+// header without touching any size/offset field (unlike injectRawZip64Extra
+// above, which also plants an overflow sentinel).
+function injectCentralExtra(archive, name, contentLength, extraBytes) {
+ const centralHeaderStart = 30 + name.length + contentLength;
+ const nameStart = centralHeaderStart + 46;
+ const extraLengthOffset = centralHeaderStart + 30;
+ assert.strictEqual(archive.readUInt16LE(extraLengthOffset), 0);
+ const before = archive.subarray(0, nameStart + name.length);
+ const after = archive.subarray(nameStart + name.length);
+ const patched = Buffer.concat([before, extraBytes, after]);
+ patched.writeUInt16LE(extraBytes.length, extraLengthOffset);
+ const eocdOffset = patched.length - 22;
+ patched.writeUInt32LE(patched.readUInt32LE(eocdOffset + 12) + extraBytes.length, eocdOffset + 12);
+ return patched;
+}
+
+// -- input coercion ------------------------------------------------------------
+
+test('a plain Uint8Array is accepted anywhere binary input is, and non-binary input is rejected', async () => {
+ const archive = await buildArchive([await zlib.ZipEntry.create('u.txt', Buffer.from('hi'))]);
+ const viaU8 = new zlib.ZipBuffer(new Uint8Array(archive));
+ assert.strictEqual((await viaU8.get('u.txt').content()).toString(), 'hi');
+ const entry = await zlib.ZipEntry.create('v.txt', new Uint8Array([1, 2, 3]), { method: 'store' });
+ assert.deepStrictEqual(await entry.content(), Buffer.from([1, 2, 3]));
+ assert.throws(() => new zlib.ZipBuffer('not binary'), { code: 'ERR_INVALID_ARG_TYPE' });
+ await assert.rejects(zlib.ZipEntry.create('w.txt', 'not binary'), { code: 'ERR_INVALID_ARG_TYPE' });
+});
+
+test('forEach() passes thisArg through on ZipBuffer and ZipFile', async () => {
+ const archive = await buildArchive([await zlib.ZipEntry.create('t.txt', Buffer.from('x'))]);
+ const zip = new zlib.ZipBuffer(archive);
+ const ctx = { hits: 0 };
+ zip.forEach(function() { this.hits++; }, ctx);
+ assert.strictEqual(ctx.hits, 1);
+
+ const filePath = tmpdir.resolve('zip-coverage-foreach.zip');
+ await fs.writeFile(filePath, archive);
+ const zf = await zlib.ZipFile.open(filePath);
+ try {
+ const fileCtx = { hits: 0 };
+ zf.forEach(function() { this.hits++; }, fileCtx);
+ zf.forEachSync(function() { this.hits++; }, fileCtx);
+ assert.strictEqual(fileCtx.hits, 2);
+ } finally {
+ await zf.close();
+ await fs.unlink(filePath);
+ }
+});
+
+// -- entry metadata before serialization ----------------------------------------
+
+test('freshly created entries expose their metadata before serialization', async () => {
+ const sym = zlib.ZipEntry.createSymlink('link', 'target');
+ assert.strictEqual(sym.mode, 0o777); // The symlink default
+ assert.strictEqual(sym.isSymlink, true);
+ assert.strictEqual(sym.isFile, false);
+
+ const entry = await zlib.ZipEntry.create('m.txt', Buffer.from('x'), {
+ comment: 'note', mode: 0o640, modified: new Date(1700000000000),
+ });
+ assert.deepStrictEqual(entry.nameBuffer, Buffer.from('m.txt'));
+ assert.strictEqual(entry.comment, 'note');
+ assert.strictEqual(entry.modified.getTime(), 1700000000000);
+ assert.strictEqual(entry.mode, 0o640);
+ assert.strictEqual(entry.isSymlink, false);
+});
+
+// -- extra-field edge cases ------------------------------------------------------
+
+test('a Unicode Path extra field with an unknown version is ignored', async () => {
+ const name = 'plain.txt';
+ const content = Buffer.from('hi');
+ const archive = await buildArchive([await zlib.ZipEntry.create(name, content, { method: 'store' })]);
+ const utf8Name = Buffer.from('other.txt');
+ const up = Buffer.allocUnsafe(4 + 5 + utf8Name.length);
+ up.writeUInt16LE(0x7075, 0);
+ up.writeUInt16LE(5 + utf8Name.length, 2);
+ up.writeUInt8(2, 4); // Unsupported version: only version 1 is defined
+ up.writeUInt32LE(0, 5); // CRC-32 (irrelevant; the version check comes first)
+ utf8Name.copy(up, 9);
+ const patched = injectCentralExtra(archive, name, content.length, up);
+ const [read] = zlib.ZipEntry.read(patched);
+ assert.strictEqual(read.name, 'plain.txt');
+});
+
+test('malformed or empty timestamp extra fields fall back to the DOS time', async () => {
+ const name = 't.txt';
+ const content = Buffer.from('x');
+ const modified = new Date(2024, 3, 5, 10, 20, 30);
+ const archive = await buildArchive(
+ [await zlib.ZipEntry.create(name, content, { method: 'store', modified })]);
+ const ntfs = Buffer.allocUnsafe(4 + 12);
+ ntfs.writeUInt16LE(0x000a, 0);
+ ntfs.writeUInt16LE(12, 2);
+ ntfs.writeUInt32LE(0, 4); // Reserved dword
+ ntfs.writeUInt16LE(1, 8); // Tag 1...
+ ntfs.writeUInt16LE(200, 10); // ...claiming 200 bytes: overruns its record
+ ntfs.writeUInt32LE(0, 12);
+ const ut = Buffer.from([0x55, 0x54, 0x01, 0x00, 0x00]); // "UT", flags 0: no mtime present
+ const ux = Buffer.allocUnsafe(4 + 4);
+ ux.writeUInt16LE(0x5855, 0);
+ ux.writeUInt16LE(4, 2); // Too short for the atime+mtime pair
+ ux.writeUInt32LE(123, 4);
+ const patched = injectCentralExtra(
+ archive, name, content.length, Buffer.concat([ntfs, ut, ux]));
+ const [read] = zlib.ZipEntry.read(patched);
+ assert.strictEqual(read.modified.getTime(), modified.getTime());
+});
+
+test('a preserved timestamp extra survives re-serialization without duplication', async () => {
+ const odd = new Date(1700000001000); // An odd second: gets a "UT" extra
+ const name = 'odd.txt';
+ const content = Buffer.from('x');
+ const first = await buildArchive(
+ [await zlib.ZipEntry.create(name, content, { modified: odd, method: 'store' })]);
+ const [read] = zlib.ZipEntry.read(first);
+ const second = await buildArchive([read]);
+ const [reread] = zlib.ZipEntry.read(second);
+ assert.strictEqual(reread.modified.getTime(), odd.getTime());
+ // Exactly one extended-timestamp record: the preserved one, no new copy.
+ const central = second.indexOf(CENTRAL_FILE_HEADER_SIGNATURE);
+ const nameLength = second.readUInt16LE(central + 28);
+ const extraLength = second.readUInt16LE(central + 30);
+ const extra = second.subarray(
+ central + 46 + nameLength, central + 46 + nameLength + extraLength);
+ let utRecords = 0;
+ for (let pos = 0; pos + 4 <= extra.length;) {
+ if (extra.readUInt16LE(pos) === 0x5455) utRecords++;
+ pos += 4 + extra.readUInt16LE(pos + 2);
+ }
+ assert.strictEqual(utRecords, 1);
+});
+
+// -- file-backed metadata and I/O failure paths ----------------------------------
+
+test('modified falls back to the central directory when the local header is malformed', async () => {
+ const name = 'm.txt';
+ const content = Buffer.from('hello');
+ const modified = new Date(2024, 3, 5, 10, 20, 30);
+ const archive = Buffer.from(await buildArchive(
+ [await zlib.ZipEntry.create(name, content, { method: 'store', modified })]));
+ archive.writeUInt32LE(0xdeadbeef, 0); // Corrupt the local header signature
+ const filePath = tmpdir.resolve('zip-coverage-badlocal.zip');
+ await fs.writeFile(filePath, archive);
+ const zf = await zlib.ZipFile.open(filePath);
+ try {
+ const entry = await zf.get(name);
+ // Metadata resolution swallows the local-header failure...
+ assert.strictEqual(entry.modified.getTime(), modified.getTime());
+ // ...but content reads fail loudly on it.
+ await assert.rejects(entry.content(), { code: 'ERR_ZIP_INVALID_ARCHIVE' });
+ } finally {
+ await zf.close();
+ await fs.unlink(filePath);
+ }
+});
+
+test('a file that shrinks after open is rejected as unexpected EOF when read', async () => {
+ // The open-time bounds and overlap checks guarantee every member lies
+ // inside the file *as it was at open time*; a file truncated afterwards
+ // (the only way a positioned read can now hit EOF) must fail cleanly.
+ const name = 'a.txt';
+ const content = Buffer.from('hello');
+ const archive = await buildArchive(
+ [await zlib.ZipEntry.create(name, content, { method: 'store' })]);
+ const filePath = tmpdir.resolve('zip-coverage-eof-local.zip');
+ await fs.writeFile(filePath, archive);
+ const zf = await zlib.ZipFile.open(filePath);
+ const zfSync = zlib.ZipFile.openSync(filePath);
+ try {
+ await fs.truncate(filePath, 10); // Cuts into the local file header
+ await assert.rejects((await zf.get(name)).content(),
+ { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /unexpected end of file/ });
+ assert.throws(() => zfSync.getSync(name).contentSync(),
+ { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /unexpected end of file/ });
+ } finally {
+ await zf.close();
+ zfSync.closeSync();
+ await fs.unlink(filePath);
+ }
+});
+
+test('a failed streaming addEntry() restores the archive and the queue continues', async () => {
+ const filePath = tmpdir.resolve('zip-coverage-rollback.zip');
+ await fs.writeFile(filePath, await buildArchive(
+ [await zlib.ZipEntry.create('seed.txt', Buffer.from('seed'))]));
+ const zip = await zlib.ZipFile.open(filePath, { writable: true });
+ try {
+ async function* failing() {
+ yield Buffer.from('partial data that overwrote the central directory');
+ throw new Error('source failed');
+ }
+ await assert.rejects(
+ zip.addEntry(zlib.ZipEntry.createStream('bad.txt', failing())),
+ /source failed/);
+ // The rewrite restored the directory in place, and the mutation queue
+ // keeps accepting work after a failure.
+ await zip.add('good.txt', Buffer.from('ok'));
+ } finally {
+ await zip.close();
+ }
+ const reread = await zlib.ZipFile.open(filePath);
+ try {
+ assert.deepStrictEqual([...reread.keys()].sort(), ['good.txt', 'seed.txt']);
+ assert.strictEqual((await (await reread.get('seed.txt')).content()).toString(), 'seed');
+ assert.strictEqual((await (await reread.get('good.txt')).content()).toString(), 'ok');
+ } finally {
+ await reread.close();
+ await fs.unlink(filePath);
+ }
+});
+
+test('opening a missing archive surfaces the file-system error', async () => {
+ const missing = tmpdir.resolve('zip-coverage-missing.zip');
+ await assert.rejects(zlib.ZipFile.open(missing), { code: 'ENOENT' });
+ assert.throws(() => zlib.ZipFile.openSync(missing), { code: 'ENOENT' });
+ await assert.rejects(
+ drain(zlib.zipFiles([[tmpdir.resolve('missing-src.txt'), 'a.txt']], { followSymlinks: false })),
+ { code: 'ENOENT' });
+});
+
+test('using a ZipFile after close() fails with system-level errors', async () => {
+ const filePath = tmpdir.resolve('zip-coverage-after-close.zip');
+ await fs.writeFile(filePath, await buildArchive(
+ [await zlib.ZipEntry.create('a.txt', Buffer.from('data'))]));
+
+ const zip = await zlib.ZipFile.open(filePath, { writable: true });
+ const entry = await zip.get('a.txt');
+ await zip.close();
+ // Reads through a retained entry hit the closed descriptor...
+ await assert.rejects(entry.content(), { code: 'EBADF' });
+ // ...as do further mutations and a second close().
+ await assert.rejects(
+ zip.addEntry(await zlib.ZipEntry.create('b.txt', Buffer.from('x'))), { code: 'EBADF' });
+ await assert.rejects(zip.close(), { code: 'EBADF' });
+
+ const zipSync = zlib.ZipFile.openSync(filePath, { writable: true });
+ const entrySync = zipSync.getSync('a.txt');
+ zipSync.closeSync();
+ assert.throws(() => entrySync.contentSync(), { code: 'EBADF' });
+ assert.throws(
+ () => zipSync.addEntrySync(zlib.ZipEntry.createSync('b.txt', Buffer.from('x'))),
+ { code: 'EBADF' });
+ await fs.unlink(filePath);
+});
+
+// -- writer-side Zip64 fields ----------------------------------------------------
+
+test('an entry starting beyond 4 GiB records its offset in a Zip64 extra field', async () => {
+ const BASE = 0x1_0000_0000; // 4 GiB
+ const entry = await zlib.ZipEntry.create('far.txt', Buffer.from('hello'), { method: 'store' });
+ const archive = await drain(zlib.createZipArchive([entry], { baseOffset: BASE }));
+ const central = archive.indexOf(CENTRAL_FILE_HEADER_SIGNATURE);
+ assert.notStrictEqual(central, -1);
+ assert.strictEqual(archive.readUInt16LE(central + 6), 45); // Version needed: Zip64
+ assert.strictEqual(archive.readUInt32LE(central + 42), 0xffffffff); // Offset sentinel
+ const nameLength = archive.readUInt16LE(central + 28);
+ const extraStart = central + 46 + nameLength;
+ assert.strictEqual(archive.readUInt16LE(extraStart), 0x0001);
+ assert.strictEqual(archive.readUInt16LE(extraStart + 2), 8);
+ assert.strictEqual(archive.readBigUInt64LE(extraStart + 4), BigInt(BASE));
+ // The trailer is promoted to Zip64 too: the directory offset overflows.
+ assert.notStrictEqual(archive.indexOf(Buffer.from([0x50, 0x4b, 0x06, 0x06])), -1);
+});
+
+test('extra fields that no longer fit their 16-bit length field are rejected on write', async () => {
+ const name = 'big-extra.txt';
+ const content = Buffer.from('hi');
+ const archive = await buildArchive(
+ [await zlib.ZipEntry.create(name, content, { method: 'store' })]);
+ // Give the entry a preserved (unknown-ID) extra field near the 65,535-byte
+ // cap; serializing it at an offset beyond 4 GiB must add a 12-byte Zip64
+ // record, pushing the total over the cap.
+ const filler = Buffer.allocUnsafe(4 + 65526);
+ filler.writeUInt16LE(0x9999, 0);
+ filler.writeUInt16LE(65526, 2);
+ filler.fill(0xab, 4);
+ const patched = injectCentralExtra(archive, name, content.length, filler);
+ const [read] = zlib.ZipEntry.read(patched);
+ await assert.rejects(
+ drain(zlib.createZipArchive([read], { baseOffset: 0x1_0000_0000 })),
+ { code: 'ERR_ZIP_ENTRY_TOO_LARGE', message: /extra fields/ });
+});
+
+// -- archive-end discovery edge cases --------------------------------------------
+
+test('a Zip64 EOCD record pushed out of the tail by its data sector is found by re-reading', async () => {
+ const PAD = 80000; // Larger than the fixed-size tail read
+ const record = Buffer.alloc(56 + PAD);
+ record.writeUInt32LE(0x06064b50, 0);
+ record.writeBigUInt64LE(BigInt(44 + PAD), 4); // Remainder size, incl. the sector
+ record.writeUInt16LE((3 << 8) | 45, 12);
+ record.writeUInt16LE(45, 14);
+ const locator = Buffer.alloc(20);
+ locator.writeUInt32LE(0x07064b50, 0);
+ locator.writeBigUInt64LE(0n, 8); // The record starts at offset 0
+ locator.writeUInt32LE(1, 16);
+ const comment = Buffer.from('sector');
+ const eocd = Buffer.alloc(22 + comment.length);
+ eocd.writeUInt32LE(0x06054b50, 0);
+ eocd.writeUInt32LE(0xffffffff, 12); // Size sentinel: the Zip64 record is required
+ eocd.writeUInt16LE(comment.length, 20);
+ comment.copy(eocd, 22);
+ const bytes = Buffer.concat([record, locator, eocd]);
+
+ // In one buffer the record is directly reachable...
+ assert.strictEqual(new zlib.ZipBuffer(bytes).size, 0);
+
+ // ...but a tail read must notice it starts before the tail and re-read.
+ const filePath = tmpdir.resolve('zip-coverage-sector.zip');
+ await fs.writeFile(filePath, bytes);
+ const zf = await zlib.ZipFile.open(filePath);
+ try {
+ assert.strictEqual(zf.size, 0);
+ assert.strictEqual(zf.comment, 'sector');
+ } finally {
+ await zf.close();
+ }
+ const zfSync = zlib.ZipFile.openSync(filePath);
+ try {
+ assert.strictEqual(zfSync.size, 0);
+ } finally {
+ zfSync.closeSync();
+ }
+ await fs.unlink(filePath);
+});
+
+test('a locator offset beyond the safe-integer range falls back to classic fields', () => {
+ const buf = buildMinimalZip64Archive({ locator: { recordOffset: 0xffffffffffffffffn } });
+ buf.writeUInt32LE(0xdeadbeef, 0); // Corrupt the record so only the fallback can succeed
+ assert.strictEqual([...zlib.ZipEntry.read(buf)].length, 0);
+});
+
+test('ZipFile rejects a central header on another disk', async () => {
+ const name = 'a.txt';
+ const content = Buffer.from('x');
+ const archive = Buffer.from(await buildArchive(
+ [await zlib.ZipEntry.create(name, content, { method: 'store' })]));
+ const centralHeaderStart = 30 + name.length + content.length;
+ archive.writeUInt16LE(1, centralHeaderStart + 34); // Disk number
+ const filePath = tmpdir.resolve('zip-coverage-disk.zip');
+ await fs.writeFile(filePath, archive);
+ await assert.rejects(zlib.ZipFile.open(filePath), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' });
+ assert.throws(() => zlib.ZipFile.openSync(filePath), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' });
+ await fs.unlink(filePath);
+});
+
+test('zipFiles() keeps a directory mapping name that already ends in a slash', async () => {
+ const dir = tmpdir.resolve('zip-coverage-dir');
+ await fs.mkdir(dir, { recursive: true });
+ const archive = await drain(zlib.zipFiles([[dir, 'mapped/']]));
+ const [entry] = zlib.ZipEntry.read(archive);
+ assert.strictEqual(entry.name, 'mapped/');
+ assert.strictEqual(entry.isDirectory, true);
+});
+
+test('a streamed entry with the store method round-trips', async () => {
+ async function* source() {
+ yield Buffer.from('stored ');
+ yield Buffer.from('stream');
+ }
+ const entry = zlib.ZipEntry.createStream('s.txt', source(), { method: 'store' });
+ const archive = await buildArchive([entry]);
+ const [read] = zlib.ZipEntry.read(archive);
+ assert.strictEqual(read.method, 0);
+ assert.strictEqual((await read.content()).toString(), 'stored stream');
+});
+
+test('createSync() honors an explicit store method', () => {
+ const entry = zlib.ZipEntry.createSync('s.bin', Buffer.from([1, 2, 3]), { method: 'store' });
+ assert.strictEqual(entry.method, 0);
+ assert.strictEqual(entry.compressed, false);
+ assert.deepStrictEqual(entry.contentSync(), Buffer.from([1, 2, 3]));
+});
diff --git a/test/parallel/test-zlib-zip-edgecases.js b/test/parallel/test-zlib-zip-edgecases.js
new file mode 100644
index 00000000000000..7d77a07ed6d0bd
--- /dev/null
+++ b/test/parallel/test-zlib-zip-edgecases.js
@@ -0,0 +1,188 @@
+'use strict';
+
+// Malformed / adversarial / boundary archives, drawn from the ZIP
+// parser-differential and torture-test literature (Go archive/zip testdata,
+// USENIX'25 semantic-gap paper, PyPI/uv ZIP-confusion advisories, Info-ZIP).
+// Node core does not extract to disk, so path-traversal safety is the caller's
+// job - these tests pin that our reader surfaces names verbatim and resolves
+// every ambiguity from the authoritative central directory.
+
+require('../common');
+
+const assert = require('node:assert');
+const zlib = require('node:zlib');
+const { test } = require('node:test');
+
+const SIG_LOCAL = 0x04034b50;
+const SIG_CENTRAL = 0x02014b50;
+const SIG_EOCD = 0x06054b50;
+
+async function buildArchive(entries, comment) {
+ const chunks = [];
+ for await (const c of zlib.createZipArchive(entries, comment)) chunks.push(c);
+ return Buffer.concat(chunks);
+}
+
+// One-entry (stored) archive with independent control of the local vs central
+// name, flags, method and sizes - the levers ZIP-confusion attacks pull.
+function buildEntryArchive(opts) {
+ const name = opts.nameBuffer;
+ const localName = opts.localNameBuffer ?? name;
+ const content = opts.content ?? Buffer.alloc(0);
+ const flags = opts.flags ?? 0;
+ const method = opts.method ?? 0;
+ const localExtra = opts.localExtra ?? Buffer.alloc(0);
+ const centralExtra = opts.centralExtra ?? Buffer.alloc(0);
+ const crc = zlib.crc32(content) >>> 0;
+
+ const local = Buffer.alloc(30);
+ local.writeUInt32LE(SIG_LOCAL, 0);
+ local.writeUInt16LE(20, 4);
+ local.writeUInt16LE(flags, 6);
+ local.writeUInt16LE(method, 8);
+ local.writeUInt16LE(0x21, 12);
+ local.writeUInt32LE(crc, 14);
+ local.writeUInt32LE(content.length, 18);
+ local.writeUInt32LE(content.length, 22);
+ local.writeUInt16LE(localName.length, 26);
+ local.writeUInt16LE(localExtra.length, 28);
+ const localRecord = Buffer.concat([local, localName, localExtra, content]);
+
+ const central = Buffer.alloc(46);
+ central.writeUInt32LE(SIG_CENTRAL, 0);
+ central.writeUInt16LE(0x0314, 4);
+ central.writeUInt16LE(20, 6);
+ central.writeUInt16LE(flags, 8);
+ central.writeUInt16LE(method, 10);
+ central.writeUInt16LE(0x21, 14);
+ central.writeUInt32LE(crc, 16);
+ central.writeUInt32LE(content.length, 20);
+ central.writeUInt32LE(content.length, 24);
+ central.writeUInt16LE(name.length, 28);
+ central.writeUInt16LE(centralExtra.length, 30);
+ central.writeUInt32LE(0, 42);
+ const centralRecord = Buffer.concat([central, name, centralExtra]);
+
+ const eocd = Buffer.alloc(22);
+ eocd.writeUInt32LE(SIG_EOCD, 0);
+ eocd.writeUInt16LE(1, 8);
+ eocd.writeUInt16LE(1, 10);
+ eocd.writeUInt32LE(centralRecord.length, 12);
+ eocd.writeUInt32LE(localRecord.length, 16);
+ return Buffer.concat([localRecord, centralRecord, eocd]);
+}
+
+// -- parser-confusion / semantic-gap defenses --------------------------------
+
+test('the central directory is authoritative when the local header disagrees', () => {
+ // Local header claims "fake.txt"; central directory says "real.txt".
+ const archive = buildEntryArchive({
+ nameBuffer: Buffer.from('real.txt'),
+ localNameBuffer: Buffer.from('fake.txt'),
+ content: Buffer.from('payload'),
+ });
+ const [entry] = zlib.ZipEntry.read(archive);
+ assert.strictEqual(entry.name, 'real.txt');
+ assert.strictEqual(entry.contentSync().toString(), 'payload');
+});
+
+test('duplicate central-directory names: read() yields all, get() takes the last', async () => {
+ const archive = await buildArchive([
+ await zlib.ZipEntry.create('dup.txt', Buffer.from('first')),
+ await zlib.ZipEntry.create('dup.txt', Buffer.from('second')),
+ ]);
+ assert.deepStrictEqual([...zlib.ZipEntry.read(archive)].map((e) => e.name), ['dup.txt', 'dup.txt']);
+ using zip = new zlib.ZipBuffer(archive);
+ assert.strictEqual(zip.size, 1);
+ assert.strictEqual(zip.get('dup.txt').contentSync().toString(), 'second');
+});
+
+test('path-unsafe names are surfaced verbatim, never normalized or rejected', () => {
+ for (const name of ['../../etc/passwd', '/etc/passwd', 'a\\b\\c.txt', 'C:\\evil.dll']) {
+ const [entry] = zlib.ZipEntry.read(buildEntryArchive({ nameBuffer: Buffer.from(name) }));
+ assert.strictEqual(entry.name, name);
+ }
+});
+
+// -- structural boundaries ----------------------------------------------------
+
+test('data prepended before the archive (SFX stub) is tolerated via prefix detection', async () => {
+ const inner = await buildArchive([
+ await zlib.ZipEntry.create('a.txt', Buffer.from('hello')),
+ await zlib.ZipEntry.create('b.txt', Buffer.from('world')),
+ ]);
+ const prefixed = Buffer.concat([Buffer.alloc(1000, 0x7f), inner]); // stub bytes
+ using zip = new zlib.ZipBuffer(prefixed);
+ assert.deepStrictEqual([...zip.keys()].sort(), ['a.txt', 'b.txt']);
+ assert.strictEqual(zip.get('a.txt').contentSync().toString(), 'hello');
+});
+
+test('an empty archive (EOCD only) reads as zero entries', () => {
+ const eocd = Buffer.alloc(22);
+ eocd.writeUInt32LE(SIG_EOCD, 0);
+ assert.deepStrictEqual([...zlib.ZipEntry.read(eocd)], []);
+ using zip = new zlib.ZipBuffer(eocd);
+ assert.strictEqual(zip.size, 0);
+});
+
+test('directory entries and zero-byte file entries round-trip', async () => {
+ const archive = await buildArchive([
+ await zlib.ZipEntry.create('dir/', Buffer.alloc(0)),
+ await zlib.ZipEntry.create('empty.txt', Buffer.alloc(0)),
+ ]);
+ using zip = new zlib.ZipBuffer(archive);
+ assert.strictEqual(zip.get('dir/').isDirectory, true);
+ const empty = zip.get('empty.txt');
+ assert.strictEqual(empty.isDirectory, false);
+ assert.strictEqual(empty.contentSync().length, 0);
+ assert.strictEqual(empty.crc32, 0);
+});
+
+test('an archive comment at the maximum length (65535 bytes) round-trips', async () => {
+ const comment = 'z'.repeat(0xffff);
+ const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))], comment);
+ using zip = new zlib.ZipBuffer(archive);
+ assert.strictEqual(zip.comment.length, 0xffff);
+ assert.strictEqual(zip.get('a.txt').contentSync().toString(), 'x');
+});
+
+// -- unsupported features are rejected, not misread --------------------------
+
+test('a traditional-encrypted entry (bit 0) is rejected', () => {
+ const [entry] = zlib.ZipEntry.read(buildEntryArchive({
+ nameBuffer: Buffer.from('f'),
+ content: Buffer.from('secret'),
+ flags: 0x0001,
+ }));
+ assert.throws(() => entry.contentSync(), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' });
+});
+
+test('a WinZip-AES entry (method 99, bit 0, 0x9901 extra) is rejected as encrypted', () => {
+ const aes = Buffer.concat([
+ Buffer.from([0x01, 0x99]), // extra id 0x9901
+ Buffer.from([0x07, 0x00]), // size 7
+ Buffer.from([0x01, 0x00]), // AE-1 version
+ Buffer.from([0x41, 0x45]), // "AE"
+ Buffer.from([0x03]), // AES-256
+ Buffer.from([0x00, 0x00]), // Actual method (store)
+ ]);
+ const [entry] = zlib.ZipEntry.read(buildEntryArchive({
+ nameBuffer: Buffer.from('f'),
+ content: Buffer.from('cipher'),
+ flags: 0x0001,
+ method: 99,
+ centralExtra: aes,
+ localExtra: aes,
+ }));
+ assert.throws(() => entry.contentSync(), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' });
+});
+
+test('a Zip64 sentinel size with no Zip64 extra field is rejected', () => {
+ const archive = buildEntryArchive({ nameBuffer: Buffer.from('f'), content: Buffer.from('hi') });
+ // Force the central uncompressed-size field to the sentinel without adding a
+ // Zip64 extra field, so "look in the Zip64 record" points at nothing.
+ const cd = archive.readUInt32LE(archive.length - 22 + 16);
+ archive.writeUInt32LE(0xffffffff, cd + 24);
+ assert.throws(() => [...zlib.ZipEntry.read(archive)][0].size,
+ { code: 'ERR_ZIP_INVALID_ARCHIVE' });
+});
diff --git a/test/parallel/test-zlib-zip-encoding.js b/test/parallel/test-zlib-zip-encoding.js
new file mode 100644
index 00000000000000..f1091ac1f74543
--- /dev/null
+++ b/test/parallel/test-zlib-zip-encoding.js
@@ -0,0 +1,244 @@
+'use strict';
+
+// Reading foreign-encoder edge cases faithfully: non-UTF-8 (CP437) and
+// Unicode-Path-extra filenames, raw name bytes, full Unix mode bits and
+// symlink type, and modification times carried in extra fields rather than
+// the coarse DOS date/time. These synthesize the exact header bytes real
+// tools (Windows Explorer, Info-ZIP, 7-Zip, WinRAR) emit, since our own
+// writer only ever produces UTF-8 names and DOS timestamps.
+
+require('../common');
+
+const assert = require('node:assert');
+const zlib = require('node:zlib');
+const { test } = require('node:test');
+
+const SIG_LOCAL = 0x04034b50;
+const SIG_CENTRAL = 0x02014b50;
+const SIG_EOCD = 0x06054b50;
+
+// Build a minimal one-entry (stored) archive with full control over every
+// header field, so tests can reproduce exactly what a foreign encoder writes.
+function buildEntryArchive(opts) {
+ const name = opts.nameBuffer;
+ const content = opts.content ?? Buffer.alloc(0);
+ const flags = opts.flags ?? 0;
+ const localExtra = opts.localExtra ?? Buffer.alloc(0);
+ const centralExtra = opts.centralExtra ?? Buffer.alloc(0);
+ const external = (opts.externalAttrs ?? 0) >>> 0;
+ const versionMadeBy = opts.versionMadeBy ?? 0x0314; // host 3 (Unix), v2.0
+ const dosTime = opts.dosTime ?? 0;
+ const dosDate = opts.dosDate ?? 0x21; // 1980-01-01
+ const crc = zlib.crc32(content) >>> 0;
+
+ const local = Buffer.alloc(30);
+ local.writeUInt32LE(SIG_LOCAL, 0);
+ local.writeUInt16LE(20, 4);
+ local.writeUInt16LE(flags, 6);
+ local.writeUInt16LE(0, 8); // store
+ local.writeUInt16LE(dosTime, 10);
+ local.writeUInt16LE(dosDate, 12);
+ local.writeUInt32LE(crc, 14);
+ local.writeUInt32LE(content.length, 18);
+ local.writeUInt32LE(content.length, 22);
+ local.writeUInt16LE(name.length, 26);
+ local.writeUInt16LE(localExtra.length, 28);
+ const localRecord = Buffer.concat([local, name, localExtra, content]);
+
+ const central = Buffer.alloc(46);
+ central.writeUInt32LE(SIG_CENTRAL, 0);
+ central.writeUInt16LE(versionMadeBy, 4);
+ central.writeUInt16LE(20, 6);
+ central.writeUInt16LE(flags, 8);
+ central.writeUInt16LE(0, 10);
+ central.writeUInt16LE(dosTime, 12);
+ central.writeUInt16LE(dosDate, 14);
+ central.writeUInt32LE(crc, 16);
+ central.writeUInt32LE(content.length, 20);
+ central.writeUInt32LE(content.length, 24);
+ central.writeUInt16LE(name.length, 28);
+ central.writeUInt16LE(centralExtra.length, 30);
+ central.writeUInt16LE(0, 32); // comment length
+ central.writeUInt16LE(0, 34);
+ central.writeUInt16LE(0, 36);
+ central.writeUInt32LE(external, 38);
+ central.writeUInt32LE(0, 42); // local header offset
+ const centralRecord = Buffer.concat([central, name, centralExtra]);
+
+ const eocd = Buffer.alloc(22);
+ eocd.writeUInt32LE(SIG_EOCD, 0);
+ eocd.writeUInt16LE(1, 8);
+ eocd.writeUInt16LE(1, 10);
+ eocd.writeUInt32LE(centralRecord.length, 12);
+ eocd.writeUInt32LE(localRecord.length, 16);
+ return Buffer.concat([localRecord, centralRecord, eocd]);
+}
+
+// -- filename encoding --------------------------------------------------------
+
+test('a bit-11-clear name is decoded as CP437, not mangled as UTF-8', () => {
+ // 0x81 is "ü" in CP437; as a lone UTF-8 byte it is invalid (would be U+FFFD).
+ const nameBuffer = Buffer.from([0x63, 0x61, 0x66, 0x81]); // "caf" + ü
+ const [entry] = zlib.ZipEntry.read(buildEntryArchive({ nameBuffer }));
+ assert.strictEqual(entry.name, 'cafü');
+ assert.deepStrictEqual(entry.nameBuffer, nameBuffer);
+});
+
+test('a bit-11-set name is decoded as UTF-8, with raw bytes on nameBuffer', () => {
+ const nameBuffer = Buffer.from('café-名前.txt', 'utf8');
+ const [entry] = zlib.ZipEntry.read(buildEntryArchive({ nameBuffer, flags: 0x0800 }));
+ assert.strictEqual(entry.name, 'café-名前.txt');
+ assert.deepStrictEqual(entry.nameBuffer, nameBuffer);
+});
+
+test('a valid Unicode Path extra field (0x7075) overrides the CP437 name', () => {
+ const cp437Name = Buffer.from([0x63, 0x61, 0x66, 0x81]); // "cafü" in CP437
+ const utf8 = Buffer.from('café.txt', 'utf8');
+ const up = Buffer.concat([
+ Buffer.from([0x75, 0x70]), // id 0x7075
+ (() => { const b = Buffer.alloc(2); b.writeUInt16LE(5 + utf8.length, 0); return b; })(),
+ Buffer.from([0x01]), // version 1
+ (() => { const b = Buffer.alloc(4); b.writeUInt32LE(zlib.crc32(cp437Name) >>> 0, 0); return b; })(),
+ utf8,
+ ]);
+ const [entry] = zlib.ZipEntry.read(buildEntryArchive({ nameBuffer: cp437Name, centralExtra: up }));
+ assert.strictEqual(entry.name, 'café.txt');
+});
+
+test('a Unicode Path extra field with a stale CRC is ignored (falls back to CP437)', () => {
+ const cp437Name = Buffer.from([0x63, 0x61, 0x66, 0x81]);
+ const utf8 = Buffer.from('renamed.txt', 'utf8');
+ const up = Buffer.concat([
+ Buffer.from([0x75, 0x70]),
+ (() => { const b = Buffer.alloc(2); b.writeUInt16LE(5 + utf8.length, 0); return b; })(),
+ Buffer.from([0x01]),
+ Buffer.from([0xde, 0xad, 0xbe, 0xef]), // wrong CRC of the standard name
+ utf8,
+ ]);
+ const [entry] = zlib.ZipEntry.read(buildEntryArchive({ nameBuffer: cp437Name, centralExtra: up }));
+ assert.strictEqual(entry.name, 'cafü'); // stale extra ignored
+});
+
+test('ZipBuffer.get() keys by the decoded (CP437) name', () => {
+ const nameBuffer = Buffer.from([0x81, 0x2e, 0x74, 0x78, 0x74]); // "ü.txt"
+ using zip = new zlib.ZipBuffer(buildEntryArchive({ nameBuffer, content: Buffer.from('x') }));
+ assert.strictEqual(zip.has('ü.txt'), true);
+ assert.strictEqual(zip.get('ü.txt').contentSync().toString(), 'x');
+});
+
+// -- Unix mode + symlink ------------------------------------------------------
+
+test('setuid/setgid/sticky bits round-trip through a read', async () => {
+ for (const mode of [0o4755, 0o2750, 0o1777, 0o755, 0o640]) {
+ const entry = await zlib.ZipEntry.create('f', Buffer.from('x'), { mode });
+ const chunks = [];
+ for await (const c of zlib.createZipArchive([entry])) chunks.push(c);
+ const [read] = zlib.ZipEntry.read(Buffer.concat(chunks));
+ assert.strictEqual(read.mode, mode, `0o${mode.toString(8)} -> 0o${read.mode.toString(8)}`);
+ }
+});
+
+test('a symlink entry is reported as a symlink, not a file', () => {
+ const target = Buffer.from('../target');
+ const S_IFLNK = 0o120000;
+ const archive = buildEntryArchive({
+ nameBuffer: Buffer.from('link'),
+ content: target,
+ externalAttrs: ((S_IFLNK | 0o777) << 16) >>> 0,
+ });
+ const [entry] = zlib.ZipEntry.read(archive);
+ assert.strictEqual(entry.isSymlink, true);
+ assert.strictEqual(entry.isFile, false);
+ assert.strictEqual(entry.isDirectory, false);
+ assert.strictEqual(entry.mode, 0o777);
+ assert.strictEqual(entry.contentSync().toString(), '../target');
+});
+
+test('external attributes from a non-Unix host expose no mode and no symlink', () => {
+ const S_IFLNK = 0o120000;
+ const archive = buildEntryArchive({
+ nameBuffer: Buffer.from('x'),
+ externalAttrs: ((S_IFLNK | 0o777) << 16) >>> 0, // Looks like a symlink...
+ versionMadeBy: 0x0014, // ...but host 0 (FAT/DOS), so not Unix mode
+ });
+ const [entry] = zlib.ZipEntry.read(archive);
+ assert.strictEqual(entry.mode, 0);
+ assert.strictEqual(entry.isSymlink, false);
+});
+
+// -- modification time from extra fields --------------------------------------
+
+// 2017-10-31T21:11:57Z, deliberately not representable in the 2-second DOS
+// field so an extra-field time can be told apart from the DOS fallback.
+const MTIME_SECS = 1509484317;
+
+function extField(id, body) {
+ const head = Buffer.alloc(4);
+ head.writeUInt16LE(id, 0);
+ head.writeUInt16LE(body.length, 2);
+ return Buffer.concat([head, body]);
+}
+
+test('the extended-timestamp extra field (0x5455) sets the modification time', () => {
+ const body = Buffer.alloc(5);
+ body.writeUInt8(0x01, 0); // mtime present
+ body.writeInt32LE(MTIME_SECS, 1);
+ const [entry] = zlib.ZipEntry.read(buildEntryArchive({
+ nameBuffer: Buffer.from('f'),
+ centralExtra: extField(0x5455, body),
+ dosDate: 0x4a21, // A different (2017) DOS date, to prove the extra wins
+ }));
+ assert.strictEqual(entry.modified.getTime(), MTIME_SECS * 1000);
+});
+
+test('the NTFS extra field (0x000a) sets a high-resolution modification time', () => {
+ const ns100 = (BigInt(MTIME_SECS) + 11644473600n) * 10000000n;
+ const times = Buffer.alloc(24);
+ times.writeBigUInt64LE(ns100, 0); // mtime
+ times.writeBigUInt64LE(ns100, 8); // atime
+ times.writeBigUInt64LE(ns100, 16); // ctime
+ const body = Buffer.concat([
+ Buffer.alloc(4), // reserved
+ extField(0x0001, times), // attribute tag 1
+ ]);
+ const [entry] = zlib.ZipEntry.read(buildEntryArchive({
+ nameBuffer: Buffer.from('f'),
+ localExtra: extField(0x000a, body), // NTFS times live in the local header
+ }));
+ assert.strictEqual(entry.modified.getTime(), MTIME_SECS * 1000);
+});
+
+test('the Info-ZIP Unix extra field (0x5855) sets the modification time', () => {
+ const body = Buffer.alloc(8);
+ body.writeInt32LE(1, 0); // atime
+ body.writeInt32LE(MTIME_SECS, 4); // mtime
+ const [entry] = zlib.ZipEntry.read(buildEntryArchive({
+ nameBuffer: Buffer.from('f'),
+ centralExtra: extField(0x5855, body),
+ }));
+ assert.strictEqual(entry.modified.getTime(), MTIME_SECS * 1000);
+});
+
+test('NTFS time is preferred over the extended timestamp when both are present', () => {
+ const extBody = Buffer.alloc(5);
+ extBody.writeUInt8(0x01, 0);
+ extBody.writeInt32LE(MTIME_SECS - 3600, 1); // an hour earlier
+ const ns100 = (BigInt(MTIME_SECS) + 11644473600n) * 10000000n;
+ const times = Buffer.alloc(24);
+ times.writeBigUInt64LE(ns100, 0);
+ const ntfsBody = Buffer.concat([Buffer.alloc(4), extField(0x0001, times)]);
+ const [entry] = zlib.ZipEntry.read(buildEntryArchive({
+ nameBuffer: Buffer.from('f'),
+ localExtra: Buffer.concat([extField(0x000a, ntfsBody), extField(0x5455, extBody)]),
+ }));
+ assert.strictEqual(entry.modified.getTime(), MTIME_SECS * 1000);
+});
+
+test('with no timestamp extra field the DOS date/time is used', () => {
+ // DOS date 0x4f21 = year 1980+((0x4f21>>9)&0x7f)=1980+39=2019, month 1, day 1.
+ const [entry] = zlib.ZipEntry.read(buildEntryArchive({
+ nameBuffer: Buffer.from('f'),
+ dosDate: 0x4f21,
+ }));
+ assert.strictEqual(entry.modified.getFullYear(), 2019);
+});
diff --git a/test/parallel/test-zlib-zip-files.js b/test/parallel/test-zlib-zip-files.js
new file mode 100644
index 00000000000000..50a442206a8ff7
--- /dev/null
+++ b/test/parallel/test-zlib-zip-files.js
@@ -0,0 +1,118 @@
+'use strict';
+
+// zlib.zipFiles(): build an archive from files on disk, capturing each file's
+// mode and modification time, streaming regular-file contents, and either
+// following symlinks (default) or storing them as symlink entries.
+
+const common = require('../common');
+
+const assert = require('node:assert');
+const zlib = require('node:zlib');
+const fsp = require('node:fs/promises');
+const tmpdir = require('../common/tmpdir');
+const path = require('node:path');
+const { test } = require('node:test');
+
+tmpdir.refresh();
+
+async function collect(readable) {
+ const chunks = [];
+ for await (const chunk of readable) chunks.push(chunk);
+ return Buffer.concat(chunks);
+}
+
+async function makeTree() {
+ const dir = await fsp.mkdtemp(path.join(tmpdir.path, 'zlib-zipfiles-'));
+ await fsp.writeFile(path.join(dir, 'a.txt'), 'alpha');
+ await fsp.chmod(path.join(dir, 'a.txt'), 0o640);
+ await fsp.mkdir(path.join(dir, 'sub'));
+ await fsp.writeFile(path.join(dir, 'sub', 'b.bin'), Buffer.from([1, 2, 3, 4]));
+ return dir;
+}
+
+test('zipFiles archives files, directories, contents and Unix mode from disk', async () => {
+ const dir = await makeTree();
+ try {
+ const files = [
+ [path.join(dir, 'a.txt'), 'a.txt'],
+ [path.join(dir, 'sub'), 'sub'],
+ [path.join(dir, 'sub', 'b.bin'), 'nested/b.bin'],
+ ];
+ using zip = new zlib.ZipBuffer(await collect(zlib.zipFiles(files)));
+ assert.deepStrictEqual([...zip.keys()].sort(), ['a.txt', 'nested/b.bin', 'sub/']);
+ assert.strictEqual(zip.get('a.txt').contentSync().toString(), 'alpha');
+ assert.strictEqual(zip.get('a.txt').mode, 0o640);
+ assert.strictEqual(zip.get('sub/').isDirectory, true);
+ assert.deepStrictEqual([...zip.get('nested/b.bin').contentSync()], [1, 2, 3, 4]);
+ } finally {
+ await fsp.rm(dir, { recursive: true, force: true });
+ }
+});
+
+test('zipFiles accepts any iterable of [path, name] (array, Map, Object.entries)', async () => {
+ const dir = await makeTree();
+ try {
+ const p = path.join(dir, 'a.txt');
+ const inputs = [
+ [[p, 'a.txt']], // array of pairs
+ new Map([[p, 'a.txt']]), // Map
+ Object.entries({ [p]: 'a.txt' }), // Object.entries
+ ];
+ for (const files of inputs) {
+ using zip = new zlib.ZipBuffer(await collect(zlib.zipFiles(files)));
+ assert.strictEqual(zip.get('a.txt').contentSync().toString(), 'alpha');
+ }
+ } finally {
+ await fsp.rm(dir, { recursive: true, force: true });
+ }
+});
+
+test('zipFiles preserves a sub-second modification time via an extra field', async () => {
+ const dir = await makeTree();
+ try {
+ const p = path.join(dir, 'a.txt');
+ // 1-second-and-a-half past an epoch second, so the DOS field alone can't
+ // represent it and the extended-timestamp extra field is what carries it.
+ const when = new Date(1700000000500);
+ await fsp.utimes(p, when, when);
+ using zip = new zlib.ZipBuffer(await collect(zlib.zipFiles([[p, 'a.txt']])));
+ assert.strictEqual(zip.get('a.txt').modified.getTime(), 1700000000000);
+ } finally {
+ await fsp.rm(dir, { recursive: true, force: true });
+ }
+});
+
+test('zipFiles follows symlinks by default, or stores them when disabled', {
+ skip: !common.canCreateSymLink() && 'insufficient privileges to create symlinks',
+}, async () => {
+ const dir = await makeTree();
+ try {
+ await fsp.symlink('a.txt', path.join(dir, 'link'));
+ const files = [[path.join(dir, 'link'), 'link']];
+
+ // Default: the link is resolved and archived as the target file.
+ using followed = new zlib.ZipBuffer(await collect(zlib.zipFiles(files)));
+ assert.strictEqual(followed.get('link').isSymlink, false);
+ assert.strictEqual(followed.get('link').isFile, true);
+ assert.strictEqual(followed.get('link').contentSync().toString(), 'alpha');
+
+ // followSymlinks: false: the link itself becomes a symlink entry.
+ using stored = new zlib.ZipBuffer(await collect(zlib.zipFiles(files, { followSymlinks: false })));
+ const link = stored.get('link');
+ assert.strictEqual(link.isSymlink, true);
+ assert.strictEqual(link.isFile, false);
+ assert.strictEqual(link.contentSync().toString(), 'a.txt');
+ } finally {
+ await fsp.rm(dir, { recursive: true, force: true });
+ }
+});
+
+test('zipFiles rejects when a listed path does not exist', async () => {
+ const dir = await makeTree();
+ try {
+ await assert.rejects(collect(zlib.zipFiles([[path.join(dir, 'missing'), 'x']])),
+ { code: 'ENOENT' });
+ } finally {
+ await fsp.rm(dir, { recursive: true, force: true });
+ }
+});
diff --git a/test/parallel/test-zlib-zip-fuzz.js b/test/parallel/test-zlib-zip-fuzz.js
new file mode 100644
index 00000000000000..3b45f7af7dce56
--- /dev/null
+++ b/test/parallel/test-zlib-zip-fuzz.js
@@ -0,0 +1,85 @@
+'use strict';
+
+require('../common');
+
+const assert = require('node:assert');
+const zlib = require('node:zlib');
+const { test } = require('node:test');
+
+function mulberry32(seed) {
+ let state = seed >>> 0;
+ return function() {
+ state = (state + 0x6d2b79f5) >>> 0;
+ let t = state;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+async function buildSeedArchive() {
+ const entries = [
+ await zlib.ZipEntry.create('hello.txt', Buffer.from('Hello, world!'.repeat(30))),
+ await zlib.ZipEntry.create('raw.bin', Buffer.from([1, 2, 3, 4, 5]), { method: 'store' }),
+ await zlib.ZipEntry.create('empty/', Buffer.alloc(0)),
+ ];
+ const chunks = [];
+ for await (const chunk of zlib.createZipArchive(entries, 'seed archive')) chunks.push(chunk);
+ return Buffer.concat(chunks);
+}
+
+function mutate(random, seed) {
+ const buf = Buffer.from(seed);
+ const kind = Math.floor(random() * 4);
+ if (kind === 0) {
+ // Flip a handful of random bits.
+ const flips = 1 + Math.floor(random() * 8);
+ for (let i = 0; i < flips; i++) {
+ buf[Math.floor(random() * buf.length)] ^= 1 << Math.floor(random() * 8);
+ }
+ } else if (kind === 1) {
+ // Overwrite a random window with boundary-ish values.
+ const boundary = [0x00, 0xff, 0x50, 0x4b][Math.floor(random() * 4)];
+ const start = Math.floor(random() * buf.length);
+ const len = Math.min(buf.length - start, 1 + Math.floor(random() * 8));
+ buf.fill(boundary, start, start + len);
+ } else if (kind === 2) {
+ // Truncate.
+ return buf.subarray(0, Math.floor(random() * buf.length));
+ } else {
+ // Extend with random padding.
+ const pad = Buffer.allocUnsafe(1 + Math.floor(random() * 16));
+ for (let i = 0; i < pad.length; i++) pad[i] = Math.floor(random() * 256);
+ return Buffer.concat([buf, pad]);
+ }
+ return buf;
+}
+
+test('the parser only ever throws Error on mutated archives, never crashes or hangs', async () => {
+ const seed = await buildSeedArchive();
+ const random = mulberry32(0x5EED1234);
+ const iterations = 3000;
+
+ for (let i = 0; i < iterations; i++) {
+ const candidate = mutate(random, seed);
+ try {
+ const entries = [...zlib.ZipEntry.read(candidate)];
+ for (const entry of entries) {
+ try {
+ await entry.content();
+ } catch (err) {
+ assert.ok(err instanceof Error, `content() threw non-Error: ${err}`);
+ }
+ }
+ } catch (err) {
+ assert.ok(err instanceof Error, `read() threw non-Error: ${err}`);
+ }
+
+ try {
+ using zipBuffer = new zlib.ZipBuffer(candidate);
+ assert.ok(zipBuffer.size >= 0);
+ } catch (err) {
+ assert.ok(err instanceof Error, `ZipBuffer threw non-Error: ${err}`);
+ }
+ }
+}, { timeout: 60_000 });
diff --git a/test/parallel/test-zlib-zip-hardening.js b/test/parallel/test-zlib-zip-hardening.js
new file mode 100644
index 00000000000000..de2a9efd491867
--- /dev/null
+++ b/test/parallel/test-zlib-zip-hardening.js
@@ -0,0 +1,376 @@
+'use strict';
+
+require('../common');
+
+const assert = require('node:assert');
+const zlib = require('node:zlib');
+const { test } = require('node:test');
+
+async function buildArchive(entries, comment) {
+ const chunks = [];
+ for await (const chunk of zlib.createZipArchive(entries, comment)) chunks.push(chunk);
+ return Buffer.concat(chunks);
+}
+
+function buildEocd({ diskNumber = 0, cdDiskNumber = 0, cdDiskRecords = 0,
+ totalRecords = 0, cdSize = 0, cdOffset = 0, comment = Buffer.alloc(0) } = {}) {
+ const buf = Buffer.allocUnsafe(22 + comment.length);
+ buf.writeUInt32LE(0x06054b50, 0);
+ buf.writeUInt16LE(diskNumber, 4);
+ buf.writeUInt16LE(cdDiskNumber, 6);
+ buf.writeUInt16LE(cdDiskRecords, 8);
+ buf.writeUInt16LE(totalRecords, 10);
+ buf.writeUInt32LE(cdSize, 12);
+ buf.writeUInt32LE(cdOffset, 16);
+ buf.writeUInt16LE(comment.length, 20);
+ comment.copy(buf, 22);
+ return buf;
+}
+
+test('an empty or tiny buffer is rejected as an invalid archive', () => {
+ for (const buf of [Buffer.alloc(0), Buffer.alloc(10), Buffer.from('not a zip')]) {
+ assert.throws(() => [...zlib.ZipEntry.read(buf)], { code: 'ERR_ZIP_INVALID_ARCHIVE' });
+ }
+});
+
+test('garbage or truncated data is rejected', () => {
+ const garbage = Buffer.alloc(100, 0x41);
+ assert.throws(() => [...zlib.ZipEntry.read(garbage)], { code: 'ERR_ZIP_INVALID_ARCHIVE' });
+
+ const truncated = buildEocd({ totalRecords: 5, cdSize: 46 * 5 }).subarray(0, 10);
+ assert.throws(() => [...zlib.ZipEntry.read(truncated)], { code: 'ERR_ZIP_INVALID_ARCHIVE' });
+});
+
+test('an EOCD-looking signature inside a trailing comment is not mistaken for the real one', async () => {
+ const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hi'), { method: 'store' });
+ // The comment scan walks backward through the trailing comment bytes
+ // before it reaches the genuine EOCD signature; embedding 4 bytes that
+ // look like one partway through must not be mistaken for the real record.
+ const fakeSignature = String.fromCharCode(0x50, 0x4b, 0x05, 0x06);
+ const archive = await buildArchive([entry], `before ${fakeSignature} after`);
+
+ const read = [...zlib.ZipEntry.read(archive)];
+ assert.strictEqual(read.length, 1);
+ assert.strictEqual(read[0].name, 'f.txt');
+});
+
+test('a declared-size mismatch is rejected as corrupt', async () => {
+ const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello world'), { method: 'store' });
+ const archive = await buildArchive([entry]);
+
+ // Shrink the *declared* uncompressed size in the central directory record
+ // without touching the stored bytes themselves, so the amount of data
+ // produced no longer matches what the header promised.
+ const tampered = Buffer.from(archive);
+ const centralHeaderStart = 30 + 'f.txt'.length + 'hello world'.length;
+ const uncompressedSizeOffset = centralHeaderStart + 24;
+ tampered.writeUInt32LE(1, uncompressedSizeOffset);
+
+ const [tamperedEntry] = zlib.ZipEntry.read(tampered);
+ assert.strictEqual(tamperedEntry.size, 1);
+ await assert.rejects(tamperedEntry.content(), { code: 'ERR_ZIP_ENTRY_CORRUPT' });
+});
+
+test('CRC-32 verification catches a single flipped byte, and can be disabled', async () => {
+ const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello world'), { method: 'store' });
+ const archive = await buildArchive([entry]);
+ const tampered = Buffer.from(archive);
+ const contentStart = 30 + 'f.txt'.length;
+ tampered[contentStart] ^= 0xff;
+
+ const [tamperedEntry] = zlib.ZipEntry.read(tampered);
+ await assert.rejects(tamperedEntry.content(), { code: 'ERR_ZIP_ENTRY_CORRUPT' });
+ const unverified = await tamperedEntry.content({ verify: false });
+ assert.strictEqual(unverified.length, 'hello world'.length);
+});
+
+test('content() enforces maxSize before allocating', async () => {
+ const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello world'));
+ await assert.rejects(entry.content({ maxSize: 1 }), { code: 'ERR_ZIP_ENTRY_TOO_LARGE' });
+});
+
+test('a forged small header whose content inflates past its declared size is rejected', async () => {
+ // The up-front maxSize check trusts the declared size, so a bomb forges a
+ // tiny declared size to clear it; the decompressor's maxOutputLength
+ // backstop is capped at declared + 1 bytes, so the lie is caught (as
+ // corruption - the declared size is provably wrong) without ever
+ // materializing more than the declared size, no matter how large maxSize
+ // is.
+ for (const { method, re } of [
+ { method: 'deflate', re: /inflates beyond its declared size/ },
+ { method: 'zstd', re: /decompresses beyond its declared size/ },
+ ]) {
+ const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('x'.repeat(5000)), { method });
+ const archive = await buildArchive([entry]);
+ const tampered = Buffer.from(archive);
+ const eocd = tampered.length - 22; // No comment, so EOCD is the last 22 bytes
+ const cdOffset = tampered.readUInt32LE(eocd + 16);
+ tampered.writeUInt32LE(50, cdOffset + 24); // Forge declared uncompressedSize
+
+ const [e] = zlib.ZipEntry.read(tampered);
+ assert.strictEqual(e.size, 50); // 50 <= maxSize 100 clears the up-front check
+ await assert.rejects(e.content({ maxSize: 100 }), { code: 'ERR_ZIP_ENTRY_CORRUPT', message: re });
+ assert.throws(() => e.contentSync({ maxSize: 100 }), { code: 'ERR_ZIP_ENTRY_CORRUPT', message: re });
+ }
+});
+
+test('getMaxZipContentSize()/setMaxZipContentSize() control the default guard', async () => {
+ const original = zlib.getMaxZipContentSize();
+ try {
+ zlib.setMaxZipContentSize(1);
+ const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello world'));
+ await assert.rejects(entry.content(), { code: 'ERR_ZIP_ENTRY_TOO_LARGE' });
+ } finally {
+ zlib.setMaxZipContentSize(original);
+ }
+ assert.strictEqual(zlib.getMaxZipContentSize(), original);
+});
+
+test('streaming a partially-consumed entry does not hang or leak', async () => {
+ async function* source() {
+ for (let i = 0; i < 1000; i++) {
+ yield Buffer.alloc(1024, i & 0xff);
+ }
+ }
+ const entry = zlib.ZipEntry.createStream('big.bin', source());
+ let count = 0;
+ let bytesSeen = 0;
+ for await (const chunk of entry) {
+ count++;
+ bytesSeen += chunk.length;
+ if (count > 2) break;
+ }
+ assert.ok(count > 2);
+ assert.ok(bytesSeen > 0);
+});
+
+test('an overlong file name is rejected', async () => {
+ await assert.rejects(
+ zlib.ZipEntry.create('x'.repeat(70000), Buffer.alloc(0)),
+ { code: 'ERR_ZIP_ENTRY_TOO_LARGE' },
+ );
+});
+
+test('an empty file name is rejected', async () => {
+ await assert.rejects(
+ zlib.ZipEntry.create('', Buffer.alloc(0)),
+ { code: 'ERR_INVALID_ARG_VALUE' },
+ );
+});
+
+test('a multi-disk archive is rejected', () => {
+ const eocd = buildEocd({ diskNumber: 1, cdDiskNumber: 1 });
+ assert.throws(() => [...zlib.ZipEntry.read(eocd)], { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' });
+});
+
+test('an encrypted entry is rejected', async () => {
+ const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('secret'), { method: 'store' });
+ const archive = await buildArchive([entry]);
+ // Set the encrypted bit (bit 0) in both the local and central header flags.
+ const tampered = Buffer.from(archive);
+ const localFlagsOffset = 6;
+ tampered.writeUInt16LE(tampered.readUInt16LE(localFlagsOffset) | 0x0001, localFlagsOffset);
+ const centralHeaderStart = 30 + 'f.txt'.length + 'secret'.length;
+ const centralFlagsOffset = centralHeaderStart + 8;
+ tampered.writeUInt16LE(tampered.readUInt16LE(centralFlagsOffset) | 0x0001, centralFlagsOffset);
+
+ const [tamperedEntry] = zlib.ZipEntry.read(tampered);
+ await assert.rejects(tamperedEntry.content(), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' });
+});
+
+test('an entry using an unsupported compression method is rejected', async () => {
+ const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hi'), { method: 'store' });
+ const archive = await buildArchive([entry]);
+ // Set the method to 1 (Shrunk), which this implementation does not support,
+ // in both the local and central headers.
+ const tampered = Buffer.from(archive);
+ const localMethodOffset = 8;
+ tampered.writeUInt16LE(1, localMethodOffset);
+ const centralHeaderStart = 30 + 'f.txt'.length + 'hi'.length;
+ const centralMethodOffset = centralHeaderStart + 10;
+ tampered.writeUInt16LE(1, centralMethodOffset);
+
+ const [tamperedEntry] = zlib.ZipEntry.read(tampered);
+ assert.strictEqual(tamperedEntry.method, 1);
+ await assert.rejects(tamperedEntry.content(), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' });
+ assert.throws(() => tamperedEntry.contentSync(), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' });
+});
+
+// -- shapes borrowed from CPython's zipfile test corpus (synthesized bytes) ------
+
+test('every possible truncation of an archive is rejected, deterministically', async () => {
+ // CPython's test_damaged_zipfile: any prefix of a valid archive must fail
+ // cleanly (never succeed, never throw a non-Error). The fuzz test samples
+ // truncation points randomly; this pins all of them for a small archive.
+ const archive = await buildArchive(
+ [await zlib.ZipEntry.create('f.txt', Buffer.from('hello world'), { method: 'store' })]);
+ for (let n = 0; n < archive.length; n++) {
+ assert.throws(() => [...zlib.ZipEntry.read(archive.subarray(0, n))],
+ { code: /^ERR_ZIP_/ }, `truncation at ${n} bytes`);
+ }
+});
+
+test('trailing padding after the EOCD is tolerated', async () => {
+ // Some streaming writers pad their output to a block size; CPython
+ // tolerates trailing newlines/NULs and so does the pass-2 EOCD scan.
+ const archive = await buildArchive(
+ [await zlib.ZipEntry.create('f.txt', Buffer.from('hi'), { method: 'store' })]);
+ const padded = Buffer.concat([archive, Buffer.from('\r\n\0\0\0')]);
+ const [entry] = zlib.ZipEntry.read(padded);
+ assert.strictEqual(entry.name, 'f.txt');
+ assert.strictEqual((await entry.content()).toString(), 'hi');
+});
+
+test('junk appended past a declared comment is tolerated and the comment preserved', async () => {
+ const archive = await buildArchive(
+ [await zlib.ZipEntry.create('f.txt', Buffer.from('hi'), { method: 'store' })],
+ 'this is a comment');
+ const appended = Buffer.concat([archive, Buffer.from('abcdef\r\n')]);
+ const zip = new zlib.ZipBuffer(appended);
+ assert.strictEqual(zip.comment, 'this is a comment');
+ assert.strictEqual(zip.get('f.txt').contentSync().toString(), 'hi');
+});
+
+test('an EOCD comment length overrunning the end of the file is rejected', async () => {
+ // CPython's _EndRecData silently returns a truncated comment here; this
+ // implementation deliberately rejects the candidate instead (both scan
+ // passes require the declared comment to fit), so the archive has no
+ // recognizable EOCD at all.
+ const comment = 'padding-padding-padding';
+ const archive = Buffer.from(await buildArchive([], comment));
+ const eocdOffset = archive.length - 22 - comment.length;
+ assert.strictEqual(archive.readUInt32LE(eocdOffset), 0x06054b50);
+ archive.writeUInt16LE(comment.length + 10, eocdOffset + 20);
+ assert.throws(() => [...zlib.ZipEntry.read(archive)],
+ { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /no end of central directory/ });
+});
+
+test('a central directory that cannot fit before the EOCD is rejected', () => {
+ // CPython: "negative central directory offset". The declared size/offset
+ // put the directory past the EOCD, so the prefix computation goes
+ // negative.
+ const eocd = buildEocd({ cdDiskRecords: 1, totalRecords: 1, cdSize: 1, cdOffset: 0xffffff });
+ assert.throws(() => [...zlib.ZipEntry.read(eocd)],
+ { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /does not fit/ });
+});
+
+test('a record count inconsistent with the directory size is rejected', () => {
+ // 100 records cannot fit in 46 bytes of central directory. Filler bytes
+ // ahead of the EOCD stand in for that region so the directory nominally
+ // fits inside the archive and the count check is what fires.
+ const eocd = buildEocd({ cdDiskRecords: 100, totalRecords: 100, cdSize: 46 });
+ const archive = Buffer.concat([Buffer.alloc(46), eocd]);
+ assert.throws(() => [...zlib.ZipEntry.read(archive)],
+ { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /inconsistent/ });
+});
+
+test('a corrupted or overrunning central directory header is rejected', async () => {
+ const nameA = 'a.txt';
+ const nameB = 'b.txt';
+ const content = Buffer.from('x');
+ const two = await buildArchive([
+ await zlib.ZipEntry.create(nameA, content, { method: 'store' }),
+ await zlib.ZipEntry.create(nameB, content, { method: 'store' }),
+ ]);
+ const memberSize = 30 + nameA.length + content.length;
+ const centralStart = 2 * memberSize;
+ const centralRecord = 46 + nameA.length;
+
+ // Zero the second central record's signature ("bad magic number").
+ const badMagic = Buffer.from(two);
+ badMagic.writeUInt32LE(0, centralStart + centralRecord);
+ assert.throws(() => [...zlib.ZipEntry.read(badMagic)],
+ { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /signature is invalid/ });
+
+ // Grow the first central record's name length so it overruns the declared
+ // central directory size ("truncated central directory").
+ const overrun = Buffer.from(two);
+ overrun.writeUInt16LE(2000, centralStart + 28);
+ assert.throws(() => [...zlib.ZipEntry.read(overrun)], { code: 'ERR_ZIP_INVALID_ARCHIVE' });
+});
+
+test('short or overrunning extra-field records do not make an entry unreadable', async () => {
+ // CPython's test_zipfile_with_short_extra_field shape: advisory extra
+ // metadata that is malformed is skipped, never fatal.
+ const name = 'f.txt';
+ const content = Buffer.from('hi');
+ const base = await buildArchive(
+ [await zlib.ZipEntry.create(name, content, { method: 'store' })]);
+ const centralHeaderStart = 30 + name.length + content.length;
+ for (const extra of [
+ Buffer.from([0x99]), // Shorter than a TLV header
+ Buffer.from([0x99, 0x99, 0xff]), // Still shorter than a TLV header
+ Buffer.from([0x99, 0x99, 0xff, 0xff]), // Declared length overruns wildly
+ ]) {
+ const before = base.subarray(0, centralHeaderStart + 46 + name.length);
+ const after = base.subarray(centralHeaderStart + 46 + name.length);
+ const patched = Buffer.concat([before, extra, after]);
+ patched.writeUInt16LE(extra.length, centralHeaderStart + 30);
+ const eocdOffset = patched.length - 22;
+ patched.writeUInt32LE(patched.readUInt32LE(eocdOffset + 12) + extra.length, eocdOffset + 12);
+ const [entry] = zlib.ZipEntry.read(patched);
+ assert.strictEqual(entry.name, name);
+ assert.strictEqual((await entry.content()).toString(), 'hi');
+ }
+});
+
+test('two central records quoting the same data are rejected as a possible zip bomb', async () => {
+ // The "quoted overlap" bomb shape (CVE-2024-0450 in other
+ // implementations): N central records pointing at one region turn a tiny
+ // file into N full-size extractions.
+ const nameA = 'a.txt';
+ const nameB = 'b.txt';
+ const content = Buffer.from('x');
+ const two = Buffer.from(await buildArchive([
+ await zlib.ZipEntry.create(nameA, content, { method: 'store' }),
+ await zlib.ZipEntry.create(nameB, content, { method: 'store' }),
+ ]));
+ const memberSize = 30 + nameA.length + content.length;
+ const centralStart = 2 * memberSize;
+ const centralRecord = 46 + nameA.length;
+ // Point the second record's local header at the first member's.
+ two.writeUInt32LE(0, centralStart + centralRecord + 42);
+ assert.throws(() => [...zlib.ZipEntry.read(two)],
+ { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /possible zip bomb/ });
+});
+
+test('a member whose data crosses into the central directory is rejected', async () => {
+ const name = 'f.txt';
+ const content = Buffer.from('hello');
+ const archive = Buffer.from(await buildArchive(
+ [await zlib.ZipEntry.create(name, content, { method: 'store' })]));
+ const centralHeaderStart = 30 + name.length + content.length;
+ // Lie about the compressed size so the member's data range reaches into
+ // the central directory (while staying inside the buffer).
+ archive.writeUInt32LE(content.length + 40, centralHeaderStart + 20);
+ assert.throws(() => [...zlib.ZipEntry.read(archive)],
+ { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /possible zip bomb/ });
+});
+
+test('a Zip64 EOCD record size-field lie is tolerated when the locator is correct', () => {
+ // The record's own size field is only consulted by the backward recovery
+ // scan; when the locator points straight at a valid record, a lying size
+ // field (CPython rejects 0 or 100 here) does not matter.
+ const record = Buffer.alloc(56);
+ record.writeUInt32LE(0x06064b50, 0);
+ record.writeBigUInt64LE(100n, 4); // Lie: the remainder is 44 bytes
+ record.writeUInt16LE((3 << 8) | 45, 12);
+ record.writeUInt16LE(45, 14);
+ const locator = Buffer.alloc(20);
+ locator.writeUInt32LE(0x07064b50, 0);
+ locator.writeBigUInt64LE(0n, 8);
+ locator.writeUInt32LE(1, 16);
+ const eocd = buildEocd({ cdSize: 0xffffffff });
+ const zip = new zlib.ZipBuffer(Buffer.concat([record, locator, eocd]));
+ assert.strictEqual(zip.size, 0);
+});
+
+test('a NUL byte inside an entry name is preserved verbatim', async () => {
+ // CPython truncates the name at the NUL on read; this implementation
+ // surfaces names verbatim and leaves interpretation to the caller.
+ const name = 'foo\x00bar.txt';
+ const archive = await buildArchive(
+ [await zlib.ZipEntry.create(name, Buffer.from('x'), { method: 'store' })]);
+ const [entry] = zlib.ZipEntry.read(archive);
+ assert.strictEqual(entry.name, name);
+});
diff --git a/test/parallel/test-zlib-zip-internals.js b/test/parallel/test-zlib-zip-internals.js
new file mode 100644
index 00000000000000..78316ec370244b
--- /dev/null
+++ b/test/parallel/test-zlib-zip-internals.js
@@ -0,0 +1,53 @@
+// Flags: --expose-internals
+'use strict';
+
+require('../common');
+
+// Directly exercises defense-in-depth guards in lib/internal/zip/ that the
+// public API cannot reach on this platform - they exist for 32-bit limits,
+// or for hypothetical callers that skip the pre-validation every current
+// call site performs - so their behavior stays pinned down.
+
+const assert = require('node:assert');
+const { test } = require('node:test');
+const zlib = require('node:zlib');
+
+const { readSafeUint64 } = require('internal/zip/binary');
+const { parseZip64Extra } = require('internal/zip/extra-fields');
+const { LocalFileHeader } = require('internal/zip/headers');
+const { decodeMemberSync } = require('internal/zip/compression');
+
+test('readSafeUint64 rejects a field extending past the buffer', () => {
+ // Every current caller validates the containing range first; the guard
+ // protects future call sites.
+ assert.throws(() => readSafeUint64(Buffer.alloc(7), 0),
+ { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /out of bounds/ });
+});
+
+test('parseZip64Extra returns nothing when no field is wanted', () => {
+ // Reachable only if a caller asks with no overflow sentinel present;
+ // CentralFileHeader always wants at least one field when it calls.
+ assert.deepStrictEqual(parseZip64Extra(Buffer.alloc(0), {}), {});
+});
+
+test('LocalFileHeader.length reports 0 when the fixed part does not fit', () => {
+ // The only current caller passes exactly 30 bytes, so the short-buffer
+ // branch cannot trigger through it.
+ assert.strictEqual(LocalFileHeader.length(Buffer.alloc(10), 0), 0);
+});
+
+test('decodeMemberSync bounds output by the declared size without a caller limit', () => {
+ // The public wrappers always pass maxSize (defaulted); without one, the
+ // declared-size cap alone must stop an overrun.
+ const data = Buffer.alloc(4096, 0x61);
+ const compressed = zlib.deflateRawSync(data);
+ const info = {
+ name: 'lie.txt',
+ flags: 0,
+ method: 8,
+ crc32: zlib.crc32(data),
+ uncompressedSize: 16, // Lie: it inflates to 4096 bytes
+ };
+ assert.throws(() => decodeMemberSync(compressed, info),
+ { code: 'ERR_ZIP_ENTRY_CORRUPT', message: /beyond its declared size/ });
+});
diff --git a/test/parallel/test-zlib-zip-interop.js b/test/parallel/test-zlib-zip-interop.js
new file mode 100644
index 00000000000000..b47ac27f94443d
--- /dev/null
+++ b/test/parallel/test-zlib-zip-interop.js
@@ -0,0 +1,128 @@
+'use strict';
+
+require('../common');
+const assert = require('node:assert');
+const zlib = require('node:zlib');
+const fs = require('node:fs/promises');
+const path = require('node:path');
+const tmpdir = require('../common/tmpdir');
+const { spawnSync } = require('node:child_process');
+const { test } = require('node:test');
+
+tmpdir.refresh();
+
+function hasTool(command, args = ['--help']) {
+ try {
+ const result = spawnSync(command, args, { stdio: 'ignore' });
+ return result.error === undefined;
+ } catch {
+ return false;
+ }
+}
+
+// Rendering a UTF-8 entry name (general-purpose bit 11, which we set) through
+// Info-ZIP unzip/zipinfo needs two things that are independent of whether the
+// archive is well-formed: (1) the build must be compiled with UNICODE_SUPPORT
+// - macOS's bundled Info-ZIP is not, and mangles such names no matter what -
+// and (2) at runtime the name is converted to the process locale, which drops
+// or garbles non-ASCII bytes under the "C"/"POSIX" locale. We detect (1) and
+// pick an installed UTF-8 locale for (2); when unzip lacks UNICODE_SUPPORT the
+// non-ASCII name assertion is skipped (its round-trip is still covered by
+// bsdtar/libarchive, which handles UTF-8 correctly regardless).
+function findUtf8Locale() {
+ const result = spawnSync('locale', ['-a'], { encoding: 'utf8' });
+ if (result.status !== 0 || !result.stdout) return null;
+ const locales = result.stdout.split('\n').map((line) => line.trim());
+ const preferred = ['C.UTF-8', 'C.utf8', 'en_US.UTF-8', 'en_US.utf8'];
+ for (const name of preferred) {
+ if (locales.includes(name)) return name;
+ }
+ return locales.find((name) => /utf-?8$/i.test(name)) || null;
+}
+
+test('an archive written by createZipArchive is readable by unzip, zipinfo, and bsdtar', async (t) => {
+ if (!hasTool('unzip', ['-v']) || !hasTool('zipinfo', ['-v']) || !hasTool('bsdtar', ['--version'])) {
+ t.skip('unzip, zipinfo, or bsdtar is not available');
+ return;
+ }
+ const locale = findUtf8Locale();
+ if (!locale) {
+ t.skip('no UTF-8 locale is available to render non-ASCII entry names');
+ return;
+ }
+ const env = { ...process.env, LANG: locale, LC_ALL: locale };
+ // Only assert non-ASCII names through zipinfo when this Info-ZIP build can
+ // actually render them (see the note above).
+ const unzipBuild = spawnSync('unzip', ['-v'], { encoding: 'utf8' }).stdout || '';
+ const zipinfoUnicode = /UNICODE_SUPPORT/.test(unzipBuild);
+
+ const dir = await fs.mkdtemp(path.join(tmpdir.path, 'zlib-zip-interop-'));
+ try {
+ const files = {
+ 'hello.txt': Buffer.from('Hello, world!'.repeat(50)),
+ 'raw.bin': Buffer.from([1, 2, 3, 4, 5]),
+ 'unicode-名前.txt': Buffer.from('unicode name'),
+ };
+ const entries = [];
+ for (const [name, data] of Object.entries(files)) {
+ entries.push(await zlib.ZipEntry.create(name, data));
+ }
+ const chunks = [];
+ for await (const chunk of zlib.createZipArchive(entries)) chunks.push(chunk);
+ const archivePath = path.join(dir, 'archive.zip');
+ await fs.writeFile(archivePath, Buffer.concat(chunks));
+
+ const unzipTest = spawnSync('unzip', ['-t', archivePath], { env });
+ assert.strictEqual(unzipTest.status, 0, unzipTest.stderr?.toString());
+
+ const zipinfo = spawnSync('zipinfo', [archivePath], { env });
+ assert.strictEqual(zipinfo.status, 0);
+ for (const name of Object.keys(files)) {
+ if (!zipinfoUnicode && /[^\x20-\x7e]/.test(name)) continue;
+ assert.ok(zipinfo.stdout.toString().includes(name), `zipinfo missing ${name}`);
+ }
+
+ const extractDir = path.join(dir, 'out');
+ await fs.mkdir(extractDir);
+ const bsdtar = spawnSync('bsdtar', ['-xf', archivePath, '-C', extractDir], { env });
+ assert.strictEqual(bsdtar.status, 0, bsdtar.stderr?.toString());
+ for (const [name, data] of Object.entries(files)) {
+ const extracted = await fs.readFile(path.join(extractDir, name));
+ assert.deepStrictEqual(extracted, data);
+ }
+ } finally {
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+});
+
+test('an archive written by Python\'s zipfile module is readable by ZipFile', async (t) => {
+ if (!hasTool('python3', ['--version'])) {
+ t.skip('python3 is not available');
+ return;
+ }
+
+ const dir = await fs.mkdtemp(path.join(tmpdir.path, 'zlib-zip-interop-'));
+ try {
+ const archivePath = path.join(dir, 'python.zip');
+ const script = `
+import zipfile
+with zipfile.ZipFile(${JSON.stringify(archivePath)}, 'w') as z:
+ z.writestr('a.txt', 'hello from python')
+ z.writestr('b.bin', bytes(range(256)))
+`;
+ const result = spawnSync('python3', ['-c', script]);
+ assert.strictEqual(result.status, 0, result.stderr?.toString());
+
+ const zip = await zlib.ZipFile.open(archivePath);
+ try {
+ const a = await zip.get('a.txt');
+ assert.strictEqual((await a.content()).toString(), 'hello from python');
+ const b = await zip.get('b.bin');
+ assert.deepStrictEqual(await b.content(), Buffer.from(Array.from({ length: 256 }, (_, i) => i)));
+ } finally {
+ await zip.close();
+ }
+ } finally {
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+});
diff --git a/test/parallel/test-zlib-zip-metadata.js b/test/parallel/test-zlib-zip-metadata.js
new file mode 100644
index 00000000000000..760b28414552fe
--- /dev/null
+++ b/test/parallel/test-zlib-zip-metadata.js
@@ -0,0 +1,96 @@
+'use strict';
+
+require('../common');
+
+const assert = require('node:assert');
+const zlib = require('node:zlib');
+const { test } = require('node:test');
+
+async function roundTrip(options) {
+ const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('x'), options);
+ const chunks = [];
+ for await (const chunk of zlib.createZipArchive([entry])) chunks.push(chunk);
+ const archive = Buffer.concat(chunks);
+ return [...zlib.ZipEntry.read(archive)][0];
+}
+
+test('modification time round-trips at 2-second resolution', async () => {
+ const modified = new Date(2024, 5, 15, 13, 45, 30);
+ const read = await roundTrip({ modified });
+ assert.strictEqual(read.modified.getFullYear(), 2024);
+ assert.strictEqual(read.modified.getMonth(), 5);
+ assert.strictEqual(read.modified.getDate(), 15);
+ assert.strictEqual(read.modified.getHours(), 13);
+ assert.strictEqual(read.modified.getMinutes(), 45);
+ assert.strictEqual(read.modified.getSeconds(), 30);
+});
+
+test('a sub-second modification time is preserved to the second via an extra field', async () => {
+ // The DOS field is 2-second, local; a sub-second time additionally writes an
+ // extended-timestamp extra field, so it round-trips to the exact UTC second.
+ const read = await roundTrip({ modified: new Date(1700000000500) });
+ assert.strictEqual(read.modified.getTime(), 1700000000000);
+});
+
+test('an odd whole-second modification time is preserved via an extra field', async () => {
+ // The DOS fields have 2-second resolution, so an odd second is just as
+ // unrepresentable as a sub-second part and also gets the extended-timestamp
+ // extra field.
+ const read = await roundTrip({ modified: new Date(1700000001000) });
+ assert.strictEqual(read.modified.getTime(), 1700000001000);
+});
+
+test('a date before 1980 is clamped to the DOS epoch', async () => {
+ const read = await roundTrip({ modified: new Date(1970, 0, 1) });
+ assert.strictEqual(read.modified.getFullYear(), 1980);
+ assert.strictEqual(read.modified.getMonth(), 0);
+ assert.strictEqual(read.modified.getDate(), 1);
+});
+
+test('a date after 2107 is clamped to the DOS ceiling', async () => {
+ const read = await roundTrip({ modified: new Date(2200, 0, 1) });
+ assert.strictEqual(read.modified.getFullYear(), 2107);
+ assert.strictEqual(read.modified.getMonth(), 11);
+ assert.strictEqual(read.modified.getDate(), 31);
+});
+
+test('Unix mode round-trips for files and directories', async () => {
+ const file = await roundTrip({ mode: 0o600 });
+ assert.strictEqual(file.mode, 0o600);
+
+ const dirEntry = await zlib.ZipEntry.create('dir/', Buffer.alloc(0), { mode: 0o700 });
+ const chunks = [];
+ for await (const chunk of zlib.createZipArchive([dirEntry])) chunks.push(chunk);
+ const read = [...zlib.ZipEntry.read(Buffer.concat(chunks))][0];
+ assert.strictEqual(read.mode, 0o700);
+ assert.strictEqual(read.isDirectory, true);
+});
+
+test('default modes are applied when none is given', async () => {
+ const file = await roundTrip({});
+ assert.strictEqual(file.mode, 0o644);
+
+ const dirEntry = await zlib.ZipEntry.create('dir/', Buffer.alloc(0));
+ const chunks = [];
+ for await (const chunk of zlib.createZipArchive([dirEntry])) chunks.push(chunk);
+ const read = [...zlib.ZipEntry.read(Buffer.concat(chunks))][0];
+ assert.strictEqual(read.mode, 0o755);
+});
+
+test('an entry comment round-trips', async () => {
+ const read = await roundTrip({ comment: 'a comment' });
+ assert.strictEqual(read.comment, 'a comment');
+});
+
+test('zipEntry.compressed reports compressed storage for any method', async () => {
+ const big = Buffer.from('x'.repeat(1000)); // Compressible, so deflate/zstd win
+ const deflated = await zlib.ZipEntry.create('d.txt', big, { method: 'deflate' });
+ const stored = await zlib.ZipEntry.create('s.bin', Buffer.from([1, 2, 3]), { method: 'store' });
+ const zstd = await zlib.ZipEntry.create('z.txt', big, { method: 'zstd' });
+ assert.strictEqual(deflated.compressed, true);
+ assert.strictEqual(stored.compressed, false);
+ assert.strictEqual(zstd.compressed, true);
+
+ // Survives a read-back round-trip too.
+ assert.strictEqual((await roundTrip({ method: 'store' })).compressed, false);
+});
diff --git a/test/parallel/test-zlib-zip-property.js b/test/parallel/test-zlib-zip-property.js
new file mode 100644
index 00000000000000..0f7cf8586fd202
--- /dev/null
+++ b/test/parallel/test-zlib-zip-property.js
@@ -0,0 +1,130 @@
+'use strict';
+
+require('../common');
+
+const assert = require('node:assert');
+const zlib = require('node:zlib');
+const fs = require('node:fs/promises');
+const path = require('node:path');
+const tmpdir = require('../common/tmpdir');
+const { test } = require('node:test');
+
+tmpdir.refresh();
+
+// A small, seeded PRNG so failures are reproducible without pulling in a
+// dependency.
+function mulberry32(seed) {
+ let state = seed >>> 0;
+ return function() {
+ state = (state + 0x6d2b79f5) >>> 0;
+ let t = state;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+function randomBuffer(random, length) {
+ const buf = Buffer.allocUnsafe(length);
+ for (let i = 0; i < length; i++) buf[i] = Math.floor(random() * 256);
+ return buf;
+}
+
+function randomName(random, index) {
+ const unicodeBits = ['a', 'é', '日', '🙂', 'z'];
+ const segment = unicodeBits[Math.floor(random() * unicodeBits.length)];
+ return `dir-${index}/${segment}-${index}.bin`;
+}
+
+async function buildTree(random, count) {
+ const specs = [];
+ for (let i = 0; i < count; i++) {
+ const size = Math.floor(random() * 4096);
+ specs.push({
+ name: randomName(random, i),
+ data: randomBuffer(random, size),
+ method: random() < 0.5 ? 'deflate' : 'store',
+ mode: random() < 0.5 ? 0o644 : 0o600,
+ modified: new Date(2000 + Math.floor(random() * 40), Math.floor(random() * 12),
+ 1 + Math.floor(random() * 27)),
+ });
+ }
+ const entries = [];
+ for (const spec of specs) {
+ entries.push(await zlib.ZipEntry.create(spec.name, spec.data, {
+ method: spec.method, mode: spec.mode, modified: spec.modified,
+ }));
+ }
+ const chunks = [];
+ for await (const chunk of zlib.createZipArchive(entries)) chunks.push(chunk);
+ return { archive: Buffer.concat(chunks), specs };
+}
+
+test('random archives round-trip through ZipEntry.read, ZipBuffer, and ZipFile', async () => {
+ const random = mulberry32(0xC0FFEE);
+ const rounds = 8;
+ const dir = await fs.mkdtemp(path.join(tmpdir.path, 'zlib-zip-property-'));
+ try {
+ for (let round = 0; round < rounds; round++) {
+ const { archive, specs } = await buildTree(random, 6);
+ const byName = new Map(specs.map((spec) => [spec.name, spec]));
+
+ // Path 1: ZipEntry.read
+ for (const entry of zlib.ZipEntry.read(archive)) {
+ const spec = byName.get(entry.name);
+ assert.ok(spec, `unexpected entry ${entry.name}`);
+ assert.deepStrictEqual(await entry.content(), spec.data);
+ assert.strictEqual(entry.mode, spec.mode);
+ }
+
+ // Path 2: ZipBuffer
+ using zipBuffer = new zlib.ZipBuffer(archive);
+ assert.strictEqual(zipBuffer.size, specs.length);
+ for (const [name, entry] of zipBuffer) {
+ const spec = byName.get(name);
+ assert.deepStrictEqual(await entry.content(), spec.data);
+ }
+
+ // Path 3: ZipFile (disk-backed), including one streamed read.
+ const filePath = path.join(dir, `round-${round}.zip`);
+ await fs.writeFile(filePath, archive);
+ const zipFile = await zlib.ZipFile.open(filePath);
+ try {
+ assert.strictEqual(zipFile.size, specs.length);
+ for (const spec of specs) {
+ const entry = await zipFile.get(spec.name);
+ assert.deepStrictEqual(await entry.content(), spec.data);
+ }
+ const firstName = specs[0].name;
+ const streamed = [];
+ for await (const chunk of await zipFile.stream(firstName)) streamed.push(chunk);
+ assert.deepStrictEqual(Buffer.concat(streamed), specs[0].data);
+ } finally {
+ await zipFile.close();
+ }
+ }
+ } finally {
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+}, { timeout: 60_000 });
+
+test('a streamed write round-trips through a streamed read', async () => {
+ const random = mulberry32(0xBADF00D);
+ const data = randomBuffer(random, 256 * 1024);
+ async function* source() {
+ for (let i = 0; i < data.length; i += 4096) {
+ yield data.subarray(i, Math.min(i + 4096, data.length));
+ }
+ }
+ const entry = zlib.ZipEntry.createStream('streamed.bin', source());
+ const chunks = [];
+ for await (const chunk of zlib.createZipArchive([entry])) chunks.push(chunk);
+ const archive = Buffer.concat(chunks);
+
+ const [read] = zlib.ZipEntry.read(archive);
+ assert.strictEqual(read.name, 'streamed.bin');
+ assert.strictEqual(read.size, data.length);
+ const streamedOut = [];
+ for await (const chunk of read.contentIterator()) streamedOut.push(chunk);
+ assert.deepStrictEqual(Buffer.concat(streamedOut), data);
+});
diff --git a/test/parallel/test-zlib-zip-sync.js b/test/parallel/test-zlib-zip-sync.js
new file mode 100644
index 00000000000000..1f7099849b9af4
--- /dev/null
+++ b/test/parallel/test-zlib-zip-sync.js
@@ -0,0 +1,247 @@
+'use strict';
+
+require('../common');
+
+const assert = require('node:assert');
+const zlib = require('node:zlib');
+const fs = require('node:fs');
+const path = require('node:path');
+const tmpdir = require('../common/tmpdir');
+const { test } = require('node:test');
+
+tmpdir.refresh();
+
+function buildArchiveSync(entries, comment) {
+ const chunks = [];
+ for (const chunk of zlib.createZipArchiveSync(entries, comment)) chunks.push(chunk);
+ return Buffer.concat(chunks);
+}
+
+function createTempZipSync(entries, comment) {
+ const dir = fs.mkdtempSync(path.join(tmpdir.path, 'zlib-zip-sync-'));
+ const filePath = path.join(dir, 'archive.zip');
+ fs.writeFileSync(filePath, buildArchiveSync(entries, comment));
+ return { filePath, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) };
+}
+
+// -- ZipEntry -----------------------------------------------------------------
+
+test('ZipEntry.createSync()/contentSync() round-trip, deflate and store', () => {
+ const deflateEntry = zlib.ZipEntry.createSync('a.txt', Buffer.from('a'.repeat(1000)));
+ assert.strictEqual(deflateEntry.method, 8);
+ assert.strictEqual(deflateEntry.contentSync().toString(), 'a'.repeat(1000));
+
+ const storeEntry = zlib.ZipEntry.createSync('b.bin', Buffer.from([1, 2, 3]), { method: 'store' });
+ assert.strictEqual(storeEntry.method, 0);
+ assert.deepStrictEqual(storeEntry.contentSync(), Buffer.from([1, 2, 3]));
+
+ // Matches the crc32/size that the async ZipEntry.create() would produce.
+ const data = Buffer.from('some content to compare');
+ assert.strictEqual(zlib.ZipEntry.createSync('x', data).crc32, zlib.crc32(data));
+});
+
+test('ZipEntry.createSync()/contentSync() round-trip, zstd', () => {
+ const zstdEntry = zlib.ZipEntry.createSync('z.txt', Buffer.from('z'.repeat(1000)), { method: 'zstd' });
+ assert.strictEqual(zstdEntry.method, 93);
+ assert.strictEqual(zstdEntry.contentSync().toString(), 'z'.repeat(1000));
+});
+
+test('ZipEntry.contentSync() enforces maxSize and CRC verification like content()', () => {
+ const entry = zlib.ZipEntry.createSync('a.txt', Buffer.from('hello world'), { method: 'store' });
+ assert.throws(() => entry.contentSync({ maxSize: 1 }), { code: 'ERR_ZIP_ENTRY_TOO_LARGE' });
+
+ const archive = buildArchiveSync([entry]);
+ const tampered = Buffer.from(archive);
+ tampered[30 + 'a.txt'.length] ^= 0xff; // Flip a content byte.
+ const [tamperedEntry] = zlib.ZipEntry.read(tampered);
+ assert.throws(() => tamperedEntry.contentSync(), { code: 'ERR_ZIP_ENTRY_CORRUPT' });
+ assert.strictEqual(tamperedEntry.contentSync({ verify: false }).length, 'hello world'.length);
+});
+
+test('createZipArchiveSync() throws for a streaming (pending) entry', () => {
+ const streaming = zlib.ZipEntry.createStream('big.bin', (async function* () {})());
+ assert.throws(() => [...zlib.createZipArchiveSync([streaming])], { code: 'ERR_INVALID_STATE' });
+});
+
+// -- ZipBuffer ----------------------------------------------------------------
+
+test('ZipBuffer.addSync()/toBufferSync() round-trip', () => {
+ const archive = buildArchiveSync([zlib.ZipEntry.createSync('a.txt', Buffer.from('a'))], 'a comment');
+ using zip = new zlib.ZipBuffer(archive);
+ const added = zip.addSync('b.txt', Buffer.from('b'));
+ assert.strictEqual(added.name, 'b.txt');
+ assert.strictEqual(zip.size, 2);
+
+ const rebuilt = zip.toBufferSync();
+ using reread = new zlib.ZipBuffer(rebuilt);
+ assert.deepStrictEqual([...reread.keys()].sort(), ['a.txt', 'b.txt']);
+ assert.strictEqual(reread.get('a.txt').contentSync().toString(), 'a');
+ assert.strictEqual(reread.get('b.txt').contentSync().toString(), 'b');
+ assert.strictEqual(reread.comment, 'a comment');
+});
+
+// -- ZipFile ------------------------------------------------------------------
+
+test('ZipFile.openSync() reads the same entries as open()', async () => {
+ const { filePath, cleanup } = createTempZipSync([
+ zlib.ZipEntry.createSync('a.txt', Buffer.from('a')),
+ zlib.ZipEntry.createSync('b.bin', Buffer.from([1, 2, 3]), { method: 'store' }),
+ ]);
+ try {
+ const zip = zlib.ZipFile.openSync(filePath);
+ try {
+ assert.strictEqual(zip.writable, false);
+ assert.strictEqual(zip.size, 2);
+ assert.strictEqual(zip.getSync('a.txt').contentSync().toString(), 'a');
+ assert.deepStrictEqual(zip.getSync('b.bin').contentSync(), Buffer.from([1, 2, 3]));
+ assert.deepStrictEqual([...zip.valuesSync()].map((e) => e.name).sort(), ['a.txt', 'b.bin']);
+ assert.deepStrictEqual([...zip.entriesSync()].map(([n]) => n).sort(), ['a.txt', 'b.bin']);
+ const seen = [];
+ zip.forEachSync((entry, name) => seen.push(name));
+ assert.deepStrictEqual(seen.sort(), ['a.txt', 'b.bin']);
+ } finally {
+ zip.closeSync();
+ }
+
+ const asyncZip = await zlib.ZipFile.open(filePath);
+ try {
+ assert.deepStrictEqual([...asyncZip.keys()].sort(), ['a.txt', 'b.bin']);
+ } finally {
+ await asyncZip.close();
+ }
+ } finally {
+ cleanup();
+ }
+});
+
+test('ZipFile opened via openSync supports `using` (Symbol.dispose)', () => {
+ const { filePath, cleanup } = createTempZipSync([zlib.ZipEntry.createSync('a.txt', Buffer.from('a'))]);
+ try {
+ {
+ using zip = zlib.ZipFile.openSync(filePath);
+ assert.strictEqual(zip.getSync('a.txt').contentSync().toString(), 'a');
+ }
+ // Disposed: the fd should be closed. Re-opening the same path must still work.
+ const reopened = zlib.ZipFile.openSync(filePath);
+ reopened.closeSync();
+ } finally {
+ cleanup();
+ }
+});
+
+test('ZipFile.addEntrySync()/addSync()/deleteSync() alter the file synchronously', () => {
+ const { filePath, cleanup } = createTempZipSync([
+ zlib.ZipEntry.createSync('a.txt', Buffer.from('AAAA'.repeat(20))),
+ zlib.ZipEntry.createSync('b.bin', Buffer.from([1, 2, 3]), { method: 'store' }),
+ ]);
+ try {
+ const zip = zlib.ZipFile.openSync(filePath, { writable: true });
+ try {
+ assert.strictEqual(zip.writable, true);
+ const sizeBefore = fs.statSync(filePath).size;
+ const added = zip.addSync('c.txt', Buffer.from('a fresh member'));
+ assert.strictEqual(added.name, 'c.txt');
+ assert.strictEqual(zip.size, 3);
+ assert.ok(fs.statSync(filePath).size > sizeBefore);
+ assert.strictEqual(zip.getSync('c.txt').contentSync().toString(), 'a fresh member');
+ // Original entries untouched.
+ assert.strictEqual(zip.getSync('a.txt').contentSync().toString(), 'AAAA'.repeat(20));
+
+ const entry = zlib.ZipEntry.createSync('d.txt', Buffer.from('d'));
+ assert.strictEqual(zip.addEntrySync(entry), entry);
+
+ const sizeBeforeDelete = fs.statSync(filePath).size;
+ assert.strictEqual(zip.deleteSync('b.bin'), true);
+ assert.ok(fs.statSync(filePath).size < sizeBeforeDelete);
+ assert.strictEqual(zip.deleteSync('does-not-exist'), false);
+ } finally {
+ zip.closeSync();
+ }
+
+ const reread = zlib.ZipFile.openSync(filePath);
+ try {
+ assert.deepStrictEqual([...reread.keys()].sort(), ['a.txt', 'c.txt', 'd.txt']);
+ } finally {
+ reread.closeSync();
+ }
+ } finally {
+ cleanup();
+ }
+});
+
+test('ZipFile.addEntrySync() rejects a pending streaming entry', () => {
+ const { filePath, cleanup } = createTempZipSync([zlib.ZipEntry.createSync('a.txt', Buffer.from('a'))]);
+ try {
+ const zip = zlib.ZipFile.openSync(filePath, { writable: true });
+ try {
+ const streaming = zlib.ZipEntry.createStream('big.bin', (async function* () {})());
+ assert.throws(() => zip.addEntrySync(streaming), { code: 'ERR_INVALID_STATE' });
+ } finally {
+ zip.closeSync();
+ }
+ } finally {
+ cleanup();
+ }
+});
+
+test('ZipFile sync mutators throw ERR_ZIP_NOT_WRITABLE when not opened writable', () => {
+ const { filePath, cleanup } = createTempZipSync([zlib.ZipEntry.createSync('a.txt', Buffer.from('a'))]);
+ try {
+ const zip = zlib.ZipFile.openSync(filePath);
+ try {
+ assert.throws(() => zip.addSync('b.txt', Buffer.from('b')), { code: 'ERR_ZIP_NOT_WRITABLE' });
+ assert.throws(() => zip.deleteSync('a.txt'), { code: 'ERR_ZIP_NOT_WRITABLE' });
+ } finally {
+ zip.closeSync();
+ }
+ } finally {
+ cleanup();
+ }
+});
+
+test('ZipFile.compactSync() matches compact() and does not touch the open file', () => {
+ const { filePath, cleanup } = createTempZipSync([
+ zlib.ZipEntry.createSync('a.txt', Buffer.from('a'.repeat(1000))),
+ zlib.ZipEntry.createSync('b.txt', Buffer.from('b'.repeat(1000))),
+ ]);
+ try {
+ const zip = zlib.ZipFile.openSync(filePath, { writable: true });
+ try {
+ zip.deleteSync('b.txt');
+ const sizeWithDeadSpace = fs.statSync(filePath).size;
+ const compacted = zip.compactSync();
+ assert.strictEqual(fs.statSync(filePath).size, sizeWithDeadSpace);
+ assert.ok(compacted.length < sizeWithDeadSpace);
+ using reread = new zlib.ZipBuffer(compacted);
+ assert.deepStrictEqual([...reread.keys()], ['a.txt']);
+ } finally {
+ zip.closeSync();
+ }
+ } finally {
+ cleanup();
+ }
+});
+
+test('a sync ZipFile method throws while an async mutation has not settled yet', async () => {
+ const { filePath, cleanup } = createTempZipSync([zlib.ZipEntry.createSync('seed.txt', Buffer.from('seed'))]);
+ try {
+ const zip = await zlib.ZipFile.open(filePath, { writable: true });
+ try {
+ // addEntry() (unlike add()) increments the busy counter synchronously,
+ // at call time - before any internal awaiting - so the checks below
+ // are guaranteed to observe the archive as busy.
+ const entry = zlib.ZipEntry.createSync('slow.txt', Buffer.from('x'));
+ const pending = zip.addEntry(entry);
+ assert.throws(() => zip.getSync('seed.txt'), { code: 'ERR_INVALID_STATE' });
+ assert.throws(() => zip.addSync('y.txt', Buffer.from('y')), { code: 'ERR_INVALID_STATE' });
+ assert.throws(() => zip.closeSync(), { code: 'ERR_INVALID_STATE' });
+ await pending;
+ // Once settled, sync methods work again.
+ assert.strictEqual(zip.getSync('seed.txt').contentSync().toString(), 'seed');
+ } finally {
+ await zip.close();
+ }
+ } finally {
+ cleanup();
+ }
+});
diff --git a/test/parallel/test-zlib-zip-writable.js b/test/parallel/test-zlib-zip-writable.js
new file mode 100644
index 00000000000000..9e036f62de317c
--- /dev/null
+++ b/test/parallel/test-zlib-zip-writable.js
@@ -0,0 +1,347 @@
+'use strict';
+
+require('../common');
+
+const assert = require('node:assert');
+const zlib = require('node:zlib');
+const fs = require('node:fs/promises');
+const path = require('node:path');
+const tmpdir = require('../common/tmpdir');
+const { test } = require('node:test');
+
+tmpdir.refresh();
+
+async function buildArchive(entries, comment) {
+ const chunks = [];
+ for await (const chunk of zlib.createZipArchive(entries, comment)) chunks.push(chunk);
+ return Buffer.concat(chunks);
+}
+
+async function createTempZip(entries, comment) {
+ const dir = await fs.mkdtemp(path.join(tmpdir.path, 'zlib-zip-writable-'));
+ const filePath = path.join(dir, 'archive.zip');
+ await fs.writeFile(filePath, await buildArchive(entries, comment));
+ return { filePath, cleanup: () => fs.rm(dir, { recursive: true, force: true }) };
+}
+
+// -- ZipBuffer --------------------------------------------------------------
+
+test('ZipBuffer is always writable', async () => {
+ const archive = await buildArchive([]);
+ using zip = new zlib.ZipBuffer(archive);
+ assert.strictEqual(zip.writable, true);
+});
+
+test('ZipBuffer preserves the archive comment across toBuffer()', async () => {
+ const archive = await buildArchive([], 'original comment');
+ using zip = new zlib.ZipBuffer(archive);
+ assert.strictEqual(zip.comment, 'original comment');
+ const rebuilt = await zip.toBuffer();
+ using reread = new zlib.ZipBuffer(rebuilt);
+ assert.strictEqual(reread.comment, 'original comment');
+});
+
+test('ZipBuffer add()/addEntry()/delete()/clear() mutate the in-memory index', async () => {
+ const archive = await buildArchive([
+ await zlib.ZipEntry.create('a.txt', Buffer.from('a')),
+ ]);
+ using zip = new zlib.ZipBuffer(archive);
+ assert.strictEqual(zip.size, 1);
+
+ const added = await zip.add('b.txt', Buffer.from('b'));
+ assert.strictEqual(added.name, 'b.txt');
+ assert.strictEqual(zip.size, 2);
+ assert.strictEqual(zip.has('b.txt'), true);
+
+ const entry = await zlib.ZipEntry.create('c.txt', Buffer.from('c'));
+ assert.strictEqual(zip.addEntry(entry), entry);
+ assert.strictEqual(zip.size, 3);
+
+ assert.strictEqual(zip.delete('a.txt'), true);
+ assert.strictEqual(zip.delete('a.txt'), false);
+ assert.strictEqual(zip.size, 2);
+
+ zip.clear();
+ assert.strictEqual(zip.size, 0);
+});
+
+test('ZipBuffer.toBuffer() serializes the current live set, replacing on overwrite', async () => {
+ const archive = await buildArchive([
+ await zlib.ZipEntry.create('a.txt', Buffer.from('original')),
+ await zlib.ZipEntry.create('b.txt', Buffer.from('keep')),
+ ]);
+ using zip = new zlib.ZipBuffer(archive);
+ await zip.add('a.txt', Buffer.from('replaced'));
+ zip.delete('b.txt');
+ await zip.add('c.txt', Buffer.from('new'));
+
+ const rebuilt = await zip.toBuffer();
+ using reread = new zlib.ZipBuffer(rebuilt);
+ assert.deepStrictEqual([...reread.keys()].sort(), ['a.txt', 'c.txt']);
+ assert.strictEqual((await reread.get('a.txt').content()).toString(), 'replaced');
+ assert.strictEqual((await reread.get('c.txt').content()).toString(), 'new');
+});
+
+test('ZipBuffer.addEntry() rejects a non-ZipEntry value', async () => {
+ const archive = await buildArchive([]);
+ using zip = new zlib.ZipBuffer(archive);
+ assert.throws(() => zip.addEntry({ name: 'fake.txt' }), { code: 'ERR_INVALID_ARG_TYPE' });
+});
+
+// -- ZipFile ------------------------------------------------------------------
+
+test('ZipFile defaults to read-only and rejects mutation', async () => {
+ const { filePath, cleanup } = await createTempZip([await zlib.ZipEntry.create('a.txt', Buffer.from('a'))]);
+ try {
+ const zip = await zlib.ZipFile.open(filePath);
+ try {
+ assert.strictEqual(zip.writable, false);
+ await assert.rejects(zip.add('b.txt', Buffer.from('b')), { code: 'ERR_ZIP_NOT_WRITABLE' });
+ await assert.rejects(zip.delete('a.txt'), { code: 'ERR_ZIP_NOT_WRITABLE' });
+ } finally {
+ await zip.close();
+ }
+ } finally {
+ await cleanup();
+ }
+});
+
+test('ZipFile opened writable: addEntry() appends where the old CD used to be', async () => {
+ const { filePath, cleanup } = await createTempZip([
+ await zlib.ZipEntry.create('a.txt', Buffer.from('AAAA'.repeat(20))),
+ await zlib.ZipEntry.create('b.bin', Buffer.from([1, 2, 3]), { method: 'store' }),
+ ]);
+ try {
+ const sizeBefore = (await fs.stat(filePath)).size;
+ const zip = await zlib.ZipFile.open(filePath, { writable: true });
+ try {
+ assert.strictEqual(zip.writable, true);
+ const added = await zip.add('c.txt', Buffer.from('a fresh member'));
+ assert.strictEqual(added.name, 'c.txt');
+ assert.strictEqual(zip.size, 3);
+
+ // The file must actually be altered by the time the call returns.
+ const sizeAfter = (await fs.stat(filePath)).size;
+ assert.ok(sizeAfter > sizeBefore);
+
+ // Original entries must be untouched.
+ assert.strictEqual((await (await zip.get('a.txt')).content()).toString(), 'AAAA'.repeat(20));
+ assert.deepStrictEqual(await (await zip.get('b.bin')).content(), Buffer.from([1, 2, 3]));
+ assert.strictEqual((await (await zip.get('c.txt')).content()).toString(), 'a fresh member');
+ } finally {
+ await zip.close();
+ }
+
+ // Re-opening from scratch must see the same three entries.
+ const reread = await zlib.ZipFile.open(filePath);
+ try {
+ assert.deepStrictEqual([...reread.keys()].sort(), ['a.txt', 'b.bin', 'c.txt']);
+ } finally {
+ await reread.close();
+ }
+ } finally {
+ await cleanup();
+ }
+});
+
+test('ZipFile addEntry() promotes a spent streaming entry into a readable file-backed entry', async () => {
+ const { filePath, cleanup } = await createTempZip([
+ await zlib.ZipEntry.create('seed.txt', Buffer.from('seed')),
+ ]);
+ try {
+ const zip = await zlib.ZipFile.open(filePath, { writable: true });
+ try {
+ const payload = 'streamed payload'.repeat(64);
+ async function* source() {
+ yield Buffer.from(payload.slice(0, 10));
+ yield Buffer.from(payload.slice(10));
+ }
+ const streamEntry = zlib.ZipEntry.createStream('s.txt', source());
+
+ // Before it is written, a streaming entry has no readable content.
+ await assert.rejects(streamEntry.content(), { code: 'ERR_INVALID_STATE' });
+
+ const returned = await zip.addEntry(streamEntry);
+ // addEntry() returns the same object, now promoted in place.
+ assert.strictEqual(returned, streamEntry);
+
+ // The once-spent entry is now readable, both buffered and streamed.
+ assert.strictEqual((await streamEntry.content()).toString(), payload);
+ const chunks = [];
+ for await (const chunk of streamEntry.contentIterator()) chunks.push(chunk);
+ assert.strictEqual(Buffer.concat(chunks).toString(), payload);
+
+ // And re-serializable: it can be copied into a fresh archive.
+ const copy = new zlib.ZipBuffer(await buildArchive([streamEntry]));
+ assert.strictEqual(copy.get('s.txt').contentSync().toString(), payload);
+ assert.strictEqual(copy.get('s.txt').crc32 >>> 0, streamEntry.crc32 >>> 0);
+
+ // An in-memory entry added alongside keeps its own buffer (not promoted).
+ const mem = await zlib.ZipEntry.create('m.txt', Buffer.from('in memory'));
+ await zip.addEntry(mem);
+ assert.deepStrictEqual(mem.rawContent, mem.rawContent); // still a Buffer
+ assert.notStrictEqual(mem.rawContent, null);
+ } finally {
+ await zip.close();
+ }
+
+ // The bytes persisted correctly and re-open sees the streamed member.
+ const reread = await zlib.ZipFile.open(filePath);
+ try {
+ assert.strictEqual(
+ (await (await reread.get('s.txt')).content()).toString(),
+ 'streamed payload'.repeat(64));
+ } finally {
+ await reread.close();
+ }
+ } finally {
+ await cleanup();
+ }
+});
+
+test('an in-place rewrite keeps central and local data-descriptor flags in agreement', async () => {
+ const { filePath, cleanup } = await createTempZip([
+ await zlib.ZipEntry.create('seed.txt', Buffer.from('seed')),
+ ]);
+ try {
+ const zip = await zlib.ZipFile.open(filePath, { writable: true });
+ try {
+ async function* source() { yield Buffer.from('streamed payload'); }
+ await zip.addEntry(zlib.ZipEntry.createStream('s.txt', source()));
+ // A second mutation rebuilds the central directory from the freshly
+ // adopted headers. The streamed entry's on-disk local header (bit 3
+ // set, with a data descriptor after its content) is never rewritten,
+ // so the rebuilt central header must keep advertising bit 3 - a
+ // cleared flag would contradict the local header (sec. 4.3.12).
+ await zip.delete('seed.txt');
+ } finally {
+ await zip.close();
+ }
+
+ const raw = await fs.readFile(filePath);
+ const FLAG_DATA_DESCRIPTOR = 0x0008;
+ // Only s.txt is live: its central record locates its local header.
+ const centralPos = raw.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02]));
+ assert.notStrictEqual(centralPos, -1);
+ const centralFlags = raw.readUInt16LE(centralPos + 8);
+ const localOffset = raw.readUInt32LE(centralPos + 42);
+ assert.strictEqual(raw.readUInt32LE(localOffset), 0x04034b50);
+ const localFlags = raw.readUInt16LE(localOffset + 6);
+ assert.strictEqual(localFlags & FLAG_DATA_DESCRIPTOR, FLAG_DATA_DESCRIPTOR);
+ assert.strictEqual(centralFlags & FLAG_DATA_DESCRIPTOR, FLAG_DATA_DESCRIPTOR);
+
+ // The archive stays fully readable after the rewrite.
+ const reread = await zlib.ZipFile.open(filePath);
+ try {
+ assert.strictEqual(
+ (await (await reread.get('s.txt')).content()).toString(), 'streamed payload');
+ } finally {
+ await reread.close();
+ }
+ } finally {
+ await cleanup();
+ }
+});
+
+test('ZipFile opened writable: delete() rewrites the CD without growing the file', async () => {
+ const { filePath, cleanup } = await createTempZip([
+ await zlib.ZipEntry.create('a.txt', Buffer.from('a')),
+ await zlib.ZipEntry.create('b.txt', Buffer.from('b')),
+ ]);
+ try {
+ const zip = await zlib.ZipFile.open(filePath, { writable: true });
+ try {
+ const sizeBefore = (await fs.stat(filePath)).size;
+ assert.strictEqual(await zip.delete('b.txt'), true);
+ const sizeAfter = (await fs.stat(filePath)).size;
+ assert.ok(sizeAfter < sizeBefore);
+ assert.strictEqual(zip.has('b.txt'), false);
+ assert.strictEqual(zip.size, 1);
+ assert.strictEqual(await zip.delete('does-not-exist'), false);
+ } finally {
+ await zip.close();
+ }
+ } finally {
+ await cleanup();
+ }
+});
+
+test('ZipFile.compact() streams a fresh archive with no dead entries and does not touch the open file', async () => {
+ const { filePath, cleanup } = await createTempZip([
+ await zlib.ZipEntry.create('a.txt', Buffer.from('a'.repeat(1000))),
+ await zlib.ZipEntry.create('b.txt', Buffer.from('b'.repeat(1000))),
+ ]);
+ try {
+ const zip = await zlib.ZipFile.open(filePath, { writable: true });
+ try {
+ await zip.delete('b.txt'); // Leaves dead space behind
+ const sizeWithDeadSpace = (await fs.stat(filePath)).size;
+
+ const chunks = [];
+ for await (const chunk of zip.compact()) chunks.push(chunk);
+ const compacted = Buffer.concat(chunks);
+
+ // compact() must not have modified the still-open file.
+ assert.strictEqual((await fs.stat(filePath)).size, sizeWithDeadSpace);
+
+ assert.ok(compacted.length < sizeWithDeadSpace);
+ using reread = new zlib.ZipBuffer(compacted);
+ assert.deepStrictEqual([...reread.keys()], ['a.txt']);
+ assert.strictEqual((await reread.get('a.txt').content()).toString(), 'a'.repeat(1000));
+ } finally {
+ await zip.close();
+ }
+ } finally {
+ await cleanup();
+ }
+});
+
+test('ZipFile preserves the archive comment across writes', async () => {
+ const { filePath, cleanup } = await createTempZip(
+ [await zlib.ZipEntry.create('a.txt', Buffer.from('a'))], 'a comment');
+ try {
+ const zip = await zlib.ZipFile.open(filePath, { writable: true });
+ try {
+ assert.strictEqual(zip.comment, 'a comment');
+ await zip.add('b.txt', Buffer.from('b'));
+ assert.strictEqual(zip.comment, 'a comment');
+ } finally {
+ await zip.close();
+ }
+ const reread = await zlib.ZipFile.open(filePath);
+ try {
+ assert.strictEqual(reread.comment, 'a comment');
+ } finally {
+ await reread.close();
+ }
+ } finally {
+ await cleanup();
+ }
+});
+
+test('ZipFile serializes concurrent add()/delete() calls instead of racing', async () => {
+ const { filePath, cleanup } = await createTempZip([await zlib.ZipEntry.create('seed.txt', Buffer.from('seed'))]);
+ try {
+ const zip = await zlib.ZipFile.open(filePath, { writable: true });
+ try {
+ const names = [];
+ for (let i = 0; i < 20; i++) names.push(`f${i}.txt`);
+ await Promise.all(names.map((name) => zip.add(name, Buffer.from(name))));
+ assert.strictEqual(zip.size, names.length + 1);
+ for (const name of names) {
+ assert.strictEqual((await (await zip.get(name)).content()).toString(), name);
+ }
+ } finally {
+ await zip.close();
+ }
+
+ const reread = await zlib.ZipFile.open(filePath);
+ try {
+ assert.strictEqual(reread.size, 21);
+ } finally {
+ await reread.close();
+ }
+ } finally {
+ await cleanup();
+ }
+}, { timeout: 30_000 });
diff --git a/test/parallel/test-zlib-zip-zip64.js b/test/parallel/test-zlib-zip-zip64.js
new file mode 100644
index 00000000000000..d22b14b99baa65
--- /dev/null
+++ b/test/parallel/test-zlib-zip-zip64.js
@@ -0,0 +1,38 @@
+'use strict';
+
+require('../common');
+
+const assert = require('node:assert');
+const zlib = require('node:zlib');
+const { test } = require('node:test');
+
+const ZIP64_EOCD_SIGNATURE = Buffer.from([0x50, 0x4b, 0x06, 0x06]);
+
+async function buildArchive(count) {
+ const entries = [];
+ for (let i = 0; i < count; i++) {
+ entries.push(await zlib.ZipEntry.create(`entry-${i}`, Buffer.alloc(0), { method: 'store' }));
+ }
+ const chunks = [];
+ for await (const chunk of zlib.createZipArchive(entries)) chunks.push(chunk);
+ return Buffer.concat(chunks);
+}
+
+test('an entry count at or above 0xFFFF forces Zip64 structures', async () => {
+ const archive = await buildArchive(0x10000);
+ assert.ok(archive.includes(ZIP64_EOCD_SIGNATURE));
+
+ const read = [...zlib.ZipEntry.read(archive)];
+ assert.strictEqual(read.length, 0x10000);
+
+ using zip = new zlib.ZipBuffer(archive);
+ assert.strictEqual(zip.size, 0x10000);
+ assert.strictEqual(zip.has('entry-0'), true);
+ assert.strictEqual(zip.has(`entry-${0x10000 - 1}`), true);
+}, { timeout: 120_000 });
+
+test('an archive below the Zip64 thresholds contains no Zip64 structures', async () => {
+ const archive = await buildArchive(10);
+ assert.ok(!archive.includes(ZIP64_EOCD_SIGNATURE));
+ assert.strictEqual([...zlib.ZipEntry.read(archive)].length, 10);
+});
diff --git a/test/parallel/test-zlib-zip.js b/test/parallel/test-zlib-zip.js
new file mode 100644
index 00000000000000..9aa91e569a33a8
--- /dev/null
+++ b/test/parallel/test-zlib-zip.js
@@ -0,0 +1,132 @@
+'use strict';
+
+require('../common');
+
+const assert = require('node:assert');
+const zlib = require('node:zlib');
+const { test } = require('node:test');
+
+async function buildArchive(entries, comment) {
+ const chunks = [];
+ for await (const chunk of zlib.createZipArchive(entries, comment)) {
+ chunks.push(chunk);
+ }
+ return Buffer.concat(chunks);
+}
+
+test('round-trips a small archive through ZipEntry.read', async () => {
+ const entries = [
+ await zlib.ZipEntry.create('hello.txt', Buffer.from('Hello, world!'.repeat(20))),
+ await zlib.ZipEntry.create('raw.bin', Buffer.from([1, 2, 3, 4, 5]), { method: 'store' }),
+ await zlib.ZipEntry.create('empty.txt', Buffer.alloc(0)),
+ await zlib.ZipEntry.create('dir/', Buffer.alloc(0)),
+ ];
+ const archive = await buildArchive(entries, 'test comment');
+
+ const read = [...zlib.ZipEntry.read(archive)];
+ assert.strictEqual(read.length, 4);
+
+ const byName = new Map(read.map((entry) => [entry.name, entry]));
+ assert.strictEqual((await byName.get('hello.txt').content()).toString(),
+ 'Hello, world!'.repeat(20));
+ assert.strictEqual(byName.get('hello.txt').method, 8);
+ assert.deepStrictEqual(await byName.get('raw.bin').content(), Buffer.from([1, 2, 3, 4, 5]));
+ assert.strictEqual(byName.get('raw.bin').method, 0);
+ assert.strictEqual((await byName.get('empty.txt').content()).length, 0);
+ assert.strictEqual(byName.get('dir/').isDirectory, true);
+ assert.strictEqual(byName.get('hello.txt').isFile, true);
+});
+
+test('ZipBuffer indexes entries by name', async () => {
+ const entries = [
+ await zlib.ZipEntry.create('a.txt', Buffer.from('a')),
+ await zlib.ZipEntry.create('b.txt', Buffer.from('b')),
+ ];
+ const archive = await buildArchive(entries);
+ using zip = new zlib.ZipBuffer(archive);
+
+ assert.strictEqual(zip.size, 2);
+ assert.strictEqual(zip.has('a.txt'), true);
+ assert.strictEqual(zip.has('missing.txt'), false);
+ assert.strictEqual((await zip.get('a.txt').content()).toString(), 'a');
+ assert.deepStrictEqual([...zip.keys()].sort(), ['a.txt', 'b.txt']);
+
+ assert.throws(() => zip.get('missing.txt'), { code: 'ERR_ZIP_ENTRY_NOT_FOUND' });
+});
+
+test('an incompressible or empty entry falls back to store', async () => {
+ const random = require('node:crypto').randomBytes(4096);
+ const entry = await zlib.ZipEntry.create('random.bin', random);
+ assert.strictEqual(entry.method, 0);
+ assert.deepStrictEqual(await entry.content(), random);
+
+ const empty = await zlib.ZipEntry.create('empty.bin', Buffer.alloc(0));
+ assert.strictEqual(empty.method, 0);
+});
+
+test('explicit store option is honored even for compressible data', async () => {
+ const data = Buffer.from('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
+ const entry = await zlib.ZipEntry.create('stored.txt', data, { method: 'store' });
+ assert.strictEqual(entry.method, 0);
+ assert.deepStrictEqual(entry.rawContent, data);
+});
+
+test('the zstd method compresses and round-trips through an archive', async () => {
+ const data = Buffer.from('zstd content '.repeat(200));
+ const entry = await zlib.ZipEntry.create('z.txt', data, { method: 'zstd' });
+ assert.strictEqual(entry.method, 93);
+ assert.ok(entry.compressedSize < data.length);
+ assert.deepStrictEqual(await entry.content(), data);
+
+ const archive = await buildArchive([entry]);
+ const [read] = zlib.ZipEntry.read(archive);
+ assert.strictEqual(read.method, 93);
+ assert.deepStrictEqual(await read.content(), data);
+});
+
+test('an incompressible entry with method zstd falls back to store', async () => {
+ const random = require('node:crypto').randomBytes(4096);
+ const entry = await zlib.ZipEntry.create('random.bin', random, { method: 'zstd' });
+ assert.strictEqual(entry.method, 0);
+ assert.deepStrictEqual(await entry.content(), random);
+});
+
+test('crc32 chains the same way as zlib.crc32', async () => {
+ const data = Buffer.from('the quick brown fox jumps over the lazy dog');
+ const entry = await zlib.ZipEntry.create('f.txt', data);
+ assert.strictEqual(entry.crc32, zlib.crc32(data));
+});
+
+test('createZipArchive rejects an overlong comment', async () => {
+ await assert.rejects(
+ buildArchive([], 'x'.repeat(70000)),
+ { code: 'ERR_ZIP_ARCHIVE_TOO_LARGE' },
+ );
+});
+
+test('directory entries cannot carry content', async () => {
+ await assert.rejects(
+ zlib.ZipEntry.create('dir/', Buffer.from('x')),
+ { code: 'ERR_INVALID_ARG_VALUE' },
+ );
+});
+
+test('ZipEntry.createSymlink round-trips as a symlink entry', async () => {
+ const entry = zlib.ZipEntry.createSymlink('link', '../target', { mode: 0o777 });
+ const [read] = zlib.ZipEntry.read(await buildArchive([entry]));
+ assert.strictEqual(read.isSymlink, true);
+ assert.strictEqual(read.isFile, false);
+ assert.strictEqual(read.mode, 0o777);
+ assert.strictEqual(read.contentSync().toString(), '../target');
+});
+
+test('a deflate and a zstd member each stream back through contentIterator', async () => {
+ for (const method of ['deflate', 'zstd']) {
+ const data = Buffer.from(`${method} payload `.repeat(500));
+ const entry = await zlib.ZipEntry.create('m.txt', data, { method });
+ const [read] = zlib.ZipEntry.read(await buildArchive([entry]));
+ const chunks = [];
+ for await (const chunk of read.contentIterator()) chunks.push(chunk);
+ assert.deepStrictEqual(Buffer.concat(chunks), data);
+ }
+});
diff --git a/test/pummel/test-zlib-zip-slow.js b/test/pummel/test-zlib-zip-slow.js
new file mode 100644
index 00000000000000..c3e334c06deca7
--- /dev/null
+++ b/test/pummel/test-zlib-zip-slow.js
@@ -0,0 +1,129 @@
+'use strict';
+
+// This test writes and reads back a ZIP archive whose total size exceeds
+// 4 GiB, to exercise the Zip64 promotion that is triggered by an offset or
+// size overflowing the classic 32-bit fields (as opposed to test-zlib-zip-
+// zip64.js in test/parallel, which triggers Zip64 through the 16-bit entry
+// count instead). It needs several GiB of free disk space and is too slow
+// for the default test run, hence living in test/pummel rather than
+// test/parallel.
+
+const common = require('../common');
+
+const assert = require('node:assert');
+const zlib = require('node:zlib');
+const fs = require('node:fs/promises');
+const path = require('node:path');
+const tmpdir = require('../common/tmpdir');
+const { test } = require('node:test');
+
+tmpdir.refresh();
+
+const GiB = 1024 * 1024 * 1024;
+const MEMBER_SIZE = 500 * 1024 * 1024; // Four ~500 MiB stored members...
+const STORED_MEMBER_COUNT = 4;
+const STREAMED_MEMBER_SIZE = 4.5 * GiB; // ...plus one >4 GiB streamed member:
+// the total archive size (~6.5 GiB) pushes offsets over the 4 GiB Zip64
+// threshold, and the streamed member's own sizes exceed 32 bits too, so the
+// per-entry Zip64 size fields (central header and data descriptor) are
+// exercised as well as the offset promotion. Required free space includes
+// generous slack over that total.
+const REQUIRED_FREE_BYTES = 12 * GiB;
+const CHUNK_SIZE = 16 * 1024 * 1024;
+
+function fillChunk(seed) {
+ const chunk = Buffer.allocUnsafe(CHUNK_SIZE);
+ chunk.fill(seed & 0xff);
+ return chunk;
+}
+
+async function* repeatingChunks(totalSize, seed) {
+ let remaining = totalSize;
+ while (remaining > 0) {
+ const size = Math.min(CHUNK_SIZE, remaining);
+ const chunk = fillChunk(seed);
+ remaining -= size;
+ yield size === chunk.length ? chunk : chunk.subarray(0, size);
+ }
+}
+
+test('an archive larger than 4 GiB round-trips and triggers Zip64 via offset', async () => {
+ let free;
+ try {
+ const stats = await fs.statfs(tmpdir.path);
+ free = stats.bavail * stats.bsize;
+ } catch {
+ free = undefined;
+ }
+ if (free !== undefined && free < REQUIRED_FREE_BYTES) {
+ common.skip(`insufficient disk space in ${tmpdir.path} for a >4 GiB archive test`);
+ return;
+ }
+
+ const dir = await fs.mkdtemp(path.join(tmpdir.path, 'zlib-zip-slow-'));
+ const archivePath = path.join(dir, 'large.zip');
+ try {
+ const entries = [];
+ for (let i = 0; i < STORED_MEMBER_COUNT; i++) {
+ entries.push(zlib.ZipEntry.createStream(`stored-${i}.bin`, repeatingChunks(MEMBER_SIZE, i), {
+ method: 'store',
+ }));
+ }
+ entries.push(zlib.ZipEntry.createStream('streamed.bin', repeatingChunks(STREAMED_MEMBER_SIZE, 0xaa), {
+ method: 'store',
+ }));
+
+ const handle = await fs.open(archivePath, 'w');
+ try {
+ for await (const chunk of zlib.createZipArchive(entries)) {
+ await handle.write(chunk);
+ }
+ } finally {
+ await handle.close();
+ }
+
+ const stat = await fs.stat(archivePath);
+ assert.ok(stat.size > 4 * GiB, `archive is only ${stat.size} bytes`);
+
+ const zip = await zlib.ZipFile.open(archivePath);
+ try {
+ assert.strictEqual(zip.size, STORED_MEMBER_COUNT + 1);
+
+ let seen = 0;
+ for await (const chunk of await zip.stream('streamed.bin')) {
+ seen += chunk.length;
+ assert.strictEqual(chunk[0], 0xaa);
+ }
+ assert.strictEqual(seen, STREAMED_MEMBER_SIZE);
+
+ let storedSeen = 0;
+ for await (const chunk of await zip.stream('stored-2.bin')) {
+ storedSeen += chunk.length;
+ assert.strictEqual(chunk[0], 2);
+ }
+ assert.strictEqual(storedSeen, MEMBER_SIZE);
+
+ // The streamed member's sizes genuinely exceed 32 bits (stored, so
+ // compressed === uncompressed), which the reader must have resolved
+ // through the central header's Zip64 extra field.
+ const big = await zip.get('streamed.bin');
+ assert.strictEqual(big.size, STREAMED_MEMBER_SIZE);
+ assert.strictEqual(big.compressedSize, STREAMED_MEMBER_SIZE);
+
+ // Re-serialize the >4 GiB file-backed member through the archive
+ // writer, discarding the output: this exercises the non-streaming
+ // Zip64 local-header path (real 64-bit sizes up front, no data
+ // descriptor) without needing a second copy on disk.
+ let reserialized = 0;
+ for await (const chunk of zlib.createZipArchive([big])) {
+ reserialized += chunk.length;
+ }
+ assert.ok(reserialized > STREAMED_MEMBER_SIZE,
+ `re-serialized only ${reserialized} bytes`);
+ } finally {
+ await zip.close();
+ }
+ } finally {
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+}, { timeout: 30 * 60 * 1000 });