From 27ab6b410be064b4c08f73aa1d3f49754154f411 Mon Sep 17 00:00:00 2001 From: Philipp Dunkel Date: Tue, 7 Jul 2026 23:25:05 +0200 Subject: [PATCH] zlib,vfs: add ZIP archive support Add ZIP archive read/write support to zlib, plus a VFS provider that mounts a ZIP archive as a browsable, read/write file system. zlib gains three classes and a pair of helper functions: * ZipEntry represents a single archive member: name, metadata, and content, created from a Buffer/stream (`create()`/`createStream()`) or read back out of raw entry bytes (`read()`). It is otherwise immutable; every accessor and static creator has a synchronous and an asynchronous form. * ZipFile wraps a real ZIP file on disk (`open()`/`openSync()`), exposing get/add/delete/stream by name plus `compact()` to reclaim space left behind by deleted entries. Opened read-only unless `{ writable: true }` is passed. * ZipBuffer is the equivalent fully in-memory representation, serializable back to a Buffer with `toBuffer()`/`toBufferSync()`. Always writable, since nothing is written until asked to serialize. * `createZipArchive()`/`createZipArchiveSync()` build a brand-new archive (as a stream or a Buffer) from a list of entries. Every read path enforces `getMaxZipContentSize()`/ `setMaxZipContentSize()` limits and rejects malformed local/central directory records, guarding against zip-bomb and corrupt-archive inputs. New ERR_ZIP_* error codes cover corrupt entries, missing entries, oversized entries, invalid archives, non-writable archives, and unsupported ZIP features. node:vfs gains ArchiveProvider, a VirtualProvider backed by an already-open ZipBuffer or ZipFile: mounting one exposes the archive's entries through the same fs-like API any other VFS provider offers. Directories are inferred from entry-name prefixes rather than stored explicitly. Because a ZIP member can only be written or read in full, never edited in place, a file opened for writing is buffered and only committed as a new archive entry when its handle is closed. The provider's readonly flag mirrors the underlying archive's own writable state. Signed-off-by: Philipp Dunkel --- doc/api/errors.md | 46 + doc/api/vfs.md | 63 +- doc/api/zlib.md | 1043 ++++++++ lib/internal/errors.js | 6 + lib/internal/vfs/providers/archive.js | 526 ++++ lib/internal/zip.js | 2325 +++++++++++++++++ lib/vfs.js | 2 + lib/zlib.js | 18 + .../test-vfs-archive-provider-handle.js | 443 ++++ test/parallel/test-vfs-archive-provider.js | 243 ++ test/parallel/test-zlib-zip-coverage.js | 738 ++++++ test/parallel/test-zlib-zip-fuzz.js | 85 + test/parallel/test-zlib-zip-hardening.js | 172 ++ test/parallel/test-zlib-zip-interop.js | 95 + test/parallel/test-zlib-zip-metadata.js | 68 + test/parallel/test-zlib-zip-property.js | 128 + test/parallel/test-zlib-zip-sync.js | 253 ++ test/parallel/test-zlib-zip-writable.js | 246 ++ test/parallel/test-zlib-zip-zip64.js | 38 + test/parallel/test-zlib-zip.js | 112 + test/pummel/test-zlib-zip-slow.js | 106 + 21 files changed, 6752 insertions(+), 4 deletions(-) create mode 100644 lib/internal/vfs/providers/archive.js create mode 100644 lib/internal/zip.js create mode 100644 test/parallel/test-vfs-archive-provider-handle.js create mode 100644 test/parallel/test-vfs-archive-provider.js create mode 100644 test/parallel/test-zlib-zip-coverage.js create mode 100644 test/parallel/test-zlib-zip-fuzz.js create mode 100644 test/parallel/test-zlib-zip-hardening.js create mode 100644 test/parallel/test-zlib-zip-interop.js create mode 100644 test/parallel/test-zlib-zip-metadata.js create mode 100644 test/parallel/test-zlib-zip-property.js create mode 100644 test/parallel/test-zlib-zip-sync.js create mode 100644 test/parallel/test-zlib-zip-writable.js create mode 100644 test/parallel/test-zlib-zip-zip64.js create mode 100644 test/parallel/test-zlib-zip.js create mode 100644 test/pummel/test-zlib-zip-slow.js diff --git a/doc/api/errors.md b/doc/api/errors.md index 4175574f1661d1..d43e0f60b891a2 100644 --- a/doc/api/errors.md +++ b/doc/api/errors.md @@ -3674,6 +3674,50 @@ All attempts at serializing an uncaught exception from a worker thread failed. The requested functionality is not supported in worker threads. + + +### `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 +4661,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/vfs.md b/doc/api/vfs.md index 90b8e9c303125a..cc707eab3cb2f3 100644 --- a/doc/api/vfs.md +++ b/doc/api/vfs.md @@ -38,10 +38,12 @@ Mounting a VFS only redirects supported [`node:fs`][] calls whose resolved paths are under the mount point. It does not prevent code from using other paths or other Node.js APIs to access resources available to the process. [`RealFSProvider`][] maps VFS paths under its configured root and rejects paths -that resolve outside that root, but that check is not a security boundary. Do -not rely on VFS to run untrusted code; use operating-system-level isolation, -such as separate users, containers, or platform sandboxes, when a security -boundary is required. +that resolve outside that root, but that check is not a security boundary. +[`ArchiveProvider`][] has no real file-system paths of its own to escape; its +entries only ever exist within the archive's own namespace. Do not rely on VFS +to run untrusted code; use operating-system-level isolation, such as separate +users, containers, or platform sandboxes, when a security boundary is +required. ## Basic usage @@ -304,6 +306,55 @@ added: v26.4.0 The resolved absolute path used as the root. +## Class: `ArchiveProvider` + + + +A provider that exposes the entries of a ZIP archive - either a +[`zlib.ZipBuffer`][] (in memory) or a [`zlib.ZipFile`][] (on disk) - through +the VFS API. `provider.readonly` reflects the archive's own +[`zipFile.writable`][] flag: a `ZipBuffer` is always writable, and a +`ZipFile` is writable only when opened with `{ writable: true }`. + +Directories are recognized both explicitly (an entry whose name ends in `/`) +and implicitly (any entry name starting with `"/"`). `readdir()` does +not support `{ recursive: true }`. Because a ZIP member cannot be edited or +read in place - only fully written or fully decompressed - a file opened for +writing only commits its content (as a new archive entry) when the handle is +closed. + +Every method has a synchronous counterpart (`openSync()`, `statSync()`, +`readdirSync()`, and so on), backed by the equally complete synchronous +surface [`zlib.ZipBuffer`][]/[`zlib.ZipFile`][] expose. As with those, the +synchronous methods here block the Node.js event loop and further JavaScript +execution until the operation - including any deflate/inflate pass - +completes. + +```cjs +const vfs = require('node:vfs'); +const zlib = require('node:zlib'); +const { readFileSync } = require('node:fs'); + +async function main() { + const zip = new zlib.ZipBuffer(readFileSync('archive.zip')); + const archiveVfs = vfs.create(new vfs.ArchiveProvider(zip)); + + console.log(await archiveVfs.promises.readdir('/')); + await archiveVfs.promises.writeFile('/new.txt', 'hello'); +} +main(); +``` + +### `new ArchiveProvider(source)` + + + +* `source` {zlib.ZipBuffer|zlib.ZipFile} An already-open archive. + ## Implementation details ### `Stats` objects @@ -318,6 +369,7 @@ fields use synthetic but stable values: * `blocks` is `Math.ceil(size / 512)`. * Times default to the moment the entry was created/last modified. +[`ArchiveProvider`]: #class-archiveprovider [`MemoryProvider`]: #class-memoryprovider [`RealFSProvider`]: #class-realfsprovider [`VirtualFileSystem`]: #class-virtualfilesystem @@ -325,3 +377,6 @@ fields use synthetic but stable values: [`fs.BigIntStats`]: fs.md#class-fsbigintstats [`fs.Stats`]: fs.md#class-fsstats [`node:fs`]: fs.md +[`zipFile.writable`]: zlib.md#zipfilewritable +[`zlib.ZipBuffer`]: zlib.md#class-zlibzipbuffer +[`zlib.ZipFile`]: zlib.md#class-zlibzipfile diff --git a/doc/api/zlib.md b/doc/api/zlib.md index 3f32a7507b1fa0..4803bbc17b4233 100644 --- a/doc/api/zlib.md +++ b/doc/api/zlib.md @@ -1006,6 +1006,909 @@ 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 view over the entries of a ZIP archive already held in a +`Buffer`, `TypedArray`, `DataView`, or `ArrayBuffer`, writable in place: +entries can be added or removed, and [`zipBuffer.toBuffer()`][] serializes +the current set of entries into a fresh archive. + +`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. + +### `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 across [`zipBuffer.toBuffer()`][] calls +unless overridden. + +### `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([comment])` + + + +* `comment` {string} An archive comment. **Default:** [`zipBuffer.comment`][]. +* 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([comment])` + + + +* `comment` {string} An archive comment. **Default:** [`zipBuffer.comment`][]. +* 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()`, `content()`, and `contentStream()` each have a `*Sync` +counterpart. 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. + +### 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`. + +### 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`][]. + +### `zipEntry.comment` + + + +* Type: {string} + +### `zipEntry.compressed` + + + +* Type: {boolean} + +`true` if the entry uses the deflate compression method. + +### `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. + +Throws an [`ERR_ZIP_ENTRY_TOO_LARGE`][] error if the entry's declared size +exceeds `maxSize`, and an [`ERR_ZIP_ENTRY_CORRUPT`][] error if the content +fails CRC-32 verification or does not match its declared size. + +### `zipEntry.contentSync([options])` + + + +* `options` {Object} See [`zipEntry.content()`][]. +* Returns: {Buffer} The entry's decompressed content. + +The synchronous version of [`zipEntry.content()`][]. + +### `zipEntry.contentStream([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. + +### `zipEntry.contentStreamSync([options])` + + + +* `options` {Object} See [`zipEntry.contentStream()`][]. +* Returns: {Iterator} of a single {Buffer} containing the entry's whole + decompressed content. + +The synchronous version of [`zipEntry.contentStream()`][]. There is no +public synchronous incremental inflate API, so - unlike +`contentStream()`, which is bounded-memory - this materializes the entire +entry before yielding it as one chunk. + +### `zipEntry.crc32` + + + +* Type: {number} + +### `zipEntry.flags` + + + +* Type: {number} + +The entry's raw general-purpose bit flag. + +### `zipEntry.isDirectory` + + + +* Type: {boolean} + +### `zipEntry.isFile` + + + +* Type: {boolean} + +### `zipEntry.mode` + + + +* Type: {number} + +The entry's Unix permission bits, or `0` if the archive was not written on +a Unix-like system. + +### `zipEntry.modified` + + + +* Type: {Date} + +### `zipEntry.method` + + + +* Type: {number} + +The entry's raw compression method: `0` for stored, `8` for deflate, `93` +for Zstandard. + +### `zipEntry.name` + + + +* Type: {string} + +### `zipEntry.rawContent` + + + +* Type: {Buffer|null} + +The entry's raw (still compressed, if applicable) content, or `null` for +an entry created with [`zlib.ZipEntry.createStream()`][]. + +### `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 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 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}. + +### 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 }`. + +### `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. + +### `zipFile.closeSync()` + + + +The synchronous version of [`zipFile.close()`][]. + +### `zipFile.comment` + + + +* Type: {string} + +The archive-level comment, preserved across [`zipFile.addEntry()`][]/ +[`zipFile.delete()`][] calls. + +### `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}. + +Reads and buffers the named member. Throws [`ERR_ZIP_ENTRY_NOT_FOUND`][] if +the archive has no entry named `name`, and [`ERR_ZIP_ENTRY_TOO_LARGE`][] if +the member is too large to buffer; use [`zipFile.stream()`][] instead. + +### `zipFile.getSync(name)` + + + +* `name` {string} +* Returns: {ZipEntry} + +The synchronous version of [`zipFile.get()`][]; use +[`zipFile.streamSync()`][] instead if the member is too large to buffer. + +### `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: {AsyncIterator} of {Buffer} chunks of the member's decompressed + content, without buffering the whole member in memory. + +Throws [`ERR_ZIP_ENTRY_NOT_FOUND`][] if the archive has no entry named `name`. + +### `zipFile.streamSync(name[, options])` + + + +* `name` {string} +* `options` {Object} See [`zipFile.stream()`][]. +* Returns: {Iterator} of a single {Buffer} containing the member's whole + decompressed content. + +The synchronous version of [`zipFile.stream()`][]. There is no public +synchronous incremental inflate API, so - unlike `stream()`, which is +bounded-memory - this reads the member's complete compressed bytes and +materializes its entire decoded content before yielding it as one chunk. + +### `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`][]. +* `comment` {string} An archive comment. +* Returns: {AsyncIterator} of {Buffer} chunks making up 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. + +```mjs +import { createWriteStream } from 'node:fs'; +import { pipeline } from 'node:stream/promises'; +import { Readable } from 'node:stream'; +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( + Readable.from(createZipArchive(entries, 'created by node:zlib')), + createWriteStream('archive.zip'), +); +``` + +```cjs +const { createWriteStream } = require('node:fs'); +const { pipeline } = require('node:stream/promises'); +const { Readable } = require('node:stream'); +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( + Readable.from(createZipArchive(entries, 'created by node:zlib')), + createWriteStream('archive.zip'), + ); +} +main(); +``` + +## `zlib.createZipArchiveSync(entries[, comment])` + + + +> Stability: 1 - Experimental + +* `entries` {Iterable} of [`ZipEntry`][]. +* `comment` {string} An archive comment. +* 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.createZstdCompress([options])` > Stability: 1 - Experimental @@ -1358,6 +2335,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.contentStream()`][], [`zipFile.stream()`][]) are bounded-memory +by design and are not affected by this setting. + ## Convenience methods @@ -2023,11 +3030,20 @@ 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_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 +3053,33 @@ 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()`]: #zipbuffertobuffercomment +[`zipBuffer.toBufferSync()`]: #zipbuffertobuffersynccomment +[`zipEntry.content()`]: #zipentrycontentoptions +[`zipEntry.contentStream()`]: #zipentrycontentstreamoptions +[`zipEntry.name`]: #zipentryname +[`zipFile.add()`]: #zipfileaddfilename-data-options +[`zipFile.addEntry()`]: #zipfileaddentryentry +[`zipFile.close()`]: #zipfileclose +[`zipFile.comment`]: #zipfilecomment +[`zipFile.compact()`]: #zipfilecompactcomment +[`zipFile.delete()`]: #zipfiledeletename +[`zipFile.entries()`]: #zipfileentries +[`zipFile.forEach()`]: #zipfileforeachcallback-thisarg +[`zipFile.get()`]: #zipfilegetname +[`zipFile.stream()`]: #zipfilestreamname-options +[`zipFile.streamSync()`]: #zipfilestreamsyncname-options +[`zipFile.values()`]: #zipfilevalues +[`zlib.ZipEntry.create()`]: #static-method-zlibzipentrycreatefilename-data-options +[`zlib.ZipEntry.createStream()`]: #static-method-zlibzipentrycreatestreamfilename-source-options +[`zlib.ZipEntry.createSync()`]: #static-method-zlibzipentrycreatesyncfilename-data-options +[`zlib.ZipFile.open()`]: #static-method-zlibzipfileopenfilename-options +[`zlib.createZipArchive()`]: #zlibcreateziparchiveentries-comment +[`zlib.createZipArchiveSync()`]: #zlibcreateziparchivesyncentries-comment +[`zlib.getMaxZipContentSize()`]: #zlibgetmaxzipcontentsize [convenience methods]: #convenience-methods [zlib documentation]: https://zlib.net/manual.html#Constants [zlib.createGzip example]: #zlib diff --git a/lib/internal/errors.js b/lib/internal/errors.js index 03d27332c894d8..b69ce55e9d53e9 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -2002,4 +2002,10 @@ 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_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/vfs/providers/archive.js b/lib/internal/vfs/providers/archive.js new file mode 100644 index 00000000000000..8d03cfe6acc3b3 --- /dev/null +++ b/lib/internal/vfs/providers/archive.js @@ -0,0 +1,526 @@ +'use strict'; + +const { + ArrayPrototypeIndexOf, + ArrayPrototypePush, + MathMax, + MathMin, + StringPrototypeIndexOf, + StringPrototypeSlice, + StringPrototypeStartsWith, +} = primordials; + +const { Buffer } = require('buffer'); +const { + codes: { + ERR_INVALID_ARG_TYPE, + ERR_METHOD_NOT_IMPLEMENTED, + }, +} = require('internal/errors'); +const { VirtualProvider } = require('internal/vfs/provider'); +const { VirtualFileHandle } = require('internal/vfs/file_handle'); +const { + createEEXIST, + createEISDIR, + createENOENT, + createENOTDIR, + createENOTEMPTY, + createEROFS, +} = require('internal/vfs/errors'); +const { createFileStats, createDirectoryStats } = require('internal/vfs/stats'); +const { Dirent } = require('internal/fs/utils'); +const { + fs: { UV_DIRENT_DIR, UV_DIRENT_FILE }, +} = internalBinding('constants'); +const { ZipBuffer, ZipFile } = require('internal/zip'); + +const EMPTY_BUFFER = Buffer.alloc(0); + +function normalize(vfsPath) { + return StringPrototypeStartsWith(vfsPath, '/') ? StringPrototypeSlice(vfsPath, 1) : vfsPath; +} + +function isCurrentPosition(position) { + return position === null || position === undefined || position === -1; +} + +function isWriteTruncate(flags) { + return flags === 'w' || flags === 'w+' || flags === 'wx' || flags === 'wx+'; +} + +function isAppend(flags) { + return flags === 'a' || flags === 'a+' || flags === 'ax' || flags === 'ax+'; +} + +function isReadableFlag(flags) { + return flags !== 'w' && flags !== 'a' && flags !== 'wx' && flags !== 'ax'; +} + +function isWritableFlag(flags) { + return flags !== 'r'; +} + +/** + * The `options.method` value that reproduces `method` (a `zipEntry.method` + * raw compression method number) on `add()`/`addSync()`, so `rename()` + * doesn't silently recompress an entry with a different method than the one + * it already had (e.g. turning a zstd-compressed entry into a stored one). + * @param {number} method + * @returns {'store' | 'zstd' | 'deflate'} + */ +function methodOption(method) { + if (method === 0) return 'store'; + if (method === 93) return 'zstd'; + return 'deflate'; +} + +/** + * A file handle over one ZIP entry. ZIP members can't be edited in place + * (they're a single compressed blob), so writes accumulate in memory and are + * only committed - as a brand-new entry - when the handle is closed. Since + * this is all in-memory buffer manipulation with no real I/O, every method + * and its `*Sync` counterpart share one private implementation. + */ +class ArchiveFileHandle extends VirtualFileHandle { + #source; + #name; + #buffer; + #size; + #dirty = false; + + /** + * @param {string} path + * @param {string} flags + * @param {number} mode + * @param {ZipBuffer | ZipFile} source + * @param {string} name The archive-relative entry name + * @param {Buffer} initial The entry's current decompressed content, or an + * empty buffer for a new/truncated file + */ + constructor(path, flags, mode, source, name, initial) { + super(path, flags, mode); + this.#source = source; + this.#name = name; + this.#buffer = initial; + this.#size = initial.length; + if (isAppend(flags)) this.position = this.#size; + } + + #checkReadable() { + if (!isReadableFlag(this.flags)) throw createEISDIR('read', this.path); + } + #checkWritable() { + if (!isWritableFlag(this.flags)) throw createEISDIR('write', this.path); + } + #ensureCapacity(size) { + if (size <= this.#buffer.length) return; + const capacity = MathMax(size, this.#buffer.length * 2); + const grown = Buffer.alloc(capacity); + this.#buffer.copy(grown, 0, 0, this.#size); + this.#buffer = grown; + } + + #doRead(buffer, offset, length, position) { + this.#checkReadable(); + const useCurrent = isCurrentPosition(position); + const pos = useCurrent ? this.position : position; + const available = MathMax(0, this.#size - pos); + const bytesRead = MathMin(length, available); + if (bytesRead > 0) this.#buffer.copy(buffer, offset, pos, pos + bytesRead); + if (useCurrent) this.position = pos + bytesRead; + return { __proto__: null, bytesRead, buffer }; + } + async read(buffer, offset, length, position) { + return this.#doRead(buffer, offset, length, position); + } + readSync(buffer, offset, length, position) { + return this.#doRead(buffer, offset, length, position); + } + + #doWrite(buffer, offset, length, position) { + this.#checkWritable(); + const useCurrent = isCurrentPosition(position); + const pos = isAppend(this.flags) ? this.#size : (useCurrent ? this.position : position); + this.#ensureCapacity(pos + length); + buffer.copy(this.#buffer, pos, offset, offset + length); + if (pos + length > this.#size) this.#size = pos + length; + this.#dirty = true; + if (useCurrent) this.position = pos + length; + return { __proto__: null, bytesWritten: length, buffer }; + } + async write(buffer, offset, length, position) { + return this.#doWrite(buffer, offset, length, position); + } + writeSync(buffer, offset, length, position) { + return this.#doWrite(buffer, offset, length, position); + } + + #doReadFile(options) { + this.#checkReadable(); + const encoding = typeof options === 'string' ? options : options?.encoding; + const content = this.#buffer.subarray(0, this.#size); + return encoding && encoding !== 'buffer' ? content.toString(encoding) : Buffer.from(content); + } + async readFile(options) { + return this.#doReadFile(options); + } + readFileSync(options) { + return this.#doReadFile(options); + } + + // Replaces content, except in append mode ('a'/'a+'/'ax'/'ax+'), where it + // appends to the existing content instead - matching MemoryFileHandle and + // what makes `appendFile()`/`appendFileSync()` (built on this, by + // VirtualProvider's defaults) actually append. + #doWriteFile(data, options) { + this.#checkWritable(); + const content = typeof data === 'string' ? Buffer.from(data, options?.encoding) : Buffer.from(data); + if (isAppend(this.flags)) { + this.#ensureCapacity(this.#size + content.length); + content.copy(this.#buffer, this.#size); + this.#size += content.length; + } else { + this.#buffer = content; + this.#size = content.length; + } + this.#dirty = true; + } + async writeFile(data, options) { + this.#doWriteFile(data, options); + } + writeFileSync(data, options) { + this.#doWriteFile(data, options); + } + + #doStat() { + return createFileStats(this.#size, { mode: this.mode }); + } + async stat(options) { + return this.#doStat(); + } + statSync(options) { + return this.#doStat(); + } + + #doTruncate(len) { + this.#checkWritable(); + this.#ensureCapacity(len); + this.#size = len; + this.#dirty = true; + } + async truncate(len = 0) { + this.#doTruncate(len); + } + truncateSync(len = 0) { + this.#doTruncate(len); + } + + async close() { + if (this.#dirty && isWritableFlag(this.flags)) { + await this.#source.add(this.#name, this.#buffer.subarray(0, this.#size), { mode: this.mode }); + } + await super.close(); + } + closeSync() { + if (this.#dirty && isWritableFlag(this.flags)) { + this.#source.addSync(this.#name, this.#buffer.subarray(0, this.#size), { mode: this.mode }); + } + super.closeSync(); + } +} + +/** + * A `node:vfs` provider backed by a ZIP archive: either a [`ZipBuffer`][] (in + * memory) or a [`ZipFile`][] (on disk). Read-only unless the underlying + * archive is writable (a `ZipBuffer`, or a `ZipFile` opened with + * `{ writable: true }`). Every method has a synchronous counterpart, backed + * by the equally complete synchronous surface `ZipBuffer`/`ZipFile` expose; + * as with those, the synchronous methods here block the Node.js event loop + * and further JavaScript execution until the operation (including any + * deflate/inflate pass) completes. + */ +class ArchiveProvider extends VirtualProvider { + #source; + + /** + * @param {ZipBuffer | ZipFile} source + */ + constructor(source) { + super(); + if (!(source instanceof ZipBuffer) && !(source instanceof ZipFile)) { + throw new ERR_INVALID_ARG_TYPE('source', ['ZipBuffer', 'ZipFile'], source); + } + this.#source = source; + } + + get readonly() { return !this.#source.writable; } + + /** + * @param {string} name + * @returns {Promise} + */ + async #getEntry(name) { + return this.#source.has(name) ? this.#source.get(name) : null; + } + /** + * @param {string} name + * @returns {import('internal/zip').ZipEntry | null} + */ + #getEntrySync(name) { + if (!this.#source.has(name)) return null; + // `ZipBuffer.prototype.get` is already synchronous (it has no `getSync` + // of its own); `ZipFile.prototype.get` is asynchronous, so its `getSync` + // is used instead when present. + return typeof this.#source.getSync === 'function' ? + this.#source.getSync(name) : this.#source.get(name); + } + /** + * @param {string} name + * @returns {boolean} + */ + #deleteEntrySync(name) { + // Same reasoning as `#getEntrySync`: `ZipBuffer.prototype.delete` is + // already synchronous; `ZipFile.prototype.delete` is not, so its + // `deleteSync` is used instead when present. + return typeof this.#source.deleteSync === 'function' ? + this.#source.deleteSync(name) : this.#source.delete(name); + } + + /** + * Whether `name` (no trailing slash) is a directory: either explicitly + * (a `"name/"` entry) or implicitly (some entry starts with `"name/"`). + * @param {string} name + * @returns {boolean} + */ + #isDirectory(name) { + const prefix = `${name}/`; + if (this.#source.has(prefix)) return true; + for (const key of this.#source.keys()) { + if (StringPrototypeStartsWith(key, prefix)) return true; + } + return false; + } + + async open(path, flags, mode) { + const name = normalize(path); + const fileEntry = await this.#getEntry(name); + if (fileEntry === null && this.#isDirectory(name)) { + throw createEISDIR('open', path); + } + const exists = fileEntry !== null; + if ((isWriteTruncate(flags) || isAppend(flags)) && this.readonly) { + throw createEROFS('open', path); + } + if ((flags === 'wx' || flags === 'wx+' || flags === 'ax' || flags === 'ax+') && exists) { + throw createEEXIST('open', path); + } + if (!exists && (flags === 'r' || flags === 'r+')) { + throw createENOENT('open', path); + } + let initial = EMPTY_BUFFER; + if (exists && !isWriteTruncate(flags)) { + initial = await fileEntry.content(); + } + return new ArchiveFileHandle(path, flags, mode, this.#source, name, initial); + } + openSync(path, flags, mode) { + const name = normalize(path); + const fileEntry = this.#getEntrySync(name); + if (fileEntry === null && this.#isDirectory(name)) { + throw createEISDIR('open', path); + } + const exists = fileEntry !== null; + if ((isWriteTruncate(flags) || isAppend(flags)) && this.readonly) { + throw createEROFS('open', path); + } + if ((flags === 'wx' || flags === 'wx+' || flags === 'ax' || flags === 'ax+') && exists) { + throw createEEXIST('open', path); + } + if (!exists && (flags === 'r' || flags === 'r+')) { + throw createENOENT('open', path); + } + let initial = EMPTY_BUFFER; + if (exists && !isWriteTruncate(flags)) { + initial = fileEntry.contentSync(); + } + return new ArchiveFileHandle(path, flags, mode, this.#source, name, initial); + } + + async stat(path, options) { + const name = normalize(path); + if (name === '') return createDirectoryStats({ mode: 0o755 }); + const entry = await this.#getEntry(name) ?? await this.#getEntry(`${name}/`); + if (entry !== null) { + return entry.isDirectory ? + createDirectoryStats({ mode: entry.mode || 0o755, mtimeMs: entry.modified.getTime() }) : + createFileStats(entry.size, { mode: entry.mode || 0o644, mtimeMs: entry.modified.getTime() }); + } + if (this.#isDirectory(name)) return createDirectoryStats({ mode: 0o755 }); + throw createENOENT('stat', path); + } + statSync(path, options) { + const name = normalize(path); + if (name === '') return createDirectoryStats({ mode: 0o755 }); + const entry = this.#getEntrySync(name) ?? this.#getEntrySync(`${name}/`); + if (entry !== null) { + return entry.isDirectory ? + createDirectoryStats({ mode: entry.mode || 0o755, mtimeMs: entry.modified.getTime() }) : + createFileStats(entry.size, { mode: entry.mode || 0o644, mtimeMs: entry.modified.getTime() }); + } + if (this.#isDirectory(name)) return createDirectoryStats({ mode: 0o755 }); + throw createENOENT('stat', path); + } + + #readdirEntries(path, name, options, stats) { + if (!stats.isDirectory()) throw createENOTDIR('scandir', path); + const prefix = name === '' ? '' : `${name}/`; + const withFileTypes = options?.withFileTypes === true; + const names = []; + const isDir = []; + for (const key of this.#source.keys()) { + if (!StringPrototypeStartsWith(key, prefix)) continue; + const rest = StringPrototypeSlice(key, prefix.length); + if (rest === '') continue; // The directory's own explicit entry + const slash = StringPrototypeIndexOf(rest, '/'); + const childName = slash === -1 ? rest : StringPrototypeSlice(rest, 0, slash); + const childIsDir = slash !== -1; + const existingIndex = ArrayPrototypeIndexOf(names, childName); + if (existingIndex !== -1) { + if (childIsDir) isDir[existingIndex] = true; + continue; + } + ArrayPrototypePush(names, childName); + ArrayPrototypePush(isDir, childIsDir); + } + const result = []; + for (let i = 0; i < names.length; i++) { + if (withFileTypes) { + ArrayPrototypePush(result, new Dirent(names[i], isDir[i] ? UV_DIRENT_DIR : UV_DIRENT_FILE, name)); + } else { + ArrayPrototypePush(result, names[i]); + } + } + return result; + } + async readdir(path, options) { + if (options?.recursive) { + throw new ERR_METHOD_NOT_IMPLEMENTED("readdir with { recursive: true } on an 'archive' provider"); + } + const name = normalize(path); + return this.#readdirEntries(path, name, options, await this.stat(path)); + } + readdirSync(path, options) { + if (options?.recursive) { + throw new ERR_METHOD_NOT_IMPLEMENTED("readdirSync with { recursive: true } on an 'archive' provider"); + } + const name = normalize(path); + return this.#readdirEntries(path, name, options, this.statSync(path)); + } + + async mkdir(path, options) { + if (this.readonly) throw createEROFS('mkdir', path); + const name = normalize(path); + if (await this.exists(path)) { + if (options?.recursive) return undefined; + throw createEEXIST('mkdir', path); + } + await this.#source.add(`${name}/`, EMPTY_BUFFER, { mode: options?.mode }); + return undefined; + } + mkdirSync(path, options) { + if (this.readonly) throw createEROFS('mkdir', path); + const name = normalize(path); + if (this.existsSync(path)) { + if (options?.recursive) return undefined; + throw createEEXIST('mkdir', path); + } + this.#source.addSync(`${name}/`, EMPTY_BUFFER, { mode: options?.mode }); + return undefined; + } + + async rmdir(path) { + if (this.readonly) throw createEROFS('rmdir', path); + const name = normalize(path); + const stats = await this.stat(path); + if (!stats.isDirectory()) throw createENOTDIR('rmdir', path); + const prefix = `${name}/`; + for (const key of this.#source.keys()) { + if (key !== prefix && StringPrototypeStartsWith(key, prefix)) { + throw createENOTEMPTY('rmdir', path); + } + } + if (!this.#source.has(prefix)) { + // An implicit-only directory can never be empty (something has to be + // under it for it to exist at all), so getting here means `path` is + // not a directory this provider can remove. + throw createENOENT('rmdir', path); + } + await this.#source.delete(prefix); + } + rmdirSync(path) { + if (this.readonly) throw createEROFS('rmdir', path); + const name = normalize(path); + const stats = this.statSync(path); + if (!stats.isDirectory()) throw createENOTDIR('rmdir', path); + const prefix = `${name}/`; + for (const key of this.#source.keys()) { + if (key !== prefix && StringPrototypeStartsWith(key, prefix)) { + throw createENOTEMPTY('rmdir', path); + } + } + if (!this.#source.has(prefix)) { + throw createENOENT('rmdir', path); + } + this.#deleteEntrySync(prefix); + } + + async unlink(path) { + if (this.readonly) throw createEROFS('unlink', path); + const name = normalize(path); + if (!this.#source.has(name)) { + throw this.#isDirectory(name) ? createEISDIR('unlink', path) : createENOENT('unlink', path); + } + await this.#source.delete(name); + } + unlinkSync(path) { + if (this.readonly) throw createEROFS('unlink', path); + const name = normalize(path); + if (!this.#source.has(name)) { + throw this.#isDirectory(name) ? createEISDIR('unlink', path) : createENOENT('unlink', path); + } + this.#deleteEntrySync(name); + } + + async rename(oldPath, newPath) { + if (this.readonly) throw createEROFS('rename', oldPath); + const oldName = normalize(oldPath); + const newName = normalize(newPath); + const entry = await this.#getEntry(oldName); + if (entry === null) throw createENOENT('rename', oldPath); + const content = await entry.content(); + await this.#source.add(newName, content, { + mode: entry.mode || undefined, + modified: entry.modified, + method: methodOption(entry.method), + }); + await this.#source.delete(oldName); + } + renameSync(oldPath, newPath) { + if (this.readonly) throw createEROFS('rename', oldPath); + const oldName = normalize(oldPath); + const newName = normalize(newPath); + const entry = this.#getEntrySync(oldName); + if (entry === null) throw createENOENT('rename', oldPath); + const content = entry.contentSync(); + this.#source.addSync(newName, content, { + mode: entry.mode || undefined, + modified: entry.modified, + method: methodOption(entry.method), + }); + this.#deleteEntrySync(oldName); + } +} + +module.exports = { + ArchiveProvider, +}; diff --git a/lib/internal/zip.js b/lib/internal/zip.js new file mode 100644 index 00000000000000..4a957c82d4608d --- /dev/null +++ b/lib/internal/zip.js @@ -0,0 +1,2325 @@ +'use strict'; + +const { + ArrayPrototypePush, + BigInt, + Date, + FunctionPrototypeCall, + JSONStringify, + Map, + MapPrototypeClear, + MapPrototypeDelete, + MapPrototypeEntries, + MapPrototypeGet, + MapPrototypeGetSize, + MapPrototypeHas, + MapPrototypeKeys, + MapPrototypeSet, + MathMax, + MathMin, + Number, + NumberIsInteger, + NumberIsNaN, + NumberMAX_SAFE_INTEGER, + Promise, + PromisePrototypeThen, + PromiseResolve, + StringPrototypeEndsWith, + Symbol, + SymbolAsyncDispose, + SymbolAsyncIterator, + SymbolDispose, + SymbolIterator, + SymbolToStringTag, +} = primordials; + +const { + codes: { + ERR_INVALID_ARG_TYPE, + ERR_INVALID_ARG_VALUE, + ERR_INVALID_STATE, + ERR_ZIP_ENTRY_CORRUPT, + ERR_ZIP_ENTRY_NOT_FOUND, + ERR_ZIP_ENTRY_TOO_LARGE, + ERR_ZIP_INVALID_ARCHIVE, + ERR_ZIP_NOT_WRITABLE, + ERR_ZIP_UNSUPPORTED_FEATURE, + }, +} = require('internal/errors'); +const { + validateBoolean, + validateFunction, + validateInteger, + validateString, + validateUint32, +} = require('internal/validators'); +const { + isAnyArrayBuffer, + isArrayBufferView, + isDate, + isUint8Array, +} = require('internal/util/types'); +const { Buffer, kMaxLength } = require('buffer'); +const { FastBuffer } = require('internal/buffer'); +const { Readable } = require('stream'); +const fs = require('fs'); +const { crc32: crc32Native } = internalBinding('zlib'); + +// `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; +} + +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 S_IFREG = 0o100000; // Unix mode type bits: regular file +const S_IFDIR = 0o040000; // Unix mode type bits: directory + +const kFinalize = Symbol('kFinalize'); + +// 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 +// (`contentStream()`, `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; +} + +// 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, + ); +} + +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 }; +} + +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`); + } +} + +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); +} + +function writeSafeUint64(buffer, offset, value) { + buffer.writeBigUInt64LE(BigInt(value), offset); +} + +// Zip64 extended information extra field (sec. 4.5.3). +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 = {}; + let cursor = pos + 4; + const end = pos + 4 + size; + const take = (bytes) => { + if (cursor + bytes > end) { + throw new ERR_ZIP_INVALID_ARCHIVE( + 'the Zip64 extended information extra field is truncated'); + } + const value = bytes === 8 ? + readSafeUint64(extra, cursor) : extra.readUInt32LE(cursor); + cursor += bytes; + return value; + }; + if (want.uncompressedSize) result.uncompressedSize = take(8); + if (want.compressedSize) result.compressedSize = take(8); + if (want.localFileHeaderOffset) result.localFileHeaderOffset = take(8); + if (want.diskNumber) result.diskNumber = take(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'); +} + +// 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); } + 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); } + 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); } + #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 this.fileNameBuffer.toString('utf8'); } + 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 this.fileCommentBuffer.toString('utf8'); } + get mode() { + const madeBy = this.version >>> 8; + if (madeBy !== MADE_BY_UNIX) return 0; + return (this.externalFileAttributes >>> 16) & 0x1ff; + } +} + +// 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); } + get compressionMethod() { return this.#buffer.readUInt16LE(this.#offset + 8); } + get lastModified() { + return decodeDosDateTime( + this.#buffer.readUInt16LE(this.#offset + 10), + this.#buffer.readUInt16LE(this.#offset + 12)); + } + get fileNameLength() { return this.#buffer.readUInt16LE(this.#offset + 26); } + get extraFieldLength() { return this.#buffer.readUInt16LE(this.#offset + 28); } + get fileName() { + const start = this.#offset + 30; + return this.#buffer.toString('utf8', start, start + this.fileNameLength); + } + 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. + * @returns {{ + * prefix: number, + * totalRecords: number, + * centralDirectoryOffset: number, + * centralDirectorySize: number, + * comment: Buffer, + * }} + */ +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; + const locatorPos = eocdPos - 20; + if ( + locatorPos >= 0 && + buffer.readUInt32LE(locatorPos) === SIG_ZIP64_EOCD_LOCATOR + ) { + const locator = new Zip64EndLocator(buffer, locatorPos); + if (locator.totalDisks > 1 || locator.recordDiskNumber !== 0) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported'); + } + let recordPos = locator.recordOffset - base; + 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) { + throw new ERR_ZIP_INVALID_ARCHIVE('Zip64 end of central directory record not found'); + } + } + const zip64 = new Zip64EndRecord(buffer, recordPos); + 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, + }; +} + +// -- header builders (write path) -------------------------------------------- + +function buildLocalHeader(meta) { + const streaming = (meta.flags & FLAG_DATA_DESCRIPTOR) !== 0; + const zip64 = + streaming || + meta.compressedSize >= SENTINEL32 || + meta.uncompressedSize >= SENTINEL32; + const extraLength = zip64 ? 20 : 0; + const buffer = Buffer.allocUnsafe(30 + meta.name.length + extraLength); + buffer.writeUInt32LE(SIG_LOCAL_FILE_HEADER, 0); + buffer.writeUInt16LE(zip64 ? VERSION_ZIP64 : VERSION_DEFAULT, 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); + if (zip64) { + const pos = 30 + meta.name.length; + 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); + } + return buffer; +} + +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 extraLength = zip64Fields ? 4 + 8 * zip64Fields : 0; + const zip64 = zip64Streaming || meta.compressedSize >= SENTINEL32 || + meta.uncompressedSize >= SENTINEL32 || zip64Fields > 0; + const buffer = Buffer.allocUnsafe( + 46 + meta.name.length + extraLength + meta.comment.length); + buffer.writeUInt32LE(SIG_CENTRAL_FILE_HEADER, 0); + buffer.writeUInt16LE( + (MADE_BY_UNIX << 8) | (zip64 ? VERSION_ZIP64 : VERSION_DEFAULT), 4); + buffer.writeUInt16LE(zip64 ? VERSION_ZIP64 : VERSION_DEFAULT, 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; + } + } + 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; +} + +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; +} + +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; +} + +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; +} + +// -- buffer coercion ---------------------------------------------------------- + +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); +} + +// -- compression plumbing ------------------------------------------------------ + +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); + }); + }); +} + +async function* pumpThroughTransform(source, transform) { + const input = Readable.from(source); + input.on('error', (err) => transform.destroy(err)); + input.pipe(transform); + try { + yield* transform; + } finally { + if (!input.destroyed) input.destroy(); + if (!transform.destroyed) transform.destroy(); + } +} + +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()); +} + +/** + * @typedef {{ + * name: string, + * flags: number, + * method: number, + * crc32: number, + * uncompressedSize: number, + * }} ZipMemberInfo + */ + +/** + * 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) { + 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}`); + } + const verify = options?.verify !== false; + 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`); + } + 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`); + } +} + +/** + * The synchronous counterpart of `decodeMemberStream()`. 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) { + 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}`); + } + const verify = options?.verify !== false; + 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`); + } + let data; + if (info.method === METHOD_DEFLATE) { + try { + data = lazyZlib().inflateRawSync( + compressed, options?.maxSize !== undefined ? { maxOutputLength: options.maxSize } : undefined); + } catch (err) { + if (err?.code === 'ERR_BUFFER_TOO_LARGE') { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + `entry ${JSONStringify(info.name)} inflates beyond the ${options.maxSize} byte limit`); + } + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(info.name)} failed to inflate: ${err.message}`); + } + } else if (info.method === METHOD_ZSTD) { + try { + data = lazyZlib().zstdDecompressSync( + compressed, options?.maxSize !== undefined ? { maxOutputLength: options.maxSize } : undefined); + } catch (err) { + if (err?.code === 'ERR_BUFFER_TOO_LARGE') { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + `entry ${JSONStringify(info.name)} decompresses beyond the ${options.maxSize} byte limit`); + } + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(info.name)} failed to decompress: ${err.message}`); + } + } else { + data = compressed; + } + if (data.length !== info.uncompressedSize) { + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(info.name)} produced ${data.length} bytes, expected ` + + `${info.uncompressedSize}`); + } + if (verify) { + const crc = crc32Native(data, 0); + if (crc !== info.crc32) { + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(info.name)} failed CRC-32 verification`); + } + } + return data; +} + +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 isDirectory = StringPrototypeEndsWith(filename, '/'); + const mode = options?.mode ?? (isDirectory ? 0o755 : 0o644); + validateUint32(mode, 'options.mode'); + const modified = options?.modified ?? new Date(); + 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 = isDirectory ? S_IFDIR : S_IFREG; + const unixAttrs = (typeBits | (mode & 0o7777)) & SENTINEL16; + const external = ((unixAttrs << 16) | (isDirectory ? 0x10 : 0)) >>> 0; + return { + name, + comment, + flags: FLAG_UTF8, + method: 0, + crc: 0, + compressedSize: 0, + uncompressedSize: 0, + modified, + external, + internal: 0, + 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; + + /** + * @private + */ + constructor(central, local, content) { + this.#central = central; + this.#local = local; + this.#content = content; + } + + get compressed() { return this.method === 8; } + 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.flags; + } + get crc32() { + if (this.#meta) { + this.#assertNotPending(); + return this.#meta.crc; + } + return this.#central.crc32; + } + get name() { + return this.#meta ? this.#meta.name.toString('utf8') : this.#local.fileName; + } + get comment() { + return this.#meta ? this.#meta.comment.toString('utf8') : 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() { + return this.#meta ? this.#meta.modified : this.#local.lastModified; + } + get mode() { + if (this.#meta) return (this.#meta.external >>> 16) & 0x1ff; + return this.#central.mode; + } + get isFile() { return !this.isDirectory; } + get isDirectory() { return StringPrototypeEndsWith(this.name, '/'); } + + #assertNotPending() { + if (this.#meta?.pending) { + throw new ERR_INVALID_STATE( + 'this streaming entry has not finished serializing yet'); + } + } + + #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: re-serialization never reproduces a bit-3 local header + // without a data descriptor. Sizes come from the central directory + // (Zip64-aware); foreign extra fields are dropped and Zip64 extras are + // regenerated as needed. + const meta = { + name: central.fileNameBuffer, + comment: central.fileCommentBuffer, + flags: central.flags & ~FLAG_DATA_DESCRIPTOR, + method: central.compressionMethod, + crc: central.crc32, + compressedSize: central.compressedSize, + uncompressedSize: central.uncompressedSize, + modified: central.lastModified, + external: central.externalFileAttributes, + internal: central.internalFileAttributes, + pending: false, + }; + this.#meta = meta; + return meta; + } + + /** + * @param {{ verify?: boolean, maxSize?: number }} [options] + * @returns {Promise} + */ + async content(options) { + if (this.flags & FLAG_ENCRYPTED) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE( + `entry ${JSONStringify(this.name)} is encrypted`); + } + if (this.method !== METHOD_STORE && this.method !== METHOD_DEFLATE && this.method !== METHOD_ZSTD) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE( + `entry ${JSONStringify(this.name)} uses compression method ${this.method}`); + } + const declared = this.size; + const maxSize = options?.maxSize ?? maxZipContentSize; + if (declared > maxSize) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + `entry ${JSONStringify(this.name)} declares ${declared} bytes, ` + + `exceeding the ${maxSize} byte limit`); + } + const verify = options?.verify !== false; + const compressed = this.#content ?? EMPTY_BUFFER; + let data; + if (this.method === METHOD_DEFLATE) { + try { + data = await inflateRawAsync(compressed, { maxOutputLength: maxSize }); + } catch (err) { + if (err?.code === 'ERR_BUFFER_TOO_LARGE') { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + `entry ${JSONStringify(this.name)} inflates beyond the ${maxSize} byte limit`); + } + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(this.name)} failed to inflate: ${err.message}`); + } + } else if (this.method === METHOD_ZSTD) { + try { + data = await zstdDecompressAsync(compressed, { maxOutputLength: maxSize }); + } catch (err) { + if (err?.code === 'ERR_BUFFER_TOO_LARGE') { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + `entry ${JSONStringify(this.name)} decompresses beyond the ${maxSize} byte limit`); + } + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(this.name)} failed to decompress: ${err.message}`); + } + } else { + data = compressed; + } + if (data.length !== declared) { + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(this.name)} produced ${data.length} bytes, expected ${declared}`); + } + if (verify) { + const crc = crc32Native(data, 0); + if (crc !== this.crc32) { + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(this.name)} failed CRC-32 verification`); + } + } + return 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 ?? maxZipContentSize; + 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.#content ?? EMPTY_BUFFER; + return decodeMemberSync(compressed, { + name: this.name, + flags: this.flags, + method: this.method, + crc32: this.crc32, + uncompressedSize: declared, + }, { verify: options?.verify, maxSize }); + } + + /** + * @param {{ verify?: boolean, maxSize?: number }} [options] + * @returns {AsyncGenerator} + */ + contentStream(options) { + const content = this.#content; + if (content === null) { + throw new ERR_INVALID_STATE( + 'the content of a streaming entry is not available for reading'); + } + const source = (async function* () { + if (content.length) yield content; + })(); + return decodeMemberStream(source, { + name: this.name, + flags: this.flags, + method: this.method, + crc32: this.crc32, + uncompressedSize: this.size, + }, options); + } + + /** + * The synchronous counterpart of `contentStream()`. There is no public + * synchronous incremental inflate API, so - unlike `contentStream()`, + * which is bounded-memory - this materializes the entire entry before + * yielding it as a single chunk. Blocks the event loop until done; see + * `contentSync()`. + * @param {{ verify?: boolean, maxSize?: number }} [options] + * @yields {Buffer} + */ + *contentStreamSync(options) { + yield this.contentSync(options); + } + + *[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.#content?.length) yield this.#content; + } + + async *[SymbolAsyncIterator]() { + const source = this.#source; + if (!source) { + 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); + } + + /** + * @private + * @param {number} localOffset + * @returns {Buffer} + */ + [kFinalize](localOffset) { + validateInteger(localOffset, 'localOffset', 0, NumberMAX_SAFE_INTEGER); + return buildCentralHeader(this.#finalizeMeta(), localOffset); + } + + /** + * @param {Buffer | TypedArray | DataView | ArrayBuffer} buffer + * @yields {ZipEntry} + */ + static *read(buffer) { + const buf = toBuffer(buffer, 'buffer'); + const end = findArchiveEnd(buf); + let pos = end.centralDirectoryOffset; + const cdEnd = end.centralDirectoryOffset + end.centralDirectorySize; + 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; + yield new ZipEntry(central, local, content); + pos = central.byteOffset + central.byteLength; + } + } + + /** + * @param {string} filename + * @param {Buffer | TypedArray | DataView | ArrayBuffer} data + * @param {{ + * comment?: string, + * mode?: number, + * modified?: Date, + * method?: 'deflate' | 'store' | 'zstd', + * }} [options] + * @returns {Promise} + */ + static async create(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; + let finalContent = content; + let method = isDirectory || content.length === 0 || options?.method === 'store' ? METHOD_STORE : + options?.method === 'zstd' ? METHOD_ZSTD : METHOD_DEFLATE; + if (method === METHOD_DEFLATE) { + const compressed = await deflateRawAsync(content); + if (compressed.length >= content.length) { + method = METHOD_STORE; // Deflate did not help; fall back to storing + } else { + finalContent = compressed; + } + } else if (method === METHOD_ZSTD) { + const compressed = await zstdCompressAsync(content); + if (compressed.length >= content.length) { + method = METHOD_STORE; // Zstd did not help; fall back to storing + } else { + finalContent = compressed; + } + } + meta.method = method; + meta.compressedSize = finalContent.length; + meta.pending = false; + const entry = new ZipEntry(null, null, finalContent); + entry.#meta = meta; + return entry; + } + + /** + * The synchronous counterpart of `create()`. Blocks the event loop and + * further JavaScript execution until done (including the deflate pass); + * see `contentSync()`. + * @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 = 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; + let finalContent = content; + let method = isDirectory || content.length === 0 || options?.method === 'store' ? METHOD_STORE : + options?.method === 'zstd' ? METHOD_ZSTD : METHOD_DEFLATE; + if (method === METHOD_DEFLATE) { + const compressed = lazyZlib().deflateRawSync(content); + if (compressed.length >= content.length) { + method = METHOD_STORE; // Deflate did not help; fall back to storing + } else { + finalContent = compressed; + } + } else if (method === METHOD_ZSTD) { + const compressed = lazyZlib().zstdCompressSync(content); + if (compressed.length >= content.length) { + method = METHOD_STORE; // Zstd did not help; fall back to storing + } else { + finalContent = compressed; + } + } + meta.method = method; + meta.compressedSize = finalContent.length; + meta.pending = false; + const entry = new ZipEntry(null, null, finalContent); + entry.#meta = meta; + return entry; + } + + /** + * @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; + } +} + +/** + * Serializes `entries` (a (async) iterable of `ZipEntry`) into a 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. + * @param {Iterable | AsyncIterable} entries + * @param {string} [comment] + * @yields {Buffer} + */ +async function* createZipArchive(entries, comment) { + let commentBuffer = EMPTY_BUFFER; + if (comment !== undefined) { + validateString(comment, 'comment'); + commentBuffer = Buffer.from(comment, 'utf8'); + if (commentBuffer.length > SENTINEL16) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + 'the archive comment must not exceed 65535 bytes when encoded as UTF-8'); + } + } + const centralHeaders = []; + let pos = 0; + 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 zip64 = + count >= SENTINEL16 || + centralDirectoryOffset >= SENTINEL32 || + centralDirectorySize >= SENTINEL32; + if (zip64) { + const recordOffset = pos; + yield buildZip64EndRecord(count, centralDirectorySize, centralDirectoryOffset); + yield buildZip64EndLocator(recordOffset); + } + yield buildEndOfCentralDirectory( + count, centralDirectorySize, centralDirectoryOffset, commentBuffer); +} + +/** + * 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] + * @yields {Buffer} + */ +function* createZipArchiveSync(entries, comment) { + let commentBuffer = EMPTY_BUFFER; + if (comment !== undefined) { + validateString(comment, 'comment'); + commentBuffer = Buffer.from(comment, 'utf8'); + if (commentBuffer.length > SENTINEL16) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + 'the archive comment must not exceed 65535 bytes when encoded as UTF-8'); + } + } + const centralHeaders = []; + let pos = 0; + 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 zip64 = + count >= SENTINEL16 || + centralDirectoryOffset >= SENTINEL32 || + centralDirectorySize >= SENTINEL32; + if (zip64) { + const recordOffset = pos; + yield buildZip64EndRecord(count, centralDirectorySize, centralDirectoryOffset); + yield buildZip64EndLocator(recordOffset); + } + yield buildEndOfCentralDirectory( + count, centralDirectorySize, centralDirectoryOffset, commentBuffer); +} + +/** + * 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; + + /** + * @param {Buffer | TypedArray | DataView | ArrayBuffer} buffer + */ + constructor(buffer) { + const buf = toBuffer(buffer, 'buffer'); + this.#comment = findArchiveEnd(buf).comment; + for (const entry of ZipEntry.read(buf)) { + MapPrototypeSet(this.#entries, entry.name, entry); + } + } + get writable() { return true; } + get comment() { return this.#comment.toString('utf8'); } + 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; + } + /** + * @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] + * @returns {Promise} + */ + async toBuffer(comment) { + const chunks = []; + for await (const chunk of createZipArchive(this.values(), comment ?? this.comment)) { + 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] + * @returns {Buffer} + */ + toBufferSync(comment) { + const chunks = []; + for (const chunk of createZipArchiveSync(this.values(), comment ?? this.comment)) { + ArrayPrototypePush(chunks, chunk); + } + return Buffer.concat(chunks); + } + [SymbolDispose]() { + MapPrototypeClear(this.#entries); + } +} + +const READ_CHUNK_SIZE = 4 * 1024 * 1024; +// EOCD + max comment + Zip64 locator + Zip64 record + slack for an +// extensible data sector. +const TAIL_LENGTH = 22 + SENTINEL16 + 20 + 56 + 4096; + +// `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 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())); + }); +} + +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; + } +} + +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; + } +} + +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; +} + +/** + * Builds a fresh central directory (plus Zip64 structures and EOCD, as + * needed) 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++) { + const header = records[i].entry[kFinalize](records[i].localOffset); + ArrayPrototypePush(centralHeaders, header); + centralDirectorySize += header.length; + } + const count = records.length; + const zip64 = + count >= SENTINEL16 || + centralDirectoryOffset >= SENTINEL32 || + centralDirectorySize >= SENTINEL32; + const chunks = []; + for (let i = 0; i < centralHeaders.length; i++) ArrayPrototypePush(chunks, centralHeaders[i]); + if (zip64) { + const recordOffset = centralDirectoryOffset + centralDirectorySize; + ArrayPrototypePush(chunks, buildZip64EndRecord(count, centralDirectorySize, centralDirectoryOffset)); + ArrayPrototypePush(chunks, buildZip64EndLocator(recordOffset)); + } + ArrayPrototypePush( + chunks, buildEndOfCentralDirectory(count, centralDirectorySize, centralDirectoryOffset, comment)); + return { centralHeaders, chunks }; +} + +/** + * 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; + + /** + * @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; } + get comment() { return this.#comment.toString('utf8'); } + #assertWritable() { + if (!this.#writable) throw new ERR_ZIP_NOT_WRITABLE(); + } + #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'); + } + } + #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); + } + async #localHeaderLength(name, headerOffset) { + const fixed = Buffer.allocUnsafe(30); + await readFdFully(this.#fd, fixed, headerOffset); + if (fixed.readUInt32LE(0) !== SIG_LOCAL_FILE_HEADER) { + throw new ERR_ZIP_INVALID_ARCHIVE( + `entry ${JSONStringify(name)} has an invalid local file header`); + } + return LocalFileHeader.length(fixed, 0); + } + #localHeaderLengthSync(name, headerOffset) { + const fixed = Buffer.allocUnsafe(30); + readFdFullySync(this.#fd, fixed, headerOffset); + if (fixed.readUInt32LE(0) !== SIG_LOCAL_FILE_HEADER) { + throw new ERR_ZIP_INVALID_ARCHIVE( + `entry ${JSONStringify(name)} has an invalid local file header`); + } + return LocalFileHeader.length(fixed, 0); + } + /** + * @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); + if (info.entry === undefined) { + const headerOffset = info.localOffset; + const headerLength = await this.#localHeaderLength(name, headerOffset); + const contentLength = info.central.compressedSize; + if (headerLength + contentLength > kMaxLength) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + `entry ${JSONStringify(name)} is too large to buffer ` + + `(${contentLength} compressed bytes); use stream() instead`); + } + const data = Buffer.allocUnsafe(headerLength + contentLength); + await readFdFully(this.#fd, data, headerOffset); + const local = new LocalFileHeader(data, 0); + const content = data.subarray(headerLength, headerLength + contentLength); + info.entry = new ZipEntry(info.central, local, content); + } + return info.entry; + } + /** + * The synchronous counterpart of `get()`. Blocks the event loop and + * further JavaScript execution until the member has been read; see the + * class-level note on synchronous methods. + * @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); + if (info.entry === undefined) { + const headerOffset = info.localOffset; + const headerLength = this.#localHeaderLengthSync(name, headerOffset); + const contentLength = info.central.compressedSize; + if (headerLength + contentLength > kMaxLength) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + `entry ${JSONStringify(name)} is too large to buffer ` + + `(${contentLength} compressed bytes); use streamSync() instead`); + } + const data = Buffer.allocUnsafe(headerLength + contentLength); + readFdFullySync(this.#fd, data, headerOffset); + const local = new LocalFileHeader(data, 0); + const content = data.subarray(headerLength, headerLength + contentLength); + info.entry = new ZipEntry(info.central, local, content); + } + return info.entry; + } + /** + * Streams a member's decoded content without buffering the whole member. + * Verifies CRC-32 by default (`{ verify: false }` to opt out). + * @param {string} name + * @param {{ verify?: boolean, maxSize?: number }} [options] + * @yields {Buffer} + */ + async *stream(name, options) { + validateString(name, 'name'); + const info = MapPrototypeGet(this.#entries, name); + if (info === undefined) throw new ERR_ZIP_ENTRY_NOT_FOUND(name); + const central = info.central; + const headerOffset = info.localOffset; + const headerLength = await this.#localHeaderLength(name, headerOffset); + const start = headerOffset + headerLength; + const compressedSize = central.compressedSize; + const fd = this.#fd; + const source = (async function* () { + let pos = start; + let remaining = 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; + } + })(); + yield* decodeMemberStream(source, { + name, + flags: central.flags, + method: central.compressionMethod, + crc32: central.crc32, + uncompressedSize: central.uncompressedSize, + }, options); + } + /** + * The synchronous counterpart of `stream()`. There is no public + * synchronous incremental inflate API, so - unlike `stream()`, which is + * bounded-memory - this reads the member's complete compressed bytes and + * materializes its entire decoded content before yielding it as a single + * chunk. Blocks the event loop until done; see the class-level note on + * synchronous methods. + * @param {string} name + * @param {{ verify?: boolean, maxSize?: number }} [options] + * @yields {Buffer} + */ + *streamSync(name, options) { + this.#assertNotBusy(); + validateString(name, 'name'); + const info = MapPrototypeGet(this.#entries, name); + if (info === undefined) throw new ERR_ZIP_ENTRY_NOT_FOUND(name); + const central = info.central; + const headerOffset = info.localOffset; + const headerLength = this.#localHeaderLengthSync(name, headerOffset); + const start = headerOffset + headerLength; + const compressed = Buffer.allocUnsafe(central.compressedSize); + readFdFullySync(this.#fd, compressed, start); + yield decodeMemberSync(compressed, { + name, + flags: central.flags, + method: central.compressionMethod, + crc32: central.crc32, + uncompressedSize: central.uncompressedSize, + }, options); + } + /** + * 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)); + } + /** + * @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)); + } + async #doAdd(entry) { + const localOffset = this.#centralDirectoryOffset; + let written = 0; + for await (const chunk of entry) { + await fsWriteAsync(this.#fd, chunk, 0, chunk.length, localOffset + written); + written += chunk.length; + } + this.#centralDirectoryOffset = localOffset + written; + MapPrototypeSet(this.#entries, entry.name, { central: null, entry, localOffset }); + await this.#rewriteCentralDirectory(); + 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; + for (const chunk of entry) { + fs.writeSync(this.#fd, chunk, 0, chunk.length, localOffset + written); + written += chunk.length; + } + this.#centralDirectoryOffset = localOffset + written; + MapPrototypeSet(this.#entries, entry.name, { central: null, entry, localOffset }); + this.#rewriteCentralDirectorySync(); + 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)); + } + 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; + } + #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 }; + } + 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 fsWriteAsync(this.#fd, chunks[i], 0, chunks[i].length, pos); + pos += chunks[i].length; + } + await fsFtruncateAsync(this.#fd, pos); + this.#adoptRewrittenCentralDirectory(names, centralHeaders, records); + } + #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++) { + fs.writeSync(this.#fd, chunks[i], 0, chunks[i].length, 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) { + const self = this; + async function* liveEntries() { + for (const name of self.keys()) { + yield await self.get(name); + } + } + return Readable.from(createZipArchive(liveEntries(), 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 self = this; + function* liveEntries() { + for (const name of self.keys()) yield self.getSync(name); + } + const chunks = []; + for (const chunk of createZipArchiveSync(liveEntries(), 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 *[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); + } + } + 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(); + } + /** + * @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); + const end = findArchiveEnd(tail, size - tailLength); + if (end.centralDirectorySize > kMaxLength) { + throw new ERR_ZIP_ENTRY_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'); + } + const directory = Buffer.allocUnsafe(end.centralDirectorySize); + await readFdFully(fd, directory, end.centralDirectoryOffset); + const headers = readCentralDirectory(directory, end.totalRecords); + 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); + const end = findArchiveEnd(tail, size - tailLength); + if (end.centralDirectorySize > kMaxLength) { + throw new ERR_ZIP_ENTRY_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'); + } + const directory = Buffer.allocUnsafe(end.centralDirectorySize); + readFdFullySync(fd, directory, end.centralDirectoryOffset); + const headers = readCentralDirectory(directory, end.totalRecords); + 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 = { + ZipEntry, + ZipFile, + ZipBuffer, + createZipArchive, + createZipArchiveSync, + getMaxZipContentSize, + setMaxZipContentSize, +}; diff --git a/lib/vfs.js b/lib/vfs.js index 0d12229aca72cd..5e77affb6b4252 100644 --- a/lib/vfs.js +++ b/lib/vfs.js @@ -8,6 +8,7 @@ const { VirtualFileSystem } = require('internal/vfs/file_system'); const { VirtualProvider } = require('internal/vfs/provider'); const { MemoryProvider } = require('internal/vfs/providers/memory'); const { RealFSProvider } = require('internal/vfs/providers/real'); +const { ArchiveProvider } = require('internal/vfs/providers/archive'); /** * Creates a new VirtualFileSystem instance. @@ -34,4 +35,5 @@ module.exports = { VirtualProvider, MemoryProvider, RealFSProvider, + ArchiveProvider, }; diff --git a/lib/zlib.js b/lib/zlib.js index fc970c306dc437..42a44335a785a3 100644 --- a/lib/zlib.js +++ b/lib/zlib.js @@ -71,6 +71,15 @@ const { validateFiniteNumber, } = require('internal/validators'); const { FastBuffer } = require('internal/buffer'); +const { + ZipEntry, + ZipFile, + ZipBuffer, + createZipArchive, + createZipArchiveSync, + getMaxZipContentSize, + setMaxZipContentSize, +} = require('internal/zip'); const kFlushFlag = Symbol('kFlushFlag'); const kError = Symbol('kError'); @@ -1016,6 +1025,15 @@ module.exports = { ZstdCompress, ZstdDecompress, + // ZIP archive support. + ZipEntry, + ZipFile, + ZipBuffer, + createZipArchive, + createZipArchiveSync, + getMaxZipContentSize, + setMaxZipContentSize, + // Convenience methods. // compress/decompress a string or buffer in one step. deflate: createConvenienceMethod(Deflate, false), diff --git a/test/parallel/test-vfs-archive-provider-handle.js b/test/parallel/test-vfs-archive-provider-handle.js new file mode 100644 index 00000000000000..e1e6b7ef79caaf --- /dev/null +++ b/test/parallel/test-vfs-archive-provider-handle.js @@ -0,0 +1,443 @@ +// Flags: --experimental-vfs +'use strict'; + +// Additional ArchiveProvider coverage beyond test-vfs-archive-provider.js: +// direct ArchiveFileHandle read/write/stat/truncate (both explicit and +// current position, append-mode positioning, buffer growth), readFile/ +// writeFile encoding and data-type variants, compression-method-preserving +// rename() (methodOption()), ZipFile-backed synchronous delete, an explicit +// empty-directory open() EISDIR, the readdir child-directory dedup path, and +// a few error branches (EROFS on open(), ENOTDIR on rmdir(), ENOENT on +// rename()) not exercised by the base test. + +const common = require('../common'); +const tmpdir = require('../common/tmpdir'); +const assert = require('assert'); +const path = require('path'); +const fsPromises = require('fs/promises'); +const zlib = require('zlib'); +const vfs = require('node:vfs'); + +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); +} + +function buildArchiveSync(entries, comment) { + return Buffer.concat([...zlib.createZipArchiveSync(entries, comment)]); +} + +// --- direct ArchiveFileHandle read/write/stat/truncate (async) ------------- +(async () => { + const archive = await buildArchive([ + await zlib.ZipEntry.create('a.txt', Buffer.from('hello world')), + ]); + const zip = new zlib.ZipBuffer(archive); + const provider = new vfs.ArchiveProvider(zip); + + const handle = await provider.open('/a.txt', 'r+'); + + // Explicit position leaves the handle's own position untouched. + const buf = Buffer.alloc(5); + const { bytesRead } = await handle.read(buf, 0, 5, 0); + assert.strictEqual(bytesRead, 5); + assert.strictEqual(buf.toString(), 'hello'); + assert.strictEqual(handle.position, 0); + + // Current position (undefined/null/-1) advances the handle's position. + const buf2 = Buffer.alloc(5); + await handle.read(buf2, 0, 5, null); + assert.strictEqual(buf2.toString(), 'hello'); + assert.strictEqual(handle.position, 5); + const buf3 = Buffer.alloc(1); + await handle.read(buf3, 0, 1, -1); + assert.strictEqual(buf3.toString(), ' '); + assert.strictEqual(handle.position, 6); + + // Reading past EOF yields 0 bytes without advancing further than available. + const tail = Buffer.alloc(100); + const { bytesRead: tailRead } = await handle.read(tail, 0, 100, 6); + assert.strictEqual(tailRead, 5); // 'world'.length + + // write() at an explicit position (overwrite), then at the current + // position (append past the buffer's current size, forcing growth). + await handle.write(Buffer.from('HELLO'), 0, 5, 0); + const stat1 = await handle.stat(); + assert.strictEqual(stat1.size, 11); + + handle.position = 11; + await handle.write(Buffer.from('!!!'), 0, 3, null); + const stat2 = await handle.stat(); + assert.strictEqual(stat2.size, 14); + + await handle.truncate(5); + const stat3 = await handle.stat(); + assert.strictEqual(stat3.size, 5); + + await handle.truncate(8); + const stat4 = await handle.stat(); + assert.strictEqual(stat4.size, 8); + + await handle.close(); + + const archiveVfs = vfs.create(provider); + const final = await archiveVfs.promises.readFile('/a.txt'); + assert.strictEqual(final.length, 8); + assert.strictEqual(final.subarray(0, 5).toString(), 'HELLO'); +})().then(common.mustCall()); + +// --- direct ArchiveFileHandle read/write/stat/truncate (sync) -------------- +(() => { + const archive = buildArchiveSync([zlib.ZipEntry.createSync('b.txt', Buffer.from('0123456789'))]); + const zip = new zlib.ZipBuffer(archive); + const provider = new vfs.ArchiveProvider(zip); + const handle = provider.openSync('/b.txt', 'r+'); + + const buf = Buffer.alloc(4); + const { bytesRead } = handle.readSync(buf, 0, 4, 2); + assert.strictEqual(bytesRead, 4); + assert.strictEqual(buf.toString(), '2345'); + + handle.writeSync(Buffer.from('AB'), 0, 2, 0); + assert.strictEqual(handle.statSync().size, 10); + + // A write growing well beyond current capacity exercises #ensureCapacity's + // doubling path. + handle.writeSync(Buffer.alloc(1000, 0x58 /* 'X' */), 0, 1000, 10); + assert.strictEqual(handle.statSync().size, 1010); + + handle.truncateSync(3); + assert.strictEqual(handle.statSync().size, 3); + assert.strictEqual(handle.readFileSync('utf8'), 'AB2'); + + handle.closeSync(); +})(); + +// --- append-mode positioning: writes always land at the current end ------- +(async () => { + const archive = await buildArchive([await zlib.ZipEntry.create('c.txt', Buffer.from('xy'))]); + const zip = new zlib.ZipBuffer(archive); + const provider = new vfs.ArchiveProvider(zip); + + const handle = await provider.open('/c.txt', 'a'); + assert.strictEqual(handle.position, 2); // Positioned at EOF on open + + // Even with an explicit (wrong) position, append mode writes at the end. + await handle.write(Buffer.from('z'), 0, 1, 0); + assert.strictEqual((await handle.stat()).size, 3); + await handle.close(); + + const archiveVfs = vfs.create(provider); + assert.strictEqual(await archiveVfs.promises.readFile('/c.txt', 'utf8'), 'xyz'); +})().then(common.mustCall()); + +// --- readFile/readFileSync encoding variants + writeFile data types -------- +(async () => { + const archive = await buildArchive([await zlib.ZipEntry.create('d.txt', Buffer.from('café'))]); + const zip = new zlib.ZipBuffer(archive); + const provider = new vfs.ArchiveProvider(zip); + + const handle = await provider.open('/d.txt', 'r'); + const asBuffer = await handle.readFile(); + assert.ok(Buffer.isBuffer(asBuffer)); + const asStringShorthand = await handle.readFile('utf8'); + assert.strictEqual(asStringShorthand, 'café'); + const asStringOption = await handle.readFile({ encoding: 'utf8' }); + assert.strictEqual(asStringOption, 'café'); + const asExplicitBuffer = await handle.readFile({ encoding: 'buffer' }); + assert.ok(Buffer.isBuffer(asExplicitBuffer)); + await handle.close(); + + const writeHandle = await provider.open('/e.txt', 'w'); + await writeHandle.writeFile(Buffer.from('buffer-data')); + await writeHandle.close(); + assert.strictEqual(await (await provider.open('/e.txt', 'r')).readFile('utf8'), 'buffer-data'); + + const writeHandle2 = await provider.open('/f.txt', 'w'); + await writeHandle2.writeFile('string-data', { encoding: 'utf8' }); + await writeHandle2.close(); + assert.strictEqual(await (await provider.open('/f.txt', 'r')).readFile('utf8'), 'string-data'); +})().then(common.mustCall()); + +(() => { + const archive = buildArchiveSync([zlib.ZipEntry.createSync('g.txt', Buffer.from('sync-café'))]); + const zip = new zlib.ZipBuffer(archive); + const provider = new vfs.ArchiveProvider(zip); + + const handle = provider.openSync('/g.txt', 'r'); + assert.ok(Buffer.isBuffer(handle.readFileSync())); + assert.strictEqual(handle.readFileSync('utf8'), 'sync-café'); + assert.strictEqual(handle.readFileSync({ encoding: 'utf8' }), 'sync-café'); + assert.ok(Buffer.isBuffer(handle.readFileSync({ encoding: 'buffer' }))); + handle.closeSync(); + + const writeHandle = provider.openSync('/h.txt', 'w'); + writeHandle.writeFileSync(Buffer.from('sync-buffer-data')); + writeHandle.closeSync(); + assert.strictEqual(provider.openSync('/h.txt', 'r').readFileSync('utf8'), 'sync-buffer-data'); +})(); + +// --- rename() preserves the original compression method (methodOption) ---- +(async () => { + const archive = await buildArchive([ + await zlib.ZipEntry.create('store.bin', Buffer.from('a'.repeat(100)), { method: 'store' }), + await zlib.ZipEntry.create('deflate.bin', Buffer.from('b'.repeat(100)), { method: 'deflate' }), + await zlib.ZipEntry.create('zstd.bin', Buffer.from('c'.repeat(100)), { method: 'zstd' }), + ]); + const zip = new zlib.ZipBuffer(archive); + const provider = new vfs.ArchiveProvider(zip); + const archiveVfs = vfs.create(provider); + + const methodsBefore = new Map([ + ['store.bin', zip.get('store.bin').method], + ['deflate.bin', zip.get('deflate.bin').method], + ['zstd.bin', zip.get('zstd.bin').method], + ]); + + await archiveVfs.promises.rename('/store.bin', '/store-renamed.bin'); + await archiveVfs.promises.rename('/deflate.bin', '/deflate-renamed.bin'); + await archiveVfs.promises.rename('/zstd.bin', '/zstd-renamed.bin'); + + assert.strictEqual(zip.get('store-renamed.bin').method, methodsBefore.get('store.bin')); + assert.strictEqual(zip.get('deflate-renamed.bin').method, methodsBefore.get('deflate.bin')); + assert.strictEqual(zip.get('zstd-renamed.bin').method, methodsBefore.get('zstd.bin')); + + // Content must still round-trip correctly under the reproduced method. + assert.strictEqual((await zip.get('store-renamed.bin').content()).toString(), 'a'.repeat(100)); + assert.strictEqual((await zip.get('deflate-renamed.bin').content()).toString(), 'b'.repeat(100)); + assert.strictEqual((await zip.get('zstd-renamed.bin').content()).toString(), 'c'.repeat(100)); +})().then(common.mustCall()); + +(() => { + const archive = buildArchiveSync([ + zlib.ZipEntry.createSync('store.bin', Buffer.from('a'.repeat(100)), { method: 'store' }), + zlib.ZipEntry.createSync('zstd.bin', Buffer.from('c'.repeat(100)), { method: 'zstd' }), + ]); + const zip = new zlib.ZipBuffer(archive); + const provider = new vfs.ArchiveProvider(zip); + const archiveVfs = vfs.create(provider); + + const storeMethod = zip.get('store.bin').method; + const zstdMethod = zip.get('zstd.bin').method; + archiveVfs.renameSync('/store.bin', '/store-renamed.bin'); + archiveVfs.renameSync('/zstd.bin', '/zstd-renamed.bin'); + assert.strictEqual(zip.get('store-renamed.bin').method, storeMethod); + assert.strictEqual(zip.get('zstd-renamed.bin').method, zstdMethod); +})(); + +// --- rename() with a missing source is rejected with ENOENT --------------- +(async () => { + const archive = await buildArchive([await zlib.ZipEntry.create('only.txt', Buffer.from('x'))]); + const zip = new zlib.ZipBuffer(archive); + const provider = new vfs.ArchiveProvider(zip); + const archiveVfs = vfs.create(provider); + + await assert.rejects( + archiveVfs.promises.rename('/missing.txt', '/renamed.txt'), + { code: 'ENOENT' }, + ); + assert.throws( + () => archiveVfs.renameSync('/missing.txt', '/renamed.txt'), + { code: 'ENOENT' }, + ); +})().then(common.mustCall()); + +// --- rmdir() on a plain file is rejected with ENOTDIR ---------------------- +// (called on the provider directly: the vfs router validates directory-ness +// itself before delegating for some operations, which would otherwise never +// exercise ArchiveProvider's own check). +(async () => { + const archive = await buildArchive([await zlib.ZipEntry.create('file.txt', Buffer.from('x'))]); + const zip = new zlib.ZipBuffer(archive); + const provider = new vfs.ArchiveProvider(zip); + + await assert.rejects(provider.rmdir('/file.txt'), { code: 'ENOTDIR' }); + assert.throws(() => provider.rmdirSync('/file.txt'), { code: 'ENOTDIR' }); +})().then(common.mustCall()); + +// --- open(): EEXIST/ENOENT/EISDIR-on-wrong-direction, called directly on +// the provider so the router can't short-circuit before delegating --------- +(async () => { + const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]); + const zip = new zlib.ZipBuffer(archive); + const provider = new vfs.ArchiveProvider(zip); + + await assert.rejects(provider.open('/a.txt', 'wx'), { code: 'EEXIST' }); + assert.throws(() => provider.openSync('/a.txt', 'wx'), { code: 'EEXIST' }); + await assert.rejects(provider.open('/missing.txt', 'r'), { code: 'ENOENT' }); + assert.throws(() => provider.openSync('/missing.txt', 'r'), { code: 'ENOENT' }); + + // A handle opened write-only can't be read from, and vice versa. + const writeOnly = await provider.open('/w.txt', 'w'); + await assert.rejects(writeOnly.read(Buffer.alloc(1), 0, 1, 0), { code: 'EISDIR' }); + await writeOnly.close(); + const readOnly = await provider.open('/a.txt', 'r'); + await assert.rejects(readOnly.write(Buffer.alloc(1), 0, 1, 0), { code: 'EISDIR' }); + await readOnly.close(); +})().then(common.mustCall()); + +// --- normalize(): a path without a leading slash is used as-is ------------ +(async () => { + const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]); + const zip = new zlib.ZipBuffer(archive); + const provider = new vfs.ArchiveProvider(zip); + + const stats = await provider.stat('a.txt'); + assert.strictEqual(stats.isFile(), true); +})().then(common.mustCall()); + +// --- mkdir(): both the with-options and no-options shapes, called +// directly on the provider --------------------------------------------- +(async () => { + const archive = await buildArchive([]); + const zip = new zlib.ZipBuffer(archive); + const provider = new vfs.ArchiveProvider(zip); + + await provider.mkdir('/no-opts'); + assert.strictEqual((await provider.stat('/no-opts')).isDirectory(), true); + await provider.mkdir('/with-opts', { mode: 0o700 }); + assert.strictEqual((await provider.stat('/with-opts')).isDirectory(), true); + + provider.mkdirSync('/no-opts-sync'); + assert.strictEqual(provider.statSync('/no-opts-sync').isDirectory(), true); + provider.mkdirSync('/with-opts-sync', { mode: 0o700 }); + assert.strictEqual(provider.statSync('/with-opts-sync').isDirectory(), true); +})().then(common.mustCall()); + +// --- readdir() skips a directory's own explicit entry when listing it ----- +(async () => { + const archive = await buildArchive([ + await zlib.ZipEntry.create('dir/', Buffer.alloc(0)), + await zlib.ZipEntry.create('dir/child.txt', Buffer.from('x')), + ]); + const zip = new zlib.ZipBuffer(archive); + const provider = new vfs.ArchiveProvider(zip); + const archiveVfs = vfs.create(provider); + + const entries = await archiveVfs.promises.readdir('/dir'); + assert.deepStrictEqual(entries, ['child.txt']); +})().then(common.mustCall()); + +// --- an entry not made by a Unix zip tool reports mode 0, so stat() and +// rename() fall back to their documented defaults -------------------------- +(async () => { + const archive = await buildArchive([ + await zlib.ZipEntry.create('foreign.txt', Buffer.from('x')), + await zlib.ZipEntry.create('foreign-dir/', Buffer.alloc(0)), + ]); + const tampered = Buffer.from(archive); + // The central header's "version made by" high byte selects the platform; + // anything other than 3 (Unix) makes `mode` report 0 (see zip.js's + // `CentralFileHeader.prototype.mode`). + // The central directory follows *all* entries' local sections, and each + // entry's central header follows the previous entries' central headers. + const localSectionsLength = (30 + 'foreign.txt'.length + 'x'.length) + + (30 + 'foreign-dir/'.length + 0); + const fileHeaderStart = localSectionsLength; + const dirHeaderStart = fileHeaderStart + (46 + 'foreign.txt'.length); + const fileMadeByOffset = fileHeaderStart + 5; + const dirMadeByOffset = dirHeaderStart + 5; + assert.strictEqual(tampered[fileMadeByOffset], 3); // sanity: was Unix-made + assert.strictEqual(tampered[dirMadeByOffset], 3); + tampered[fileMadeByOffset] = 0; // MS-DOS/FAT + tampered[dirMadeByOffset] = 0; + + const zip = new zlib.ZipBuffer(tampered); + assert.strictEqual(zip.get('foreign.txt').mode, 0); + assert.strictEqual(zip.get('foreign-dir/').mode, 0); + const provider = new vfs.ArchiveProvider(zip); + const archiveVfs = vfs.create(provider); + + // stat()'s `entry.mode || ` fallback, for both a file and an + // (explicit) directory entry. + const fileStats = await archiveVfs.promises.stat('/foreign.txt'); + assert.strictEqual(fileStats.mode & 0o777, 0o644); + const dirStats = await archiveVfs.promises.stat('/foreign-dir'); + assert.strictEqual(dirStats.mode & 0o777, 0o755); + assert.strictEqual(archiveVfs.statSync('/foreign.txt').mode & 0o777, 0o644); + assert.strictEqual(archiveVfs.statSync('/foreign-dir').mode & 0o777, 0o755); + + // rename()'s `mode: entry.mode || undefined` fallback (undefined lets + // ZipEntry.create() pick its own default mode instead of reproducing 0). + await archiveVfs.promises.rename('/foreign.txt', '/renamed.txt'); + assert.strictEqual((await archiveVfs.promises.stat('/renamed.txt')).mode & 0o777, 0o644); +})().then(common.mustCall()); + +(() => { + const archive = buildArchiveSync([zlib.ZipEntry.createSync('foreign.txt', Buffer.from('x'))]); + const tampered = Buffer.from(archive); + const centralHeaderStart = 30 + 'foreign.txt'.length + 'x'.length; + const madeByOffset = centralHeaderStart + 5; + tampered[madeByOffset] = 0; + + const zip = new zlib.ZipBuffer(tampered); + const provider = new vfs.ArchiveProvider(zip); + const archiveVfs = vfs.create(provider); + + archiveVfs.renameSync('/foreign.txt', '/renamed.txt'); + assert.strictEqual(archiveVfs.statSync('/renamed.txt').mode & 0o777, 0o644); +})(); + +// --- opening an explicit (empty) directory entry is rejected with EISDIR -- +(async () => { + const archive = await buildArchive([await zlib.ZipEntry.create('empty-dir/', Buffer.alloc(0))]); + const zip = new zlib.ZipBuffer(archive); + const provider = new vfs.ArchiveProvider(zip); + + await assert.rejects(provider.open('/empty-dir', 'r'), { code: 'EISDIR' }); + assert.throws(() => provider.openSync('/empty-dir', 'r'), { code: 'EISDIR' }); +})().then(common.mustCall()); + +// --- readdir dedups a child directory reached through multiple entries ---- +(async () => { + const archive = await buildArchive([ + await zlib.ZipEntry.create('dir/one.txt', Buffer.from('1')), + await zlib.ZipEntry.create('dir/two.txt', Buffer.from('2')), + ]); + const zip = new zlib.ZipBuffer(archive); + const provider = new vfs.ArchiveProvider(zip); + const archiveVfs = vfs.create(provider); + + // Both entries imply the same 'dir' child at the root; it must appear once. + const rootEntries = await archiveVfs.promises.readdir('/'); + assert.deepStrictEqual(rootEntries, ['dir']); + const dirEntries = await archiveVfs.promises.readdir('/dir'); + assert.deepStrictEqual(dirEntries.sort(), ['one.txt', 'two.txt']); +})().then(common.mustCall()); + +// --- open() with a write flag against a readonly (ZipFile) archive: EROFS -- +(async () => { + const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]); + const filePath = path.join(tmpdir.path, 'vfs-archive-handle-readonly.zip'); + await fsPromises.writeFile(filePath, archive); + const zip = await zlib.ZipFile.open(filePath); + const provider = new vfs.ArchiveProvider(zip); + + await assert.rejects(provider.open('/new.txt', 'w'), { code: 'EROFS' }); + assert.throws(() => provider.openSync('/new.txt', 'w'), { code: 'EROFS' }); + + await zip.close(); +})().then(common.mustCall()); + +// --- ZipFile-backed writable archive: sync unlink/rmdir (deleteSync) ------ +(async () => { + const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]); + const filePath = path.join(tmpdir.path, 'vfs-archive-handle-writable-sync.zip'); + await fsPromises.writeFile(filePath, archive); + const zip = await zlib.ZipFile.open(filePath, { writable: true }); + const provider = new vfs.ArchiveProvider(zip); + const archiveVfs = vfs.create(provider); + + archiveVfs.mkdirSync('/somedir'); + assert.strictEqual(archiveVfs.statSync('/somedir').isDirectory(), true); + archiveVfs.rmdirSync('/somedir'); + assert.throws(() => archiveVfs.statSync('/somedir'), { code: 'ENOENT' }); + + archiveVfs.unlinkSync('/a.txt'); + assert.throws(() => archiveVfs.statSync('/a.txt'), { code: 'ENOENT' }); + + await zip.close(); +})().then(common.mustCall()); diff --git a/test/parallel/test-vfs-archive-provider.js b/test/parallel/test-vfs-archive-provider.js new file mode 100644 index 00000000000000..82e16796e77ccf --- /dev/null +++ b/test/parallel/test-vfs-archive-provider.js @@ -0,0 +1,243 @@ +// Flags: --experimental-vfs +'use strict'; + +// Exercises ArchiveProvider (node:vfs backed by node:zlib's ZipBuffer/ +// ZipFile): construction validation, readonly reflecting the archive's own +// writability, stat/readdir over explicit and implicit directories, and the +// full async and synchronous CRUD surface, against both a ZipBuffer and a +// ZipFile (opened both via open() and openSync()) source. + +const common = require('../common'); +const tmpdir = require('../common/tmpdir'); +const assert = require('assert'); +const path = require('path'); +const fsPromises = require('fs/promises'); +const zlib = require('zlib'); +const vfs = require('node:vfs'); + +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 () => { + // Construction validation. + assert.throws(() => new vfs.ArchiveProvider({}), { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => new vfs.ArchiveProvider(null), { code: 'ERR_INVALID_ARG_TYPE' }); + + // --- ZipBuffer-backed: always writable ------------------------------------ + { + const archive = await buildArchive([ + await zlib.ZipEntry.create('a.txt', Buffer.from('hello')), + await zlib.ZipEntry.create('dir/b.txt', Buffer.from('nested')), + await zlib.ZipEntry.create('empty-dir/', Buffer.alloc(0)), + ]); + const zip = new zlib.ZipBuffer(archive); + const provider = new vfs.ArchiveProvider(zip); + assert.strictEqual(provider.readonly, false); + assert.strictEqual(provider.supportsSymlinks, false); + assert.strictEqual(provider.supportsWatch, false); + + const archiveVfs = vfs.create(provider); + + // stat: file, implicit directory, explicit directory, root. + const fileStat = await archiveVfs.promises.stat('/a.txt'); + assert.strictEqual(fileStat.isFile(), true); + assert.strictEqual(fileStat.size, 5); + + const implicitDirStat = await archiveVfs.promises.stat('/dir'); + assert.strictEqual(implicitDirStat.isDirectory(), true); + + const explicitDirStat = await archiveVfs.promises.stat('/empty-dir'); + assert.strictEqual(explicitDirStat.isDirectory(), true); + + const rootStat = await archiveVfs.promises.stat('/'); + assert.strictEqual(rootStat.isDirectory(), true); + + await assert.rejects(archiveVfs.promises.stat('/missing.txt'), { code: 'ENOENT' }); + + // readdir: root lists both files and directories, deduped. + const rootEntries = await archiveVfs.promises.readdir('/'); + assert.deepStrictEqual(rootEntries.sort(), ['a.txt', 'dir', 'empty-dir']); + + const dirEntries = await archiveVfs.promises.readdir('/dir'); + assert.deepStrictEqual(dirEntries, ['b.txt']); + + const withTypes = await archiveVfs.promises.readdir('/', { withFileTypes: true }); + const byName = new Map(withTypes.map((d) => [d.name, d])); + assert.strictEqual(byName.get('a.txt').isFile(), true); + assert.strictEqual(byName.get('dir').isDirectory(), true); + + await assert.rejects(archiveVfs.promises.readdir('/a.txt'), { code: 'ENOTDIR' }); + await assert.rejects( + archiveVfs.promises.readdir('/', { recursive: true }), + { code: 'ERR_METHOD_NOT_IMPLEMENTED' }, + ); + + // readFile / writeFile round trip (new file). + assert.strictEqual(await archiveVfs.promises.readFile('/a.txt', 'utf8'), 'hello'); + await archiveVfs.promises.writeFile('/new.txt', 'brand new'); + assert.strictEqual(await archiveVfs.promises.readFile('/new.txt', 'utf8'), 'brand new'); + assert.strictEqual(zip.has('new.txt'), true); + + // Overwriting an existing file. + await archiveVfs.promises.writeFile('/a.txt', 'overwritten'); + assert.strictEqual(await archiveVfs.promises.readFile('/a.txt', 'utf8'), 'overwritten'); + + // appendFile. + await archiveVfs.promises.writeFile('/append.txt', 'ab'); + await archiveVfs.promises.appendFile('/append.txt', 'cd'); + assert.strictEqual(await archiveVfs.promises.readFile('/append.txt', 'utf8'), 'abcd'); + + // mkdir + rmdir. + await archiveVfs.promises.mkdir('/newdir'); + assert.strictEqual((await archiveVfs.promises.stat('/newdir')).isDirectory(), true); + await assert.rejects(archiveVfs.promises.mkdir('/newdir'), { code: 'EEXIST' }); + await archiveVfs.promises.mkdir('/newdir', { recursive: true }); // No throw, already exists + await archiveVfs.promises.rmdir('/newdir'); + await assert.rejects(archiveVfs.promises.stat('/newdir'), { code: 'ENOENT' }); + + // Rmdir refuses a non-empty directory. + await assert.rejects(archiveVfs.promises.rmdir('/dir'), { code: 'ENOTEMPTY' }); + + // unlink. + await archiveVfs.promises.unlink('/append.txt'); + await assert.rejects(archiveVfs.promises.stat('/append.txt'), { code: 'ENOENT' }); + await assert.rejects(archiveVfs.promises.unlink('/missing.txt'), { code: 'ENOENT' }); + await assert.rejects(archiveVfs.promises.unlink('/dir'), { code: 'EISDIR' }); + + // rename. + await archiveVfs.promises.writeFile('/rename-me.txt', 'content'); + await archiveVfs.promises.rename('/rename-me.txt', '/renamed.txt'); + assert.strictEqual(zip.has('rename-me.txt'), false); + assert.strictEqual(await archiveVfs.promises.readFile('/renamed.txt', 'utf8'), 'content'); + + // open() flag semantics. + await assert.rejects(archiveVfs.promises.open('/does-not-exist.txt', 'r'), { code: 'ENOENT' }); + await assert.rejects(archiveVfs.promises.open('/a.txt', 'wx'), { code: 'EEXIST' }); + await assert.rejects(archiveVfs.promises.open('/dir', 'r'), { code: 'EISDIR' }); + } + + // --- ZipFile-backed, read-only: writes rejected with EROFS ---------------- + { + const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]); + const filePath = path.join(tmpdir.path, 'vfs-archive-readonly.zip'); + await fsPromises.writeFile(filePath, archive); + const zip = await zlib.ZipFile.open(filePath); + const provider = new vfs.ArchiveProvider(zip); + assert.strictEqual(provider.readonly, true); + const archiveVfs = vfs.create(provider); + + assert.strictEqual(await archiveVfs.promises.readFile('/a.txt', 'utf8'), 'x'); + await assert.rejects(archiveVfs.promises.writeFile('/new.txt', 'y'), { code: 'EROFS' }); + await assert.rejects(archiveVfs.promises.unlink('/a.txt'), { code: 'EROFS' }); + await assert.rejects(archiveVfs.promises.mkdir('/newdir'), { code: 'EROFS' }); + await assert.rejects(archiveVfs.promises.rmdir('/newdir'), { code: 'EROFS' }); + await assert.rejects(archiveVfs.promises.rename('/a.txt', '/b.txt'), { code: 'EROFS' }); + + // The synchronous surface rejects the same way. + assert.strictEqual(archiveVfs.readFileSync('/a.txt', 'utf8'), 'x'); + assert.throws(() => archiveVfs.writeFileSync('/new.txt', 'y'), { code: 'EROFS' }); + assert.throws(() => archiveVfs.unlinkSync('/a.txt'), { code: 'EROFS' }); + assert.throws(() => archiveVfs.mkdirSync('/newdir'), { code: 'EROFS' }); + assert.throws(() => archiveVfs.rmdirSync('/newdir'), { code: 'EROFS' }); + assert.throws(() => archiveVfs.renameSync('/a.txt', '/b.txt'), { code: 'EROFS' }); + + await zip.close(); + } + + // --- ZipFile-backed, opened writable: mutations persist to disk ---------- + { + const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]); + const filePath = path.join(tmpdir.path, 'vfs-archive-writable.zip'); + await fsPromises.writeFile(filePath, archive); + const zip = await zlib.ZipFile.open(filePath, { writable: true }); + const provider = new vfs.ArchiveProvider(zip); + assert.strictEqual(provider.readonly, false); + const archiveVfs = vfs.create(provider); + + await archiveVfs.promises.writeFile('/b.txt', 'new content'); + await zip.close(); + + const reopened = await zlib.ZipFile.open(filePath); + assert.strictEqual((await (await reopened.get('b.txt')).content()).toString(), 'new content'); + await reopened.close(); + } + + // --- ZipBuffer-backed, fully synchronous CRUD ------------------------------ + { + const archive = await buildArchive([ + await zlib.ZipEntry.create('a.txt', Buffer.from('hello')), + await zlib.ZipEntry.create('dir/b.txt', Buffer.from('nested')), + ]); + const zip = new zlib.ZipBuffer(archive); + const provider = new vfs.ArchiveProvider(zip); + const archiveVfs = vfs.create(provider); + + // stat/readdir. + assert.strictEqual(archiveVfs.statSync('/a.txt').isFile(), true); + assert.strictEqual(archiveVfs.statSync('/dir').isDirectory(), true); + assert.throws(() => archiveVfs.statSync('/missing.txt'), { code: 'ENOENT' }); + assert.deepStrictEqual(archiveVfs.readdirSync('/').sort(), ['a.txt', 'dir']); + assert.throws(() => archiveVfs.readdirSync('/a.txt'), { code: 'ENOTDIR' }); + assert.throws( + () => archiveVfs.readdirSync('/', { recursive: true }), + { code: 'ERR_METHOD_NOT_IMPLEMENTED' }, + ); + + // readFile/writeFile/appendFile round trip. + assert.strictEqual(archiveVfs.readFileSync('/a.txt', 'utf8'), 'hello'); + archiveVfs.writeFileSync('/new.txt', 'brand new'); + assert.strictEqual(archiveVfs.readFileSync('/new.txt', 'utf8'), 'brand new'); + archiveVfs.appendFileSync('/new.txt', '!'); + assert.strictEqual(archiveVfs.readFileSync('/new.txt', 'utf8'), 'brand new!'); + + // mkdir/rmdir. + archiveVfs.mkdirSync('/newdir'); + assert.strictEqual(archiveVfs.statSync('/newdir').isDirectory(), true); + assert.throws(() => archiveVfs.mkdirSync('/newdir'), { code: 'EEXIST' }); + archiveVfs.mkdirSync('/newdir', { recursive: true }); // No throw, already exists. + archiveVfs.rmdirSync('/newdir'); + assert.throws(() => archiveVfs.statSync('/newdir'), { code: 'ENOENT' }); + assert.throws(() => archiveVfs.rmdirSync('/dir'), { code: 'ENOTEMPTY' }); + + // unlink. + archiveVfs.unlinkSync('/new.txt'); + assert.throws(() => archiveVfs.statSync('/new.txt'), { code: 'ENOENT' }); + assert.throws(() => archiveVfs.unlinkSync('/missing.txt'), { code: 'ENOENT' }); + assert.throws(() => archiveVfs.unlinkSync('/dir'), { code: 'EISDIR' }); + + // rename. + archiveVfs.writeFileSync('/rename-me.txt', 'content'); + archiveVfs.renameSync('/rename-me.txt', '/renamed.txt'); + assert.strictEqual(zip.has('rename-me.txt'), false); + assert.strictEqual(archiveVfs.readFileSync('/renamed.txt', 'utf8'), 'content'); + + // open() flag semantics. + assert.throws(() => archiveVfs.openSync('/does-not-exist.txt', 'r'), { code: 'ENOENT' }); + assert.throws(() => archiveVfs.openSync('/a.txt', 'wx'), { code: 'EEXIST' }); + assert.throws(() => archiveVfs.openSync('/dir', 'r'), { code: 'EISDIR' }); + } + + // --- ZipFile-backed via openSync: sync-only round trip on disk ----------- + { + const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]); + const filePath = path.join(tmpdir.path, 'vfs-archive-opensync.zip'); + await fsPromises.writeFile(filePath, archive); + const zip = zlib.ZipFile.openSync(filePath, { writable: true }); + const provider = new vfs.ArchiveProvider(zip); + assert.strictEqual(provider.readonly, false); + const archiveVfs = vfs.create(provider); + + assert.strictEqual(archiveVfs.readFileSync('/a.txt', 'utf8'), 'x'); + archiveVfs.writeFileSync('/b.txt', 'new content'); + zip.closeSync(); + + const reopened = zlib.ZipFile.openSync(filePath); + assert.strictEqual(reopened.getSync('b.txt').contentSync().toString(), 'new content'); + reopened.closeSync(); + } +})().then(common.mustCall()); diff --git a/test/parallel/test-zlib-zip-coverage.js b/test/parallel/test-zlib-zip-coverage.js new file mode 100644 index 00000000000000..1a4889ae3858a1 --- /dev/null +++ b/test/parallel/test-zlib-zip-coverage.js @@ -0,0 +1,738 @@ +'use strict'; + +require('../common'); + +const assert = require('node:assert'); +const os = require('node:os'); +const path = require('node:path'); +const fs = require('node:fs/promises'); +const zlib = require('node:zlib'); +const { test } = require('node:test'); + +// 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 Zip64 record that cannot be found anywhere is rejected', () => { + const buf = buildMinimalZip64Archive({ locator: { recordOffset: 999_999n } }); + 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/, + }); +}); + +// 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 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_ENTRY_TOO_LARGE' }); + assert.throws(() => [...zlib.createZipArchiveSync([], 'x'.repeat(70000))], + { code: 'ERR_ZIP_ENTRY_TOO_LARGE' }); +}); + +// -- streaming-entry state guards ---------------------------------------------- + +test('a pending streaming entry rejects size/crc32/compressedSize/contentStream() 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.contentStream(), { 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})` }); + } +}); + +// -- contentStream() / decodeMemberStream() error paths ------------------------ + +test('contentStream() 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.contentStream()), { 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.contentStream()), { 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.contentStream({ 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.contentStream()), { 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.contentStream()), { code: 'ERR_ZIP_ENTRY_CORRUPT' }); + const unverified = await drain(read.contentStream({ 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 `ZipFile.prototype.streamSync()`) duplicates +// `decodeMemberStream()`'s guards for its own, separate synchronous code +// path; exercise them through a disk-backed ZipFile, which - unlike +// `ZipEntry.prototype.contentSync()` - doesn't pre-check maxSize itself. +test('ZipFile streamSync() enforces the same guards via decodeMemberSync()', async () => { + async function writeTempArchive(archive, suffix) { + const filePath = path.join(os.tmpdir(), `zip-coverage-streamsync-${process.pid}-${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.streamSync('f.txt')], { 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.streamSync('f.txt', { 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.streamSync('f.txt')], { 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.streamSync('f.txt')], { code: 'ERR_ZIP_ENTRY_CORRUPT' }); + zf.closeSync(); + await fs.unlink(filePath); + } +}); + +test('contentStream() 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.contentStream()), { + 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 = path.join(os.tmpdir(), `zip-coverage-iteration-${process.pid}.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']); + } 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 = path.join(os.tmpdir(), `zip-coverage-garbage-${process.pid}.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()/streamSync() reject a missing entry name', async () => { + const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]); + const filePath = path.join(os.tmpdir(), `zip-coverage-notfound-${process.pid}.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').next(), { code: 'ERR_ZIP_ENTRY_NOT_FOUND' }); + assert.throws(() => [...zf.streamSync('missing')], { code: 'ERR_ZIP_ENTRY_NOT_FOUND' }); + } finally { + await zf.close(); + await fs.unlink(filePath); + } +}); + +test('a corrupted local file header offset is rejected when the entry is read', 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 + // (signature 0x02014b50, not the local-header signature 0x04034b50). + tampered.writeUInt32LE(centralHeaderStart, centralHeaderStart + 42); + const filePath = path.join(os.tmpdir(), `zip-coverage-badlocal-${process.pid}.zip`); + await fs.writeFile(filePath, tampered); + + const zf = await zlib.ZipFile.open(filePath); + const zfSync = zlib.ZipFile.openSync(filePath); + try { + await assert.rejects(zf.get(name), { code: 'ERR_ZIP_INVALID_ARCHIVE' }); + assert.throws(() => zfSync.getSync(name), { code: 'ERR_ZIP_INVALID_ARCHIVE' }); + } finally { + await zf.close(); + zfSync.closeSync(); + 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 = path.join(os.tmpdir(), `zip-coverage-eof-${process.pid}.zip`); + await fs.writeFile(filePath, tampered); + + const zf = await zlib.ZipFile.open(filePath); + const zfSync = zlib.ZipFile.openSync(filePath); + try { + await assert.rejects(zf.get(name), { code: 'ERR_ZIP_INVALID_ARCHIVE' }); + assert.throws(() => zfSync.getSync(name), { code: 'ERR_ZIP_INVALID_ARCHIVE' }); + } finally { + await zf.close(); + zfSync.closeSync(); + await fs.unlink(filePath); + } +}); + +test('get()/getSync() refuse to buffer an entry declaring more than kMaxLength bytes', 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 just below kMaxLength is itself a safe + // integer (so it clears the readSafeUint64() check), but adding the local + // header's own size still pushes the total over the limit - no multi-GB + // file is needed to observe this, since the check runs before any read. + 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 - 10), 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 = path.join(os.tmpdir(), `zip-coverage-toolarge-${process.pid}.zip`); + await fs.writeFile(toolargeFilePath, patched); + const zf2 = await zlib.ZipFile.open(toolargeFilePath); + const zf2Sync = zlib.ZipFile.openSync(toolargeFilePath); + try { + await assert.rejects(zf2.get(name), { code: 'ERR_ZIP_ENTRY_TOO_LARGE', message: /use stream\(\) instead/ }); + assert.throws(() => zf2Sync.getSync(name), + { code: 'ERR_ZIP_ENTRY_TOO_LARGE', message: /use streamSync\(\) instead/ }); + } finally { + await zf2.close(); + zf2Sync.closeSync(); + 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 }); 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..ee19d0b7b37eb9 --- /dev/null +++ b/test/parallel/test-zlib-zip-hardening.js @@ -0,0 +1,172 @@ +'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('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' }); +}); diff --git a/test/parallel/test-zlib-zip-interop.js b/test/parallel/test-zlib-zip-interop.js new file mode 100644 index 00000000000000..17f4f17d0ea53f --- /dev/null +++ b/test/parallel/test-zlib-zip-interop.js @@ -0,0 +1,95 @@ +'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 os = require('node:os'); +const { spawnSync } = require('node:child_process'); +const { test } = require('node:test'); + +function hasTool(command, args = ['--help']) { + try { + const result = spawnSync(command, args, { stdio: 'ignore' }); + return result.error === undefined; + } catch { + return false; + } +} + +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 dir = await fs.mkdtemp(path.join(os.tmpdir(), '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]); + assert.strictEqual(unzipTest.status, 0, unzipTest.stderr?.toString()); + + const zipinfo = spawnSync('zipinfo', [archivePath]); + assert.strictEqual(zipinfo.status, 0); + for (const name of Object.keys(files)) { + 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]); + 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(os.tmpdir(), '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..d96e322ae8c29d --- /dev/null +++ b/test/parallel/test-zlib-zip-metadata.js @@ -0,0 +1,68 @@ +'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 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'); +}); diff --git a/test/parallel/test-zlib-zip-property.js b/test/parallel/test-zlib-zip-property.js new file mode 100644 index 00000000000000..356cb2e01a7f6d --- /dev/null +++ b/test/parallel/test-zlib-zip-property.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 os = require('node:os'); +const { test } = require('node:test'); + +// 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(os.tmpdir(), '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 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.contentStream()) 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..b37d927dfa9a4e --- /dev/null +++ b/test/parallel/test-zlib-zip-sync.js @@ -0,0 +1,253 @@ +'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 os = require('node:os'); +const { test } = require('node:test'); + +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(os.tmpdir(), '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('ZipEntry.contentStreamSync() yields the whole entry as one chunk', () => { + const entry = zlib.ZipEntry.createSync('a.txt', Buffer.from('stream me')); + const chunks = [...entry.contentStreamSync()]; + assert.strictEqual(chunks.length, 1); + assert.strictEqual(chunks[0].toString(), 'stream me'); +}); + +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.streamSync('a.txt')].map(String), ['a']); + 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..7fc7f772c5e8e5 --- /dev/null +++ b/test/parallel/test-zlib-zip-writable.js @@ -0,0 +1,246 @@ +'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 os = require('node:os'); +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); +} + +async function createTempZip(entries, comment) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), '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 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..e93ed94c846d6a --- /dev/null +++ b/test/parallel/test-zlib-zip.js @@ -0,0 +1,112 @@ +'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_ENTRY_TOO_LARGE' }, + ); +}); + +test('directory entries cannot carry content', async () => { + await assert.rejects( + zlib.ZipEntry.create('dir/', Buffer.from('x')), + { code: 'ERR_INVALID_ARG_VALUE' }, + ); +}); diff --git a/test/pummel/test-zlib-zip-slow.js b/test/pummel/test-zlib-zip-slow.js new file mode 100644 index 00000000000000..9aab95956cb4e9 --- /dev/null +++ b/test/pummel/test-zlib-zip-slow.js @@ -0,0 +1,106 @@ +'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 os = require('node:os'); +const { test } = require('node:test'); + +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 = 2.5 * GiB; // ...plus one large streamed member: +// total archive size is ~4.5 GiB, comfortably over the 4 GiB Zip64 offset +// threshold. Required free space includes generous slack over that total. +const REQUIRED_FREE_BYTES = 10 * 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(os.tmpdir()); + free = stats.bavail * stats.bsize; + } catch { + free = undefined; + } + if (free !== undefined && free < REQUIRED_FREE_BYTES) { + common.skip(`insufficient disk space in ${os.tmpdir()} for a >4 GiB archive test`); + return; + } + + const dir = await fs.mkdtemp(path.join(os.tmpdir(), '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 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 zip.stream('stored-2.bin')) { + storedSeen += chunk.length; + assert.strictEqual(chunk[0], 2); + } + assert.strictEqual(storedSeen, MEMBER_SIZE); + } finally { + await zip.close(); + } + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}, { timeout: 30 * 60 * 1000 });