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 });