diff --git a/benchmark/webstreams/tee.js b/benchmark/webstreams/tee.js new file mode 100644 index 000000000000..2b1b4802dd92 --- /dev/null +++ b/benchmark/webstreams/tee.js @@ -0,0 +1,44 @@ +'use strict'; +const common = require('../common.js'); +const { ReadableStream } = require('node:stream/web'); + +const bench = common.createBenchmark(main, { + n: [1e5], + type: ['normal', 'bytes'], +}); + +async function main({ n, type }) { + let i = 0; + const source = type === 'bytes' ? + { + type: 'bytes', + pull(controller) { + if (i++ < n) controller.enqueue(new Uint8Array(16)); + else controller.close(); + }, + } : + { + pull(controller) { + if (i++ < n) controller.enqueue('a'); + else controller.close(); + }, + }; + + const rs = new ReadableStream(source); + const [branch1, branch2] = rs.tee(); + const reader1 = branch1.getReader(); + const reader2 = branch2.getReader(); + let reads = 0; + + bench.start(); + for (;;) { + const [result1, result2] = await Promise.all([ + reader1.read(), + reader2.read(), + ]); + if (result1.done || result2.done) break; + reads++; + } + bench.end(reads); + console.assert(reads === n); +} diff --git a/common.gypi b/common.gypi index e065e6e925f5..0b01ec8c49fe 100644 --- a/common.gypi +++ b/common.gypi @@ -42,7 +42,7 @@ # Reset this number to 0 on major V8 upgrades. # Increment by one for each non-official patch applied to deps/v8. - 'v8_embedder_string': '-node.27', + 'v8_embedder_string': '-node.28', ##### V8 defaults for Node.js ##### diff --git a/deps/v8/AUTHORS b/deps/v8/AUTHORS index 9329e34ae252..ef0aeedaca51 100644 --- a/deps/v8/AUTHORS +++ b/deps/v8/AUTHORS @@ -77,6 +77,7 @@ Artem Kobzar Arthur Islamov Asuka Shikina Aurèle Barrière +Aviv Keller Bala Avulapati Bangfu Tao Ben Coe diff --git a/deps/v8/src/inspector/injected-script.cc b/deps/v8/src/inspector/injected-script.cc index 1260f19be6de..41b4d4bd25b5 100644 --- a/deps/v8/src/inspector/injected-script.cc +++ b/deps/v8/src/inspector/injected-script.cc @@ -204,8 +204,7 @@ class InjectedScript::ProtocolPromiseHandler { PromiseHandlerTracker::DiscardReason::kFulfilled); } - ProtocolPromiseHandler(PromiseHandlerTracker::Id id, - V8InspectorSessionImpl* session, + ProtocolPromiseHandler(V8InspectorSessionImpl* session, int executionContextId, const String16& objectGroup, std::unique_ptr wrapOptions, bool replMode, bool throwOnSideEffect, @@ -220,7 +219,13 @@ class InjectedScript::ProtocolPromiseHandler { m_replMode(replMode), m_throwOnSideEffect(throwOnSideEffect), m_callback(std::move(callback)), - m_evaluationResult(m_inspector->isolate(), evaluationResult) { + m_evaluationResult(m_inspector->isolate(), evaluationResult) {} + + void makeWeak(PromiseHandlerTracker::Id id) { + if (m_isActive || m_evaluationResult.IsEmpty() || + m_evaluationResult.IsWeak()) { + return; + } m_evaluationResult.SetWeak(reinterpret_cast(id), cleanup, v8::WeakCallbackType::kParameter); } @@ -238,6 +243,7 @@ class InjectedScript::ProtocolPromiseHandler { } void thenCallback(v8::Local value) { + m_isActive = true; // We don't need the m_evaluationResult in the `thenCallback`, but we also // don't want `cleanup` running in case we re-enter JS. m_evaluationResult.Reset(); @@ -285,9 +291,10 @@ class InjectedScript::ProtocolPromiseHandler { } void catchCallback(v8::Local result) { + m_isActive = true; // Hold strongly onto m_evaluationResult now to prevent `cleanup` from // running in case any code below triggers GC. - m_evaluationResult.ClearWeak(); + if (m_evaluationResult.IsWeak()) m_evaluationResult.ClearWeak(); V8InspectorSessionImpl* session = m_inspector->sessionById(m_contextGroupId, m_sessionId); if (!session) return; @@ -393,6 +400,7 @@ class InjectedScript::ProtocolPromiseHandler { std::unique_ptr m_wrapOptions; bool m_replMode; bool m_throwOnSideEffect; + bool m_isActive = false; std::weak_ptr m_callback; v8::Global m_evaluationResult; }; @@ -1190,8 +1198,7 @@ template PromiseHandlerTracker::Id PromiseHandlerTracker::create(Args&&... args) { Id id = m_lastUsedId++; InjectedScript::ProtocolPromiseHandler* handler = - new InjectedScript::ProtocolPromiseHandler(id, - std::forward(args)...); + new InjectedScript::ProtocolPromiseHandler(std::forward(args)...); m_promiseHandlers.emplace(id, handler); return id; } @@ -1225,6 +1232,30 @@ InjectedScript::ProtocolPromiseHandler* PromiseHandlerTracker::get( return iter->second.get(); } +void PromiseHandlerTracker::makeWeakForContext(int executionContextId) { + for (auto& [id, handler] : m_promiseHandlers) { + if (handler->m_executionContextId == executionContextId) { + handler->makeWeak(id); + } + } +} + +void PromiseHandlerTracker::makeWeakForObjectGroup( + int sessionId, const String16& objectGroup) { + for (auto& [id, handler] : m_promiseHandlers) { + if (handler->m_sessionId == sessionId && + handler->m_objectGroup == objectGroup) { + handler->makeWeak(id); + } + } +} + +void PromiseHandlerTracker::makeWeakForSession(int sessionId) { + for (auto& [id, handler] : m_promiseHandlers) { + if (handler->m_sessionId == sessionId) handler->makeWeak(id); + } +} + void PromiseHandlerTracker::sendFailure( InjectedScript::ProtocolPromiseHandler* handler, const protocol::DispatchResponse& response) const { diff --git a/deps/v8/src/inspector/injected-script.h b/deps/v8/src/inspector/injected-script.h index 895f01fde9aa..864bc8c53f9f 100644 --- a/deps/v8/src/inspector/injected-script.h +++ b/deps/v8/src/inspector/injected-script.h @@ -298,6 +298,9 @@ class PromiseHandlerTracker { Id create(Args&&... args); void discard(Id id, DiscardReason reason); InjectedScript::ProtocolPromiseHandler* get(Id id) const; + void makeWeakForContext(int executionContextId); + void makeWeakForObjectGroup(int sessionId, const String16& objectGroup); + void makeWeakForSession(int sessionId); private: void sendFailure(InjectedScript::ProtocolPromiseHandler* handler, diff --git a/deps/v8/src/inspector/v8-inspector-impl.cc b/deps/v8/src/inspector/v8-inspector-impl.cc index 9dae9ef1f369..a4610e0d9b33 100644 --- a/deps/v8/src/inspector/v8-inspector-impl.cc +++ b/deps/v8/src/inspector/v8-inspector-impl.cc @@ -319,6 +319,7 @@ void V8InspectorImpl::contextCollected(int groupId, int contextId) { session->runtimeAgent()->reportExecutionContextDestroyed(inspectedContext); }); discardInspectedContext(groupId, contextId); + m_promiseHandlerTracker.makeWeakForContext(contextId); } void V8InspectorImpl::resetContextGroup(int contextGroupId) { diff --git a/deps/v8/src/inspector/v8-inspector-session-impl.cc b/deps/v8/src/inspector/v8-inspector-session-impl.cc index d51bd1a56ac2..95ae57032b32 100644 --- a/deps/v8/src/inspector/v8-inspector-session-impl.cc +++ b/deps/v8/src/inspector/v8-inspector-session-impl.cc @@ -224,6 +224,7 @@ void V8InspectorSessionImpl::discardInjectedScripts() { [&sessionId](InspectedContext* context) { context->discardInjectedScript(sessionId); }); + m_inspector->promiseHandlerTracker().makeWeakForSession(sessionId); } Response V8InspectorSessionImpl::findInjectedScript( @@ -260,6 +261,10 @@ void V8InspectorSessionImpl::releaseObjectGroup(const String16& objectGroup) { InjectedScript* injectedScript = context->getInjectedScript(sessionId); if (injectedScript) injectedScript->releaseObjectGroup(objectGroup); }); + if (!objectGroup.isEmpty()) { + m_inspector->promiseHandlerTracker().makeWeakForObjectGroup(m_sessionId, + objectGroup); + } } bool V8InspectorSessionImpl::unwrapObject( diff --git a/deps/v8/test/inspector/runtime/evaluate-promise-lifetime-expected.txt b/deps/v8/test/inspector/runtime/evaluate-promise-lifetime-expected.txt new file mode 100644 index 000000000000..6216bff5f443 --- /dev/null +++ b/deps/v8/test/inspector/runtime/evaluate-promise-lifetime-expected.txt @@ -0,0 +1,65 @@ +Tests the lifetime of pending Runtime.evaluate requests. + +Running test: testPromiseIsKeptAlive +Using replMode: +{ + id : + result : { + result : { + description : 42 + type : number + value : 42 + } + } +} +Using awaitPromise: +{ + id : + result : { + result : { + description : 42 + type : number + value : 42 + } + } +} + +Running test: testObjectGroupReleaseMakesPromiseCollectible +Using replMode: +{ + error : { + code : -32000 + message : Promise was collected + } + id : +} +Using awaitPromise: +{ + error : { + code : -32000 + message : Promise was collected + } + id : +} + +Running test: testContextDestructionDiscardsPromise +Using replMode: +{ + error : { + code : -32000 + message : Execution context was destroyed. + } + id : +} +Using awaitPromise: +{ + error : { + code : -32000 + message : Execution context was destroyed. + } + id : +} + +Running test: testSessionDestructionMakesPromiseCollectible +Promise is alive before disconnect: true +Promise is alive after disconnect: false diff --git a/deps/v8/test/inspector/runtime/evaluate-promise-lifetime.js b/deps/v8/test/inspector/runtime/evaluate-promise-lifetime.js new file mode 100644 index 000000000000..49bde5385185 --- /dev/null +++ b/deps/v8/test/inspector/runtime/evaluate-promise-lifetime.js @@ -0,0 +1,105 @@ +// Copyright 2026 the V8 project authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Flags: --no-stress-incremental-marking + +let {Protocol} = InspectorTest.start( + 'Tests the lifetime of pending Runtime.evaluate requests.'); + +const evaluationModes = [ + { + name: 'replMode', + arguments: {replMode: true}, + expression: + 'await new Promise(resolve => globalThis.resolve = resolve); 42', + resolveExpression: 'resolve()', + pendingExpression: 'await new Promise(() => {})', + }, + { + name: 'awaitPromise', + arguments: {awaitPromise: true}, + expression: `(() => { + let resolve; + const promise = new Promise(r => resolve = r); + promise.resolve = resolve; + globalThis.weak = new WeakRef(promise); + return promise; + })()`, + resolveExpression: 'weak.deref().resolve(42)', + pendingExpression: 'new Promise(() => {})', + }, +]; + +function evaluate(Protocol, mode, expression, extraArguments = {}) { + return Protocol.Runtime.evaluate( + {...mode.arguments, ...extraArguments, expression}); +} + +InspectorTest.runAsyncTestSuite([ + async function testPromiseIsKeptAlive() { + for (const mode of evaluationModes) { + InspectorTest.log(`Using ${mode.name}:`); + const evaluation = evaluate(Protocol, mode, mode.expression); + + await Protocol.HeapProfiler.collectGarbage(); + await Protocol.Runtime.evaluate({expression: mode.resolveExpression}); + + InspectorTest.logMessage(await evaluation); + } + }, + + async function testObjectGroupReleaseMakesPromiseCollectible() { + for (const mode of evaluationModes) { + InspectorTest.log(`Using ${mode.name}:`); + const evaluation = evaluate( + Protocol, mode, mode.pendingExpression, + {objectGroup: 'evaluation'}); + + await Protocol.Runtime.releaseObjectGroup({objectGroup: 'evaluation'}); + await Protocol.HeapProfiler.collectGarbage(); + + InspectorTest.logMessage(await evaluation); + } + }, + + async function testContextDestructionDiscardsPromise() { + for (const mode of evaluationModes) { + InspectorTest.log(`Using ${mode.name}:`); + const contextGroup = new InspectorTest.ContextGroup(); + const session = contextGroup.connect(); + const evaluation = evaluate( + session.Protocol, mode, mode.pendingExpression); + + await session.Protocol.Runtime.evaluate( + {expression: 'inspector.fireContextDestroyed()'}); + + InspectorTest.logMessage(await evaluation); + session.disconnect(); + } + }, + + async function testSessionDestructionMakesPromiseCollectible() { + const contextGroup = new InspectorTest.ContextGroup(); + const session1 = contextGroup.connect(); + const session2 = contextGroup.connect(); + session1.Protocol.Runtime.evaluate({ + expression: evaluationModes[1].expression, + awaitPromise: true, + }); + + await session2.Protocol.HeapProfiler.collectGarbage(); + let result = await session2.Protocol.Runtime.evaluate( + {expression: 'weak.deref() !== undefined'}); + InspectorTest.log( + `Promise is alive before disconnect: ${result.result.result.value}`); + + session1.disconnect(); + await session2.Protocol.HeapProfiler.collectGarbage(); + result = await session2.Protocol.Runtime.evaluate( + {expression: 'weak.deref() !== undefined'}); + InspectorTest.log( + `Promise is alive after disconnect: ${result.result.result.value}`); + session2.disconnect(); + }, +]); diff --git a/doc/api/cli.md b/doc/api/cli.md index 76f2aecf75b6..9434e19d3b73 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -2877,6 +2877,21 @@ This option may be specified multiple times to include multiple glob patterns. If both `--test-coverage-exclude` and `--test-coverage-include` are provided, files must meet **both** criteria to be included in the coverage report. +### `--test-coverage-include-all` + + + +> Stability: 1 - Experimental + +Includes source files that were never loaded by the test run in the coverage +report, where they are reported as having zero coverage. + +Candidate files are searched for in the current working directory, and are +subject to the same `--test-coverage-include` and `--test-coverage-exclude` +filtering as the rest of the report. + ### `--test-coverage-lines=threshold` + +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.ZipProvider(zip)); + + console.log(await archiveVfs.promises.readdir('/')); + await archiveVfs.promises.writeFile('/new.txt', 'hello'); +} +main(); +``` + +### `new ZipProvider(source)` + + + +* `source` {zlib.ZipBuffer|zlib.ZipFile} An already-open archive. + ## Implementation details ### `Stats` objects @@ -320,6 +371,10 @@ fields use synthetic but stable values: [`RealFSProvider`]: #class-realfsprovider [`VirtualFileSystem`]: #class-virtualfilesystem [`VirtualProvider`]: #class-virtualprovider +[`ZipProvider`]: #class-zipprovider [`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 46b8eef1ad9f..234a23bbe092 100644 --- a/doc/api/zlib.md +++ b/doc/api/zlib.md @@ -1007,6 +1007,1104 @@ added: v0.5.8 Decompress either a Gzip- or Deflate-compressed stream by auto-detecting the header. +## Class: `zlib.ZipBuffer` + + + +> Stability: 1.0 - Early development + +The ZIP archive API is experimental. Using any part of it (this class among +them) emits an experimental warning the first time; merely importing +`node:zlib` does not. + +An in-memory, **zero-copy** view over the entries of a ZIP archive already +held in a `Buffer`, `TypedArray`, `DataView`, or `ArrayBuffer`. Its set of +entries can be edited - entries added or removed - but, unlike [`ZipFile`][], +those edits are **not** written into the source buffer: a newly added entry is +held as a separate in-memory [`ZipEntry`][] (the passed buffer is a fixed-size +view with no room to append to), and removal just drops the entry from +`ZipBuffer`'s index. The original bytes are never modified. +[`zipBuffer.toBuffer()`][] serializes the current set of entries into a fresh +archive. + +`ZipBuffer` does not copy the archive you hand it. It keeps a view onto that +memory and reads each entry's content lazily and directly from it, which is +what makes construction cheap regardless of archive size. The trade-off is +that you **must not modify or reuse** that memory - including the +`ArrayBuffer` backing a `TypedArray`/`DataView` - while the `ZipBuffer`, or +any [`ZipEntry`][] obtained from it, is still in use: a later read would +observe the change and may fail or return corrupt data. Pass a copy (for +example `Buffer.from(source)`) if the source might be mutated or reused. + +`add()` and `toBuffer()` each have a `*Sync` counterpart +([`addSync()`][`zipBuffer.addSync()`], [`toBufferSync()`][`zipBuffer.toBufferSync()`]) +that performs the same compression work synchronously. As with the +synchronous `node:fs` APIs, these block the Node.js event loop and further +JavaScript execution until the operation completes; use them only where +synchronous execution is appropriate (for example, short-lived scripts or +startup code), not in code that must stay responsive. + +```mjs +import { ZipBuffer } from 'node:zlib'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { Buffer } from 'node:buffer'; + +const zip = new ZipBuffer(readFileSync('archive.zip')); +for (const [name, entry] of zip) { + console.log(name, entry.size); +} +await zip.add('hello.txt', Buffer.from('Hello, world!')); +zip.delete('unwanted.txt'); +writeFileSync('archive.zip', await zip.toBuffer()); +``` + +```cjs +const { ZipBuffer } = require('node:zlib'); +const { readFileSync, writeFileSync } = require('node:fs'); + +async function main() { + const zip = new ZipBuffer(readFileSync('archive.zip')); + for (const [name, entry] of zip) { + console.log(name, entry.size); + } + await zip.add('hello.txt', Buffer.from('Hello, world!')); + zip.delete('unwanted.txt'); + writeFileSync('archive.zip', await zip.toBuffer()); +} +main(); +``` + +### `new zlib.ZipBuffer(buffer)` + + + +* `buffer` {Buffer|TypedArray|DataView|ArrayBuffer} A complete ZIP archive. + +Parses the archive's central directory. Throws an [`ERR_ZIP_INVALID_ARCHIVE`][] +or [`ERR_ZIP_UNSUPPORTED_FEATURE`][] error if `buffer` is not a well-formed, +supported archive. + +`buffer` is **not copied**: the `ZipBuffer` retains a zero-copy view of it (for +a `TypedArray`, `DataView`, or `ArrayBuffer`, of the underlying `ArrayBuffer`) +and reads entry content directly from it on demand. Do not mutate or reuse that +memory while the `ZipBuffer` or any entry read from it is still live; pass a +copy if it might change. + +### `zipBuffer.add(filename, data[, options])` + + + +* `filename` {string} The entry's name within the archive. A trailing `/` + marks a directory entry. +* `data` {Buffer|TypedArray|DataView|ArrayBuffer} The entry's complete, + uncompressed content. +* `options` {Object} See [`zlib.ZipEntry.create()`][]. +* Returns: {Promise} Fulfilled with the created {ZipEntry}. + +Equivalent to `zipBuffer.addEntry(await zlib.ZipEntry.create(filename, data, +options))`. + +### `zipBuffer.addSync(filename, data[, options])` + + + +* `filename` {string} The entry's name within the archive. A trailing `/` + marks a directory entry. +* `data` {Buffer|TypedArray|DataView|ArrayBuffer} The entry's complete, + uncompressed content. +* `options` {Object} See [`zlib.ZipEntry.createSync()`][]. +* Returns: {ZipEntry} The created entry. + +The synchronous version of [`zipBuffer.add()`][]. Equivalent to +`zipBuffer.addEntry(zlib.ZipEntry.createSync(filename, data, options))`. + +### `zipBuffer.addEntry(entry)` + + + +* `entry` {ZipEntry} +* Returns: {ZipEntry} `entry`. + +Adds an already-built entry, keyed by its own [`zipEntry.name`][]. Replaces +any existing entry of that name. + +### `zipBuffer.clear()` + + + +Removes every entry. + +### `zipBuffer.comment` + + + +* Type: {string} + +The archive-level comment, preserved byte-for-byte across +[`zipBuffer.toBuffer()`][] calls unless overridden. The bytes are decoded as +UTF-8 when they are valid UTF-8 and as CP437 otherwise (the field carries no +encoding flag of its own). + +### `zipBuffer.delete(name)` + + + +* `name` {string} +* Returns: {boolean} `true` if an entry named `name` existed and was removed. + +### `zipBuffer.entries()` + + + +* Returns: {Iterator} of `[name, entry]` pairs, where `entry` is a + [`ZipEntry`][]. + +### `zipBuffer.forEach(callback[, thisArg])` + + + +* `callback` {Function} +* `thisArg` {any} + +Calls `callback` once for each entry, in the order the archive lists them. + +### `zipBuffer.get(name)` + + + +* `name` {string} +* Returns: {ZipEntry} + +Throws [`ERR_ZIP_ENTRY_NOT_FOUND`][] if the archive has no entry named `name`. + +### `zipBuffer.has(name)` + + + +* `name` {string} +* Returns: {boolean} + +### `zipBuffer.keys()` + + + +* Returns: {Iterator} of entry names. + +### `zipBuffer.size` + + + +* Type: {number} + +The number of entries in the archive. + +### `zipBuffer.toBuffer([options])` + + + +* `options` {string|Object} An archive comment, as a shorthand for + `{ comment: options }`. + * `comment` {string} An archive comment. **Default:** [`zipBuffer.comment`][]. + * `baseOffset` {number} Shifts every offset the archive records by this + many bytes, so the serialized archive is self-describing even when it is + written somewhere other than the start of its eventual file - for example, + after `baseOffset` bytes of other content already written to the same + output. **Default:** `0`. +* Returns: {Promise} Fulfilled with a {Buffer} containing the serialized + archive. + +Serializes the current set of entries - in the order they were added or +read - into a fresh archive, switching to Zip64 structures automatically as +needed (see [`zlib.createZipArchive()`][]). + +### `zipBuffer.toBufferSync([options])` + + + +* `options` {string|Object} See [`zipBuffer.toBuffer()`][]. +* Returns: {Buffer} The serialized archive. + +The synchronous version of [`zipBuffer.toBuffer()`][] (see +[`zlib.createZipArchiveSync()`][]). + +### `zipBuffer.values()` + + + +* Returns: {Iterator} of [`ZipEntry`][]. + +### `zipBuffer.writable` + + + +* Type: {boolean} + +Always `true`. + +## Class: `zlib.ZipEntry` + + + +> Stability: 1.0 - Early development + +The ZIP archive API is experimental. Using any part of it (this class among +them) emits an experimental warning the first time; merely importing +`node:zlib` does not. + +A single file or directory inside a ZIP archive. Instances are produced by +[`ZipBuffer`][] and [`ZipFile`][], or created directly for writing with +`ZipEntry.create()`/`ZipEntry.createStream()`. + +`create()` and `content()` each have a `*Sync` counterpart (the streaming +`contentIterator()` does not). As with the synchronous `node:fs` APIs, these +block the +Node.js event loop and further JavaScript execution until the operation +(including any deflate/inflate pass) completes; use them only where +synchronous execution is appropriate (for example, short-lived scripts or +startup code), not in code that must stay responsive. + +### Static method: `zlib.ZipEntry.create(filename, data[, options])` + + + +* `filename` {string} The entry's name within the archive. A trailing `/` + marks a directory entry. +* `data` {Buffer|TypedArray|DataView|ArrayBuffer} The entry's complete, + uncompressed content. Must be empty when `filename` names a directory. +* `options` {Object} + * `comment` {string} An entry comment. + * `mode` {integer} Unix permission bits. **Default:** `0o644` (`0o755` for + directories). + * `modified` {Date} The entry's modification time. **Default:** the + current time. + * `method` {string} One of `'deflate'`, `'store'`, or `'zstd'`. **Default:** + `'deflate'`, except for directories and empty content, which are always + stored. +* Returns: {Promise} Fulfilled with a {ZipEntry}. + +Compresses `data` (unless `method` is `'store'`, or compression would not +reduce its size) and computes its CRC-32. + +When the entry ends up stored uncompressed (because `method` is `'store'`, +or because compression would not reduce the size), the entry retains a +zero-copy view of `data` rather than a copy, and its CRC-32 has already been +recorded. Do not mutate `data` after creating the entry; pass a copy if it +might change. + +The MS-DOS date/time fields ZIP uses for `modified` have 2-second resolution +and no time zone. When `modified` does not fall on a whole 2-second +boundary, an Info-ZIP extended-timestamp extra field is written as well, +recording the whole (UTC) second so the time round-trips more precisely (see +[`zipEntry.modified`][]). This applies to every entry-creation path. + +### Static method: `zlib.ZipEntry.createStream(filename, source[, options])` + + + +* `filename` {string} The entry's name within the archive. Must not end + in `/`. +* `source` {AsyncIterable} Yields the entry's uncompressed content as + `Uint8Array` chunks. +* `options` {Object} + * `comment` {string} An entry comment. + * `mode` {integer} Unix permission bits. **Default:** `0o644`. + * `modified` {Date} The entry's modification time. **Default:** the + current time. + * `method` {string} One of `'deflate'`, `'store'`, or `'zstd'`. **Default:** + `'deflate'`. +* Returns: {ZipEntry} + +Creates an entry whose content is compressed on the fly as it is serialized +by [`zlib.createZipArchive()`][], without buffering `source` in memory. Its +`size`, `compressedSize`, and `crc32` only become available once +serialization has finished. There is no synchronous counterpart: streaming +entries only make sense with an asynchronous, incrementally-produced +`source`. + +`source` is drained exactly once, during serialization. Until that happens +the entry has no readable content, so [`zipEntry.content()`][], +[`zipEntry.contentSync()`][], and [`zipEntry.contentIterator()`][] throw +[`ERR_INVALID_STATE`][]. If the entry is serialized by adding it to a writable +[`ZipFile`][] with [`zipFile.addEntry()`][] (or `addEntrySync()`), it is then +**promoted in place** to a file-backed entry pointing at the copy just written, +so it becomes readable (and can be serialized again) for as long as that +`ZipFile` stays open. Serializing it any other way (for example directly +through [`zlib.createZipArchive()`][]) leaves it spent and unreadable. + +Because `source` may hold an operating-system resource (a file read stream, +say), a streaming entry is disposable: its `Symbol.dispose` and +`Symbol.asyncDispose` methods destroy `source` if it has not been consumed. +An entry passed to an archive is disposed by that archive (see +[`zlib.createZipArchive()`][]); dispose an entry directly only when it was +built but never handed to one. Disposal is a no-op for non-streaming entries - +in particular a file-backed entry never closes the [`ZipFile`][] descriptor it +borrows. + +### Static method: `zlib.ZipEntry.createSymlink(filename, target[, options])` + + + +* `filename` {string} The entry's name within the archive. +* `target` {string} The symbolic link's target path. +* `options` {Object} + * `comment` {string} An entry comment. + * `mode` {integer} Unix permission bits. **Default:** `0o777`. + * `modified` {Date} The entry's modification time. **Default:** the current + time. +* Returns: {ZipEntry} + +Creates a symbolic-link entry: a stored entry whose content is `target` and +whose Unix mode type bits mark it as a symlink, so [`zipEntry.isSymlink`][] is +`true` when it is read back. Extraction tools that honor symlink entries +recreate the link; treat `target` as untrusted (see [`zipEntry.name`][] on +path safety). + +### Static method: `zlib.ZipEntry.createSync(filename, data[, options])` + + + +* `filename` {string} The entry's name within the archive. A trailing `/` + marks a directory entry. +* `data` {Buffer|TypedArray|DataView|ArrayBuffer} The entry's complete, + uncompressed content. Must be empty when `filename` names a directory. +* `options` {Object} See [`zlib.ZipEntry.create()`][]. +* Returns: {ZipEntry} + +The synchronous version of [`zlib.ZipEntry.create()`][]. + +### Static method: `zlib.ZipEntry.read(buffer)` + + + +* `buffer` {Buffer|TypedArray|DataView|ArrayBuffer} A complete ZIP archive. +* Returns: {Iterator} of {ZipEntry}. + +Parses every entry out of `buffer` directly, without indexing it into a +[`ZipBuffer`][]. Like [`ZipBuffer`][], the yielded entries hold zero-copy views +of `buffer` rather than copies of their content, so the same rule applies: do +not mutate or reuse `buffer` while any of them is still in use. + +### `zipEntry.comment` + + + +* Type: {string} + +### `zipEntry.compressed` + + + +* Type: {boolean} + +`true` if the entry's content is stored in compressed form (any compression +method, currently deflate or Zstandard); `false` if it is stored +uncompressed. + +### `zipEntry.compressedSize` + + + +* Type: {number} + +### `zipEntry.content([options])` + + + +* `options` {Object} + * `verify` {boolean} Verify the entry's CRC-32 checksum. **Default:** `true`. + * `maxSize` {number} Reject content declaring more than this many + uncompressed bytes, before allocating anything. **Default:** + [`zlib.getMaxZipContentSize()`][]. +* Returns: {Promise} Fulfilled with a {Buffer} containing the entry's + decompressed content. The buffer is a fresh copy that shares no memory + with the archive or with data the entry was created from. + +Throws an [`ERR_ZIP_ENTRY_TOO_LARGE`][] error if the entry's declared size +exceeds `maxSize`, an [`ERR_ZIP_ENTRY_CORRUPT`][] error if the content fails +CRC-32 verification or does not match its declared size, and an +[`ERR_INVALID_STATE`][] error for a streaming entry +([`zlib.ZipEntry.createStream()`][]) whose content is not yet available (see +that method for when a streaming entry becomes readable). + +### `zipEntry.contentSync([options])` + + + +* `options` {Object} See [`zipEntry.content()`][]. +* Returns: {Buffer} The entry's decompressed content. + +The synchronous version of [`zipEntry.content()`][]. + +### `zipEntry.contentIterator([options])` + + + +* `options` {Object} + * `verify` {boolean} Verify the entry's CRC-32 checksum. **Default:** `true`. + * `maxSize` {number} Reject content declaring more than this many + uncompressed bytes, before decompressing anything. **Default:** no limit. +* Returns: {AsyncIterator} of {Buffer} chunks of the entry's decompressed + content. + +Unlike [`zipEntry.content()`][], this does not buffer the whole member in +memory. For a file-backed entry (one returned by [`zipFile.get()`][]) the +compressed bytes are read from disk as the iterator is consumed and nothing is +retained; the entry is valid only while its `ZipFile` is open. + +Because streaming is the bounded-memory path for arbitrarily large members, it +is **not** capped by [`zlib.getMaxZipContentSize()`][] the way +[`zipEntry.content()`][] is - that default guards a single large allocation, +which streaming never makes. Output is still bounded per chunk to the declared +uncompressed size; pass `maxSize` to impose an explicit ceiling. + +For an in-memory entry stored without compression, the yielded chunks are +zero-copy views of the entry's retained content (see +[`zipEntry.rawContent`][]); do not mutate them. + +The yielded chunks are **provisional until the iterator completes**. CRC-32 +verification (and the final declared-size check) can only run once every byte +has been read, so a corrupt or truncated entry is reported by the iterator +throwing _after_ the last chunk, not before the first. Each chunk is still +bounded so the total never exceeds the declared size or `maxSize`, but a +consumer that must not act on unverified bytes should buffer them (or use +[`zipEntry.content()`][], which verifies before returning anything) rather than +processing chunks as they arrive. + +### `zipEntry.crc32` + + + +* Type: {number} + +### `zipEntry.flags` + + + +* Type: {number} + +The entry's raw general-purpose bit flag. + +### `zipEntry.isDirectory` + + + +* Type: {boolean} + +`true` if the entry is a directory (its name ends with `/`). + +### `zipEntry.isFile` + + + +* Type: {boolean} + +`true` if the entry is a regular file — that is, neither a directory nor a +symbolic link. + +### `zipEntry.isSymlink` + + + +* Type: {boolean} + +`true` if the entry is a symbolic link (its Unix mode type bits are +`S_IFLNK`); its content is the link target. Always `false` for archives not +written on a Unix-like system. When extracting, treat a symlink's target as +untrusted — see [`zipEntry.name`][] on path safety. + +### `zipEntry.mode` + + + +* Type: {number} + +The entry's Unix mode permission bits, including the setuid, setgid, and +sticky bits (the low 12 bits, `0o7777`), or `0` if the archive was not written +on a Unix-like system. The file-type bits are not included here; use +[`zipEntry.isDirectory`][] / [`zipEntry.isSymlink`][] for the type. + +### `zipEntry.modified` + + + +* Type: {Date} + +The entry's last-modification time. When the archive carries a higher-fidelity +timestamp in an extra field — an NTFS (`0x000a`), Info-ZIP extended (`0x5455`), +or Info-ZIP Unix (`0x5855`) field, as most modern tools write — that absolute +(UTC) time is used; otherwise the coarse, local-time MS-DOS date/time field +(2-second resolution) is used. + +Some tools store their high-fidelity timestamp only in the local file header, +so on a file-backed entry (one returned by [`zipFile.get()`][]) the first read +of this property may perform a small synchronous positioned disk read to +resolve that header. If that read fails, the value silently falls back to the +central-directory data. + +### `zipEntry.method` + + + +* Type: {number} + +The entry's raw compression method: `0` for stored, `8` for deflate, `93` +for Zstandard. + +### `zipEntry.name` + + + +* Type: {string} + +The entry's name, decoded from the central directory, which is treated as +authoritative — a local file header that disagrees is ignored, so a +mismatched-header ("ZIP-confusion") archive cannot make `name` disagree with +what is read. The bytes are decoded from a valid Info-ZIP Unicode Path extra +field (`0x7075`) when one is present; otherwise as UTF-8 when the +language-encoding flag (general-purpose bit 11) is set **or the bytes are +valid UTF-8** (plenty of tools wrote UTF-8 names without ever setting the +flag); and as CP437 — the historical default — only when they are not. +See [`zipEntry.nameBuffer`][] for the raw bytes. + +The name is returned **verbatim**: it is never normalized, and a name +containing `..`, a leading `/`, a drive letter, or backslashes is neither +rewritten nor rejected. A `ZipFile`/`ZipBuffer` never writes to disk, so +guarding against path traversal ("Zip Slip") when extracting is the caller's +responsibility. + +### `zipEntry.nameBuffer` + + + +* Type: {Buffer} + +The entry's raw name bytes, before any character decoding. Useful when the +archive's names are in an encoding other than UTF-8 or CP437 and the caller +wants to decode them itself. + +### `zipEntry.rawContent` + + + +* Type: {Buffer|null} + +The entry's raw (still compressed, if applicable) content when it is held in +memory, or `null` when there is no in-memory buffer to expose - for an entry +created with [`zlib.ZipEntry.createStream()`][], or a file-backed entry +returned by [`zipFile.get()`][], whose bytes are read from disk on demand +rather than retained. Use [`zipEntry.content()`][] or +[`zipEntry.contentIterator()`][] to read a file-backed entry. + +### `zipEntry.size` + + + +* Type: {number} + +The entry's uncompressed size, in bytes. + +## Class: `zlib.ZipFile` + + + +> Stability: 1.0 - Early development + +The ZIP archive API is experimental. Using any part of it (this class among +them) emits an experimental warning the first time; merely importing +`node:zlib` does not. + +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. + +These in-place edits are **not crash-atomic**. Rewriting the central directory +happens in place, so a write that fails partway - the disk fills, the device +disconnects, the process is killed - can leave the archive on disk with a +partial or missing central directory, i.e. unreadable, even though the member +data before it is intact. The rejected call surfaces the underlying error and +the `ZipFile` object is left usable (its in-memory view is not discarded, so a +caller can attempt recovery - for example re-writing the entries elsewhere with +[`zipFile.compact()`][]), but that in-memory view may no longer match the bytes +on disk. Write to a copy, or `compact()` into a fresh file, when durability +across a failure matters. + +Every method has a `*Sync` counterpart. As with the synchronous `node:fs` +APIs, these block the Node.js event loop and further JavaScript execution +until the operation completes; use them only where synchronous execution is +appropriate (for example, short-lived scripts or startup code), not in code +that must stay responsive. A synchronous method throws `ERR_INVALID_STATE` +if called while an asynchronous `add()`, `addEntry()`, `delete()`, or +`close()` on the same `ZipFile` has not settled yet, since letting the two +interleave could corrupt the archive. + +```mjs +import { ZipFile } from 'node:zlib'; +import { Buffer } from 'node:buffer'; + +const zip = await ZipFile.open('archive.zip', { writable: true }); +try { + const entry = await zip.get('member.txt'); + console.log((await entry.content()).toString()); + for await (const chunk of await zip.stream('huge.bin')) { + // Process each chunk without buffering the whole member. + } + await zip.add('new.txt', Buffer.from('hello')); + await zip.delete('unwanted.txt'); +} finally { + await zip.close(); +} +``` + +```cjs +const { ZipFile } = require('node:zlib'); + +async function main() { + const zip = await ZipFile.open('archive.zip', { writable: true }); + try { + const entry = await zip.get('member.txt'); + console.log((await entry.content()).toString()); + for await (const chunk of await zip.stream('huge.bin')) { + // Process each chunk without buffering the whole member. + } + await zip.add('new.txt', Buffer.from('hello')); + await zip.delete('unwanted.txt'); + } finally { + await zip.close(); + } +} +main(); +``` + +### Static method: `zlib.ZipFile.open(filename[, options])` + + + +* `filename` {string} +* `options` {Object} + * `writable` {boolean} Open the underlying file for both reading and + writing (`'r+'`), enabling [`zipFile.addEntry()`][]/[`zipFile.add()`][]/ + [`zipFile.delete()`][]. **Default:** `false`. +* Returns: {Promise} Fulfilled with a {ZipFile}. + +Throws an [`ERR_ZIP_ARCHIVE_TOO_LARGE`][] error if the archive's central +directory is too large to buffer in memory. + +### Static method: `zlib.ZipFile.openSync(filename[, options])` + + + +* `filename` {string} +* `options` {Object} See [`zlib.ZipFile.open()`][]. +* Returns: {ZipFile} + +The synchronous version of [`zlib.ZipFile.open()`][]. + +### `zipFile.add(filename, data[, options])` + + + +* `filename` {string} The entry's name within the archive. A trailing `/` + marks a directory entry. +* `data` {Buffer|TypedArray|DataView|ArrayBuffer} The entry's complete, + uncompressed content. +* `options` {Object} See [`zlib.ZipEntry.create()`][]. +* Returns: {Promise} Fulfilled with the created {ZipEntry}. + +Equivalent to `zipFile.addEntry(await zlib.ZipEntry.create(filename, data, +options))`. + +### `zipFile.addEntry(entry)` + + + +* `entry` {ZipEntry} +* Returns: {Promise} Fulfilled with `entry`. + +Writes `entry` where the central directory currently starts, then rewrites +the central directory to include it, replacing any existing entry of the +same name. Throws [`ERR_ZIP_NOT_WRITABLE`][] if the `ZipFile` was not opened +with `{ writable: true }`. + +The returned (same) `entry` is left readable: a streaming entry created with +[`zlib.ZipEntry.createStream()`][], which would otherwise be spent once +serialized, is promoted in place to a file-backed entry pointing at the copy +just written (valid while this `ZipFile` is open). In-memory entries keep their +own buffer unchanged. + +### `zipFile.addEntrySync(entry)` + + + +* `entry` {ZipEntry} +* Returns: {ZipEntry} `entry`. + +The synchronous version of [`zipFile.addEntry()`][]. `entry` must not be a +pending streaming entry (one created with +[`zlib.ZipEntry.createStream()`][]) - there is no synchronous way to drain +its asynchronous source. + +### `zipFile.addSync(filename, data[, options])` + + + +* `filename` {string} The entry's name within the archive. A trailing `/` + marks a directory entry. +* `data` {Buffer|TypedArray|DataView|ArrayBuffer} The entry's complete, + uncompressed content. +* `options` {Object} See [`zlib.ZipEntry.createSync()`][]. +* Returns: {ZipEntry} The created entry. + +The synchronous version of [`zipFile.add()`][]. Equivalent to +`zipFile.addEntrySync(zlib.ZipEntry.createSync(filename, data, options))`. + +### `zipFile.close()` + + + +* Returns: {Promise} + +Closes the underlying file handle. + +Closing does not invalidate outstanding objects: `ZipEntry` objects previously +returned by [`zipFile.get()`][] and the `ZipFile`'s own methods will fail with +system-level errors (for example `EBADF`) if used after close, rather than a +dedicated Node.js error code. The same applies to [`zipFile.closeSync()`][]. + +### `zipFile.closeSync()` + + + +The synchronous version of [`zipFile.close()`][]. + +### `zipFile.comment` + + + +* Type: {string} + +The archive-level comment, preserved byte-for-byte across +[`zipFile.addEntry()`][]/[`zipFile.delete()`][] calls. The bytes are decoded +as UTF-8 when they are valid UTF-8 and as CP437 otherwise (the field carries +no encoding flag of its own). + +### `zipFile.compact([comment])` + + + +* `comment` {string} An archive comment. **Default:** [`zipFile.comment`][]. +* Returns: {stream.Readable} A stream of the currently live entries, + serialized as a fresh archive with no dead space left by prior + [`zipFile.addEntry()`][]/[`zipFile.delete()`][] calls. + +Does not modify the open file; pipe the result into a new one: + +```mjs +import { createWriteStream } from 'node:fs'; +zip.compact().pipe(createWriteStream('compacted.zip')); +``` + +### `zipFile.compactSync([comment])` + + + +* `comment` {string} An archive comment. **Default:** [`zipFile.comment`][]. +* Returns: {Buffer} The currently live entries, serialized as a fresh + archive with no dead space left by prior + [`zipFile.addEntry()`][]/[`zipFile.delete()`][] calls. + +The synchronous version of [`zipFile.compact()`][]. Does not modify the +open file. + +### `zipFile.delete(name)` + + + +* `name` {string} +* Returns: {Promise} Fulfilled with `true` if an entry named `name` existed + and was removed, `false` otherwise. + +Rewrites the central directory without writing any new content - the +archive does not grow. Throws [`ERR_ZIP_NOT_WRITABLE`][] if the `ZipFile` was +not opened with `{ writable: true }`. + +### `zipFile.deleteSync(name)` + + + +* `name` {string} +* Returns: {boolean} `true` if an entry named `name` existed and was + removed, `false` otherwise. + +The synchronous version of [`zipFile.delete()`][]. + +### `zipFile.entries()` + + + +* Returns: {Iterator} of `[name, entry]` pairs, where `entry` is a + {Promise} fulfilled with a [`ZipEntry`][]. + +### `zipFile.entriesSync()` + + + +* Returns: {Iterator} of `[name, entry]` pairs, where `entry` is a resolved + [`ZipEntry`][] (not a `Promise`). + +The synchronous version of [`zipFile.entries()`][]. + +### `zipFile.forEach(callback[, thisArg])` + + + +* `callback` {Function} +* `thisArg` {any} + +### `zipFile.forEachSync(callback[, thisArg])` + + + +* `callback` {Function} +* `thisArg` {any} + +The synchronous version of [`zipFile.forEach()`][]: `callback` is invoked +with a resolved [`ZipEntry`][] instead of a `Promise`. + +### `zipFile.get(name)` + + + +* `name` {string} +* Returns: {Promise} Fulfilled with a {ZipEntry}. + +Returns a lazy, file-backed [`ZipEntry`][] for `name`. Nothing is read from +disk here and no content is buffered: the returned entry reads (and, for +[`zipEntry.content()`][], decompresses) its member straight from the file on +each access, and the `ZipFile` retains no member content. The entry is valid +only while this `ZipFile` is open. Reading its content later may throw +[`ERR_ZIP_ENTRY_TOO_LARGE`][] if the member is too large to hold in a single +buffer; use [`zipEntry.contentIterator()`][] (or [`zipFile.stream()`][]) +instead. Throws [`ERR_ZIP_ENTRY_NOT_FOUND`][] if the archive has no entry +named `name`. + +### `zipFile.getSync(name)` + + + +* `name` {string} +* Returns: {ZipEntry} + +The synchronous version of [`zipFile.get()`][]. Like `get()`, it reads +nothing up front and only builds the lazy handle, so it does not itself block +on I/O - but reads performed later through the returned entry (such as +[`zipEntry.contentSync()`][]) do; see the note above on synchronous methods. + +### `zipFile.has(name)` + + + +* `name` {string} +* Returns: {boolean} + +### `zipFile.keys()` + + + +* Returns: {Iterator} of entry names. + +### `zipFile.size` + + + +* Type: {number} + +The number of entries in the archive. + +### `zipFile.stream(name[, options])` + + + +* `name` {string} +* `options` {Object} + * `verify` {boolean} Verify the entry's CRC-32 checksum. **Default:** `true`. + * `maxSize` {number} Reject content declaring more than this many + uncompressed bytes. **Default:** no limit. +* Returns: {Promise} Fulfilled with a {stream.Readable} of the member's + decompressed content, without buffering the whole member in memory. + +Convenience wrapper that resolves to a `Readable` over +[`zipEntry.contentIterator()`][] of [`zipFile.get()`][]`(name)`; the +compressed bytes are read from disk as the stream is consumed. The returned +promise rejects with [`ERR_ZIP_ENTRY_NOT_FOUND`][] if the archive has no entry +named `name`. + +### `zipFile.values()` + + + +* Returns: {Iterator} of {Promise} objects, each fulfilled with a + [`ZipEntry`][]. + +### `zipFile.valuesSync()` + + + +* Returns: {Iterator} of resolved [`ZipEntry`][] values (not `Promise`s). + +The synchronous version of [`zipFile.values()`][]. + +### `zipFile.writable` + + + +* Type: {boolean} + +Whether this `ZipFile` was opened with `{ writable: true }`. + ## Class: `zlib.ZlibBase` + +> Stability: 1.0 - Early development + +The ZIP archive API is experimental. Using any part of it (this function among +them) emits an experimental warning the first time; merely importing +`node:zlib` does not. + +* `entries` {Iterable|AsyncIterable} of [`ZipEntry`][]. +* `options` {string|Object} An archive comment, as a shorthand for + `{ comment: options }`. + * `comment` {string} An archive comment. + * `baseOffset` {number} Shifts every local/central header offset the + archive records by this many bytes, so the emitted stream is + self-describing even when something else is written before it - for + example, appending the archive after `baseOffset` bytes already written to + the same file, rather than at its start. **Default:** `0`. +* Returns: {stream.Readable} A byte stream of the serialized archive. + +Serializes `entries` into a ZIP archive, switching to Zip64 structures +automatically once the entry count, or any offset or size, exceeds what the +classic 32-/16-bit ZIP fields can hold. The returned `Readable` is also an +`AsyncIterable` of the same {Buffer} chunks it streams. + +Entries are written in iteration order and nothing deduplicates names: an +iterable that yields two entries with the same name produces an archive +containing both, and most extraction tools keep the one that appears later. +[`ZipBuffer`][] and [`ZipFile`][] `add()` methods replace entries by name +instead. + +The entries are owned by the returned stream: each is consumed as the archive +is produced and must not be reused afterwards. This matters for streaming +entries (from [`zlib.ZipEntry.createStream()`][]), which hold an underlying +source such as a file read stream. If the returned stream is destroyed before +it is fully consumed - for example, the destination of a [`pipeline()`][] +fails - it disposes the entry it was serializing and every entry still queued +behind it, destroying their sources so no descriptor leaks. Consume the stream +to the end, or destroy it (directly, through a failed `pipeline()`, or with +`await using`), to guarantee this cleanup; a stream that is neither consumed +nor destroyed cannot release anything. A [`ZipEntry`][] that is never handed to +an archive can be released directly with `Symbol.dispose` / `Symbol.asyncDispose`. + +Throws an [`ERR_ZIP_ARCHIVE_TOO_LARGE`][] error if the archive comment +exceeds 65,535 bytes when encoded as UTF-8. + +```mjs +import { createWriteStream } from 'node:fs'; +import { pipeline } from 'node:stream/promises'; +import { Buffer } from 'node:buffer'; +import { ZipEntry, createZipArchive } from 'node:zlib'; + +const entries = [ + await ZipEntry.create('hello.txt', Buffer.from('Hello, world!')), + await ZipEntry.create('data/', Buffer.alloc(0)), +]; +await pipeline( + createZipArchive(entries, 'created by node:zlib'), + createWriteStream('archive.zip'), +); +``` + +```cjs +const { createWriteStream } = require('node:fs'); +const { pipeline } = require('node:stream/promises'); +const { ZipEntry, createZipArchive } = require('node:zlib'); + +async function main() { + const entries = [ + await ZipEntry.create('hello.txt', Buffer.from('Hello, world!')), + await ZipEntry.create('data/', Buffer.alloc(0)), + ]; + await pipeline( + createZipArchive(entries, 'created by node:zlib'), + createWriteStream('archive.zip'), + ); +} +main(); +``` + +Passing `options.baseOffset` produces an archive that is valid immediately +when placed after other content in the same file, without relying on a +reader's self-extracting-archive detection to compensate for the shift: + +```mjs +import { createWriteStream } from 'node:fs'; +import { Buffer } from 'node:buffer'; +import { ZipEntry, createZipArchive } from 'node:zlib'; + +const prefix = Buffer.from('#!/bin/sh\nexit 0\n'); +const entries = [await ZipEntry.create('hello.txt', Buffer.from('Hello, world!'))]; +const out = createWriteStream('self-extracting.zip'); +out.write(prefix); +createZipArchive(entries, { baseOffset: prefix.byteLength }).pipe(out); +``` + +## `zlib.createZipArchiveSync(entries[, options])` + + + +> Stability: 1.0 - Early development + +The ZIP archive API is experimental. Using any part of it (this function among +them) emits an experimental warning the first time; merely importing +`node:zlib` does not. + +* `entries` {Iterable} of [`ZipEntry`][]. +* `options` {string|Object} See [`zlib.createZipArchive()`][]. +* Returns: {Iterator} of {Buffer} chunks making up the serialized archive. + +The synchronous version of [`zlib.createZipArchive()`][]. Blocks the +Node.js event loop and further JavaScript execution until the whole +archive (including any deflate passes) has been produced; use only where +synchronous execution is appropriate (for example, short-lived scripts or +startup code), not in code that must stay responsive. `entries` must be a +plain (synchronous) `Iterable` - a streaming entry created with +[`zlib.ZipEntry.createStream()`][] throws when its turn to serialize comes +up, since draining its asynchronous source has no synchronous equivalent. + +As with [`zlib.createZipArchive()`][], the entries are owned by the returned +iterator and must not be reused. If iteration stops early - including the +throw on a streaming entry - the entry that stopped it and every entry still +queued behind it are disposed, releasing any sources they hold. + +## `zlib.zipFiles(files[, options])` + + + +> Stability: 1.0 - Early development + +The ZIP archive API is experimental. Using any part of it (this function among +them) emits an experimental warning the first time; merely importing +`node:zlib` does not. + +* `files` {Iterable} of `[sourcePath, entryName]` string pairs. Any iterable + works — an array, a `Map`, the result of `Object.entries()`, a generator. +* `options` {string|Object} + * `followSymlinks` {boolean} Resolve a symbolic link and archive the file it + points to, rather than storing the link itself. **Default:** `true`. + * `comment` {string} An archive comment; a string `options` is shorthand for + `{ comment: options }`. + * `baseOffset` {number} See [`zlib.createZipArchive()`][]. +* Returns: {stream.Readable} of {Buffer} chunks making up the serialized + archive. + +Builds an archive from files on disk. For each `[sourcePath, entryName]` pair +it reads `sourcePath` and adds an entry named `entryName`, capturing the file's +Unix mode and modification time. A directory becomes a directory entry; a +regular file's contents are streamed in (as a [`zlib.ZipEntry.createStream()`][] +entry) without being buffered in memory. Directory contents are not walked +recursively — list each path you want included. + +When `followSymlinks` is `true` (the default) a symbolic link is resolved and +archived as its target file; when it is `false` the link itself is stored as a +symbolic-link entry whose content is the target path (see +[`zlib.ZipEntry.createSymlink()`][]). + +```mjs +import { zipFiles } from 'node:zlib'; +import { createWriteStream } from 'node:fs'; +import { pipeline } from 'node:stream/promises'; + +await pipeline( + zipFiles([ + ['/data/report.pdf', 'report.pdf'], + ['/data/notes.txt', 'docs/notes.txt'], + ]), + createWriteStream('archive.zip'), +); +``` + ## `zlib.createZstdCompress([options])` > Stability: 1 - Experimental @@ -1363,6 +2639,44 @@ added: Creates and returns a new [`ZstdDecompress`][] object. +## `zlib.getMaxZipContentSize()` + + + +> Stability: 1.0 - Early development + +The ZIP archive API is experimental. Using any part of it (this function among +them) emits an experimental warning the first time; merely importing +`node:zlib` does not. + +* 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.0 - Early development + +The ZIP archive API is experimental. Using any part of it (this function among +them) emits an experimental warning the first time; merely importing +`node:zlib` does not. + +* `size` {number} + +Sets the default ceiling used by [`zipEntry.content()`][] when no explicit +`maxSize` option is given. This is a guard against zip bombs: an archive +whose central directory declares a member larger than this is rejected +before allocating memory for it. Streaming reads +([`zipEntry.contentIterator()`][], [`zipFile.stream()`][]) are bounded-memory +by design and are not affected by this setting. + ## Convenience methods @@ -2029,11 +3343,22 @@ Create a Zstandard decompression transform. [`Content-Encoding`]: https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.11 [`DeflateRaw`]: #class-zlibdeflateraw [`Deflate`]: #class-zlibdeflate +[`ERR_INVALID_STATE`]: errors.md#err_invalid_state +[`ERR_ZIP_ARCHIVE_TOO_LARGE`]: errors.md#err_zip_archive_too_large +[`ERR_ZIP_ENTRY_CORRUPT`]: errors.md#err_zip_entry_corrupt +[`ERR_ZIP_ENTRY_NOT_FOUND`]: errors.md#err_zip_entry_not_found +[`ERR_ZIP_ENTRY_TOO_LARGE`]: errors.md#err_zip_entry_too_large +[`ERR_ZIP_INVALID_ARCHIVE`]: errors.md#err_zip_invalid_archive +[`ERR_ZIP_NOT_WRITABLE`]: errors.md#err_zip_not_writable +[`ERR_ZIP_UNSUPPORTED_FEATURE`]: errors.md#err_zip_unsupported_feature [`Gunzip`]: #class-zlibgunzip [`Gzip`]: #class-zlibgzip [`InflateRaw`]: #class-zlibinflateraw [`Inflate`]: #class-zlibinflate [`Unzip`]: #class-zlibunzip +[`ZipBuffer`]: #class-zlibzipbuffer +[`ZipEntry`]: #class-zlibzipentry +[`ZipFile`]: #class-zlibzipfile [`ZlibBase`]: #class-zlibzlibbase [`ZstdCompress`]: #class-zlibzstdcompress [`ZstdDecompress`]: #class-zlibzstddecompress @@ -2041,8 +3366,43 @@ Create a Zstandard decompression transform. [`deflateInit2` and `inflateInit2`]: https://zlib.net/manual.html#Advanced [`node:stream/iter`]: stream_iter.md [`pipeTo()`]: stream_iter.md#pipetosource-transforms-writer-options +[`pipeline()`]: stream.md#streampipelinesource-transforms-destination-callback [`pull()`]: stream_iter.md#pullsource-transforms-options [`stream.Transform`]: stream.md#class-streamtransform +[`zipBuffer.add()`]: #zipbufferaddfilename-data-options +[`zipBuffer.addSync()`]: #zipbufferaddsyncfilename-data-options +[`zipBuffer.comment`]: #zipbuffercomment +[`zipBuffer.toBuffer()`]: #zipbuffertobufferoptions +[`zipBuffer.toBufferSync()`]: #zipbuffertobuffersyncoptions +[`zipEntry.content()`]: #zipentrycontentoptions +[`zipEntry.contentIterator()`]: #zipentrycontentiteratoroptions +[`zipEntry.contentSync()`]: #zipentrycontentsyncoptions +[`zipEntry.isDirectory`]: #zipentryisdirectory +[`zipEntry.isSymlink`]: #zipentryissymlink +[`zipEntry.modified`]: #zipentrymodified +[`zipEntry.nameBuffer`]: #zipentrynamebuffer +[`zipEntry.name`]: #zipentryname +[`zipEntry.rawContent`]: #zipentryrawcontent +[`zipFile.add()`]: #zipfileaddfilename-data-options +[`zipFile.addEntry()`]: #zipfileaddentryentry +[`zipFile.close()`]: #zipfileclose +[`zipFile.closeSync()`]: #zipfileclosesync +[`zipFile.comment`]: #zipfilecomment +[`zipFile.compact()`]: #zipfilecompactcomment +[`zipFile.delete()`]: #zipfiledeletename +[`zipFile.entries()`]: #zipfileentries +[`zipFile.forEach()`]: #zipfileforeachcallback-thisarg +[`zipFile.get()`]: #zipfilegetname +[`zipFile.stream()`]: #zipfilestreamname-options +[`zipFile.values()`]: #zipfilevalues +[`zlib.ZipEntry.create()`]: #static-method-zlibzipentrycreatefilename-data-options +[`zlib.ZipEntry.createStream()`]: #static-method-zlibzipentrycreatestreamfilename-source-options +[`zlib.ZipEntry.createSymlink()`]: #static-method-zlibzipentrycreatesymlinkfilename-target-options +[`zlib.ZipEntry.createSync()`]: #static-method-zlibzipentrycreatesyncfilename-data-options +[`zlib.ZipFile.open()`]: #static-method-zlibzipfileopenfilename-options +[`zlib.createZipArchive()`]: #zlibcreateziparchiveentries-options +[`zlib.createZipArchiveSync()`]: #zlibcreateziparchivesyncentries-options +[`zlib.getMaxZipContentSize()`]: #zlibgetmaxzipcontentsize [convenience methods]: #convenience-methods [zlib documentation]: https://zlib.net/manual.html#Constants [zlib.createGzip example]: #zlib diff --git a/doc/contributing/writing-tests.md b/doc/contributing/writing-tests.md index 3f439d6d70b5..f9ca3c772017 100644 --- a/doc/contributing/writing-tests.md +++ b/doc/contributing/writing-tests.md @@ -430,19 +430,21 @@ static void at_exit_callback(void* arg) { } ``` -Next add the test to the `sources` in the `cctest` target in node.gyp: +There is no need to list the file anywhere: `configure.py` collects every `.cc` +and `.h` file under `test/cctest` into the `node_cctest_sources` variable that +the `cctest` target in node.gyp builds. -```console -'sources': [ - 'test/cctest/test_env.cc', - ... -], -``` +If the test can only be built when a given feature is enabled, add it to the +matching variable in node.gyp so that it is excluded from the build otherwise: + +* `node_cctest_openssl_sources` for tests that require crypto support +* `node_cctest_quic_sources` for tests that require QUIC support +* `node_cctest_inspector_sources` for tests that require the inspector -The only sources that should be included in the cctest target are -actual test or helper source files. There might be a need to include specific -object files that are compiled by the `node` target and this can be done by -adding them to the `libraries` section in the cctest target. +The only sources that should be placed in `test/cctest` are actual test or +helper source files. There might be a need to include specific object files +that are compiled by the `node` target and this can be done by adding them to +the `libraries` section in the cctest target. The test can be executed by running the `cctest` target: diff --git a/doc/node.1 b/doc/node.1 index 498e10137abf..05b80da80a43 100644 --- a/doc/node.1 +++ b/doc/node.1 @@ -1411,6 +1411,13 @@ This option may be specified multiple times to include multiple glob patterns. If both \fB--test-coverage-exclude\fR and \fB--test-coverage-include\fR are provided, files must meet \fBboth\fR criteria to be included in the coverage report. . +.It Fl -test-coverage-include-all +Includes source files that were never loaded by the test run in the coverage +report, where they are reported as having zero coverage. +Candidate files are searched for in the current working directory, and are +subject to the same \fB--test-coverage-include\fR and \fB--test-coverage-exclude\fR +filtering as the rest of the report. +. .It Fl -test-coverage-lines Ns = Ns Ar threshold Require a minimum percent of covered lines. If code coverage does not reach the threshold specified, the process will exit with code \fB1\fR. @@ -2136,6 +2143,8 @@ one is included in the list below. .It \fB--test-coverage-functions\fR .It +\fB--test-coverage-include-all\fR +.It \fB--test-coverage-include\fR .It \fB--test-coverage-lines\fR diff --git a/doc/type-map.json b/doc/type-map.json index c4a411139982..c10fff5c404f 100644 --- a/doc/type-map.json +++ b/doc/type-map.json @@ -130,6 +130,9 @@ "WritableStreamDefaultController": "webstreams.html#class-writablestreamdefaultcontroller", "WritableStreamDefaultWriter": "webstreams.html#class-writablestreamdefaultwriter", "X509Certificate": "crypto.html#class-x509certificate", + "ZipBuffer": "zlib.html#class-zlibzipbuffer", + "ZipEntry": "zlib.html#class-zlibzipentry", + "ZipFile": "zlib.html#class-zlibzipfile", "zlib options": "zlib.html#class-options", "zstd options": "zlib.html#class-zstdoptions" } diff --git a/lib/ffi.js b/lib/ffi.js index f35ebae3e312..01c330953ac8 100644 --- a/lib/ffi.js +++ b/lib/ffi.js @@ -68,7 +68,6 @@ const { } = require('internal/ffi-shared-buffer'); const { - initializeFastBufferMetadata, wrapWithRawPointerConversions, } = require('internal/ffi/fast-api'); @@ -90,7 +89,6 @@ function wrapFFIFunction(rawFn, owner) { returnType = rawFn[kSbReturn]; } } - initializeFastBufferMetadata(rawFn, argumentTypes); const wrapped = wrapWithSharedBuffer( rawFn, argumentTypes === undefined ? undefined : makeSignature(argumentTypes, returnType)); diff --git a/lib/internal/dtls/dtls.js b/lib/internal/dtls/dtls.js index c4aab52e6e76..e6017e3e8761 100644 --- a/lib/internal/dtls/dtls.js +++ b/lib/internal/dtls/dtls.js @@ -7,6 +7,7 @@ const { ArrayIsArray, FunctionPrototypeBind, + PromisePrototypeThen, PromiseWithResolvers, SafeSet, SymbolAsyncDispose, @@ -41,6 +42,10 @@ const { Buffer, } = require('buffer'); +const { + isIP, +} = require('internal/net'); + const { DTLSEndpointState, DTLSSessionState, @@ -102,6 +107,12 @@ class DTLSSession { kPrivateConstructor, handle.getStats()); this.#pendingOpen = PromiseWithResolvers(); this.#pendingClose = PromiseWithResolvers(); + // opened/closed may reject (handshake error, destroy(error)). Attach a + // no-op rejection handler so a caller that uses the callback API and never + // awaits them does not trigger an unhandled rejection; an explicit + // await/then/catch on opened/closed still observes the rejection. + PromisePrototypeThen(this.#pendingOpen.promise, undefined, () => {}); + PromisePrototypeThen(this.#pendingClose.promise, undefined, () => {}); } // --- Callback setters --- @@ -261,6 +272,22 @@ class DTLSSession { this.#onerror(error); } this.#pendingOpen.reject(error); + + // The session has failed and cannot continue. Tear it down so it does not + // linger in the endpoint's table, and -- for a client session that owns + // its internal endpoint -- close the endpoint too so the event loop can + // drain. destroy() removes the session from the C++ table first, so the + // endpoint.close() below won't try to re-close it. Reentrant destroy from + // within the error emit is safe: Cycle()/the timer hold a strong ref. + const endpoint = this.#endpoint; + const ownsEndpoint = this.#ownsEndpoint; + this.destroy(); + if (endpoint) { + endpoint.sessions.delete(this); + if (ownsEndpoint) { + endpoint.close(); + } + } } [kSessionClose]() { @@ -314,6 +341,9 @@ class DTLSEndpoint { this.#stats = new DTLSEndpointStats( kPrivateConstructor, this.#handle.getStats()); this.#pendingClose = PromiseWithResolvers(); + // See DTLSSession: keep an unobserved closed rejection from surfacing as an + // unhandled rejection. + PromisePrototypeThen(this.#pendingClose.promise, undefined, () => {}); if (options.mtu !== undefined) { validateInteger(options.mtu, 'options.mtu', 256, 65535); @@ -359,10 +389,24 @@ class DTLSEndpoint { // --- Client mode --- connect(context, host, port, servername) { - const sessionHandle = this.#handle.connect(context, host, port); - if (servername) { - sessionHandle.setServername(servername); + // Resolve SNI and the expected peer identity here so that every caller of + // the endpoint API -- not only the top-level dtls.connect() -- gets safe + // defaults. The identity is always bound to the requested servername (or, + // failing that, the host). OpenSSL only *enforces* it when the context is + // in a verifying mode, so binding it is a no-op for non-verifying + // (rejectUnauthorized: false) contexts. + // + // These are applied to the client SSL inside the binding, before the + // handshake's ClientHello is emitted; they cannot be set afterwards. + let sni = servername !== undefined ? (servername || undefined) : host; + if (sni !== undefined && isIP(sni) !== 0) { + sni = undefined; // SNI is never sent for IP literals (matching TLS). } + const verifyHost = servername || host; + const verifyIsIp = isIP(verifyHost) !== 0; + + const sessionHandle = this.#handle.connect( + context, host, port, sni, verifyHost, verifyIsIp); const session = new DTLSSession( kPrivateConstructor, sessionHandle, this); this.#sessions.add(session); @@ -604,7 +648,12 @@ function listen(onsession, options = kEmptyObject) { * @param {string|Buffer|Array} [options.ca] CA certificates (PEM). * @param {string|Buffer} [options.cert] Client certificate (PEM). * @param {string|Buffer} [options.key] Client private key (PEM). - * @param {boolean} [options.rejectUnauthorized] Reject unauthorized. + * @param {boolean} [options.rejectUnauthorized] When true (default), verify + * the server certificate against the trusted CAs and check its identity + * against servername (or host); aborts the handshake on failure. + * @param {string} [options.servername] Server name for the SNI extension and + * the identity checked during certificate verification. Defaults to host; + * set to '' to disable SNI. Never sent for IP address literals. * @param {string} [options.bindHost] Local bind address. * @param {number} [options.bindPort] Local bind port (0 = ephemeral). * @param {number} [options.mtu] MTU for DTLS records. @@ -632,13 +681,11 @@ function connect(host, port, options = kEmptyObject) { endpoint.bind(bindHost, bindPort); - // Default SNI servername to the host argument (matching Node.js TLS). - // Can be overridden with options.servername, or disabled with '' or false. - const servername = options.servername !== undefined ? - (options.servername || undefined) : - host; - - const session = endpoint.connect(context, host, port, servername); + // SNI and peer-identity verification are resolved inside + // DTLSEndpoint.connect(), which defaults both to the host argument (matching + // Node.js TLS). The identity is enforced whenever the context verifies, i.e. + // unless rejectUnauthorized is false. + const session = endpoint.connect(context, host, port, options.servername); // Mark that this session owns the endpoint so it gets closed // automatically when the session closes, allowing process exit. session.ownsEndpoint = true; diff --git a/lib/internal/errors.js b/lib/internal/errors.js index 1b5487849169..93498488ce56 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -2004,4 +2004,11 @@ E('ERR_WORKER_UNSERIALIZABLE_ERROR', 'Serializing an uncaught exception failed', Error); E('ERR_WORKER_UNSUPPORTED_OPERATION', '%s is not supported in workers', TypeError); +E('ERR_ZIP_ARCHIVE_TOO_LARGE', 'ZIP archive structure exceeds the allowed size: %s', RangeError); +E('ERR_ZIP_ENTRY_CORRUPT', 'ZIP entry is corrupt: %s', Error); +E('ERR_ZIP_ENTRY_NOT_FOUND', 'no such entry %j in the archive', Error); +E('ERR_ZIP_ENTRY_TOO_LARGE', 'ZIP entry exceeds the allowed size: %s', RangeError); +E('ERR_ZIP_INVALID_ARCHIVE', 'invalid ZIP archive: %s', Error); +E('ERR_ZIP_NOT_WRITABLE', 'this archive was not opened for writing', TypeError); +E('ERR_ZIP_UNSUPPORTED_FEATURE', 'unsupported ZIP feature: %s', Error); E('ERR_ZSTD_INVALID_PARAM', '%s is not a valid zstd parameter', RangeError); diff --git a/lib/internal/ffi/fast-api.js b/lib/internal/ffi/fast-api.js index c897f1baf9e6..ebfaed92b27d 100644 --- a/lib/internal/ffi/fast-api.js +++ b/lib/internal/ffi/fast-api.js @@ -6,7 +6,6 @@ const { ObjectDefineProperty, ReflectApply, StringPrototypeIncludes, - Symbol, TypeError, } = primordials; @@ -24,11 +23,8 @@ const { getRawPointer, kFastArguments, kFastBufferInvoke, - kSbSharedBuffer, } = internalBinding('ffi'); -const kFastBuffer = Symbol('kFastBuffer'); - const U64_MAX = 0xFFFFFFFFFFFFFFFFn; const I64_MAX = 0x7FFFFFFFFFFFFFFFn; const I64_MIN = -0x8000000000000000n; @@ -50,6 +46,10 @@ const fastIntegerTypeInfo = { int16: { kind: 'number', min: -32768, max: 32767, label: 'an int16' }, u16: { kind: 'number', min: 0, max: 65535, label: 'a uint16' }, uint16: { kind: 'number', min: 0, max: 65535, label: 'a uint16' }, + i32: { kind: 'number', min: -2147483648, max: 2147483647, label: 'an int32' }, + int32: { kind: 'number', min: -2147483648, max: 2147483647, label: 'an int32' }, + u32: { kind: 'number', min: 0, max: 4294967295, label: 'a uint32' }, + uint32: { kind: 'number', min: 0, max: 4294967295, label: 'a uint32' }, i64: { kind: 'bigint', min: I64_MIN, max: I64_MAX, label: 'an int64' }, int64: { kind: 'bigint', min: I64_MIN, max: I64_MAX, label: 'an int64' }, u64: { kind: 'bigint', min: 0n, max: U64_MAX, label: 'a uint64' }, @@ -79,16 +79,13 @@ function validateFastIntegerArg(type, value, index) { } } -function needsRawPointerConversion(type, rawFn) { - if (rawFn !== undefined && rawFn[kFastBuffer] === true && - (type === 'buffer' || type === 'arraybuffer')) { - return false; - } +function needsRawPointerConversion(type) { return type === 'buffer' || type === 'arraybuffer'; } function needsPointerLikeConversion(type) { - return type === 'pointer' || type === 'ptr' || type === 'function'; + return type === 'pointer' || type === 'ptr' || type === 'function' || + type === 'buffer' || type === 'arraybuffer'; } function needsStringPointerConversion(type) { @@ -100,12 +97,8 @@ function needsNullPointerConversion(type) { needsRawPointerConversion(type); } -function needsPointerConversion(type, rawFn) { - if (rawFn !== undefined && rawFn[kFastBuffer] === true && - (type === 'buffer' || type === 'arraybuffer')) { - return false; - } - return needsRawPointerConversion(type, rawFn) || +function needsPointerConversion(type) { + return needsRawPointerConversion(type) || needsNullPointerConversion(type) || needsStringPointerConversion(type); } @@ -174,11 +167,11 @@ function convertPointerArg(type, value, stringState, index) { return value; } -function getFastArgumentIndexes(argumentsTypes, rawFn) { +function getFastArgumentIndexes(argumentsTypes) { let indexes = null; for (let i = 0; i < argumentsTypes.length; i++) { if (fastIntegerTypeInfo[argumentsTypes[i]] === undefined && - !needsPointerConversion(argumentsTypes[i], rawFn)) { + !needsPointerConversion(argumentsTypes[i])) { continue; } if (indexes === null) { @@ -189,31 +182,12 @@ function getFastArgumentIndexes(argumentsTypes, rawFn) { return indexes; } -function convertFastArg(type, value, rawFn, stringState, index) { +function convertFastArg(type, value, stringState, index) { validateFastIntegerArg(type, value, index); - return needsPointerConversion(type, rawFn) ? + return needsPointerConversion(type) ? convertPointerArg(type, value, stringState, index) : value; } -function initializeFastBufferMetadata(rawFn, argumentTypes) { - if (rawFn === undefined || rawFn === null || argumentTypes === undefined) { - return; - } - if (rawFn[kSbSharedBuffer] !== undefined) { - return; - } - - if (rawFn[kFastArguments] !== undefined) { - for (let i = 0; i < argumentTypes.length; i++) { - const type = argumentTypes[i]; - if (type === 'buffer' || type === 'arraybuffer') { - rawFn[kFastBuffer] = true; - break; - } - } - } -} - function inheritMetadata(wrapper, rawFn, nargs) { ObjectDefineProperty(wrapper, 'name', { __proto__: null, value: rawFn.name, configurable: true, @@ -239,7 +213,7 @@ function wrapWithRawPointerConversions(rawFn, argumentTypes, _owner) { return rawFn; } - const indexes = getFastArgumentIndexes(argumentTypes, rawFn); + const indexes = getFastArgumentIndexes(argumentTypes); if (indexes === null) { return rawFn; } @@ -295,9 +269,8 @@ function wrapWithRawPointerConversions(rawFn, argumentTypes, _owner) { (c1 && hasStringPointerArg(t1, a1)); if (stringCall) enterStringConversion(stringState); try { - return rawFn(c0 ? - convertFastArg(t0, a0, rawFn, stringState, 0) : a0, - c1 ? convertFastArg(t1, a1, rawFn, stringState, 1) : a1); + return rawFn(c0 ? convertFastArg(t0, a0, stringState, 0) : a0, + c1 ? convertFastArg(t1, a1, stringState, 1) : a1); } finally { if (stringCall) exitStringConversion(stringState); } @@ -318,10 +291,9 @@ function wrapWithRawPointerConversions(rawFn, argumentTypes, _owner) { (c2 && hasStringPointerArg(t2, a2)); if (stringCall) enterStringConversion(stringState); try { - return rawFn(c0 ? - convertFastArg(t0, a0, rawFn, stringState, 0) : a0, - c1 ? convertFastArg(t1, a1, rawFn, stringState, 1) : a1, - c2 ? convertFastArg(t2, a2, rawFn, stringState, 2) : a2); + return rawFn(c0 ? convertFastArg(t0, a0, stringState, 0) : a0, + c1 ? convertFastArg(t1, a1, stringState, 1) : a1, + c2 ? convertFastArg(t2, a2, stringState, 2) : a2); } finally { if (stringCall) exitStringConversion(stringState); } @@ -344,7 +316,7 @@ function wrapWithRawPointerConversions(rawFn, argumentTypes, _owner) { for (let i = 0; i < indexes.length; i++) { const index = indexes[i]; args[index] = convertFastArg( - argumentTypes[index], args[index], rawFn, stringState, index); + argumentTypes[index], args[index], stringState, index); } return ReflectApply(rawFn, undefined, args); } finally { @@ -360,6 +332,5 @@ module.exports = { convertPointerArg, hasPointerMemoryArg, hasStringPointerArg, - initializeFastBufferMetadata, wrapWithRawPointerConversions, }; diff --git a/lib/internal/fs/glob.js b/lib/internal/fs/glob.js index a0e7837db7c3..c608016833f9 100644 --- a/lib/internal/fs/glob.js +++ b/lib/internal/fs/glob.js @@ -936,15 +936,16 @@ function matchGlobPattern(path, pattern, windows = isWindows) { validateString(path, 'path'); validateString(pattern, 'pattern'); + const cacheKey = `${windows ? 'win32' : 'posix'}:${pattern}`; let matcher; - if (patternFnCache.has(pattern)) { - matcher = patternFnCache.get(pattern); + if (patternFnCache.has(cacheKey)) { + matcher = patternFnCache.get(cacheKey); } else { matcher = createMatcher(pattern, { kEmptyObject, platform: windows ? 'win32' : 'posix', }); - patternFnCache.set(pattern, matcher); + patternFnCache.set(cacheKey, matcher); if (patternFnCache.size >= kGlobPatternCacheLimit) { patternFnCache.delete(patternFnCache.keys().next().value); diff --git a/lib/internal/quic/diagnostics.js b/lib/internal/quic/diagnostics.js index 7180f719bc09..10dac5693fba 100644 --- a/lib/internal/quic/diagnostics.js +++ b/lib/internal/quic/diagnostics.js @@ -1,6 +1,6 @@ 'use strict'; -// TODO(@jasnell) Temporarily ignoring c8 covrerage for this file while tests +// TODO(@jasnell) Temporarily ignoring c8 coverage for this file while tests // are still being developed. /* c8 ignore start */ diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index 386967bd9a9c..7fd22b26586f 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -1,6 +1,6 @@ 'use strict'; -// TODO(@jasnell) Temporarily ignoring c8 covrerage for this file while tests +// TODO(@jasnell) Temporarily ignoring c8 coverage for this file while tests // are still being developed. /* c8 ignore start */ diff --git a/lib/internal/quic/state.js b/lib/internal/quic/state.js index 02cfadd35d58..0f3a868e54b4 100644 --- a/lib/internal/quic/state.js +++ b/lib/internal/quic/state.js @@ -1,6 +1,6 @@ 'use strict'; -// TODO(@jasnell) Temporarily ignoring c8 covrerage for this file while tests +// TODO(@jasnell) Temporarily ignoring c8 coverage for this file while tests // are still being developed. /* c8 ignore start */ diff --git a/lib/internal/quic/stats.js b/lib/internal/quic/stats.js index e7e8e7ee2719..f2fefbcb74cd 100644 --- a/lib/internal/quic/stats.js +++ b/lib/internal/quic/stats.js @@ -1,6 +1,6 @@ 'use strict'; -// TODO(@jasnell) Temporarily ignoring c8 covrerage for this file while tests +// TODO(@jasnell) Temporarily ignoring c8 coverage for this file while tests // are still being developed. /* c8 ignore start */ diff --git a/lib/internal/quic/symbols.js b/lib/internal/quic/symbols.js index 665ab7ca1911..1dabb928af3c 100644 --- a/lib/internal/quic/symbols.js +++ b/lib/internal/quic/symbols.js @@ -1,6 +1,6 @@ 'use strict'; -// TODO(@jasnell) Temporarily ignoring c8 covrerage for this file while tests +// TODO(@jasnell) Temporarily ignoring c8 coverage for this file while tests // are still being developed. /* c8 ignore start */ diff --git a/lib/internal/streams/iter/consumers.js b/lib/internal/streams/iter/consumers.js index 3894b3fa24a8..e3bd7856dcd4 100644 --- a/lib/internal/streams/iter/consumers.js +++ b/lib/internal/streams/iter/consumers.js @@ -28,7 +28,6 @@ const { const { codes: { - ERR_INVALID_ARG_TYPE, ERR_INVALID_ARG_VALUE, ERR_OUT_OF_RANGE, }, @@ -39,6 +38,7 @@ const { validateFunction, validateInteger, validateObject, + validateString, } = require('internal/validators'); const { @@ -196,10 +196,7 @@ function validateBaseConsumerOptions(options) { validateInteger(options.limit, 'options.limit', 0); } if (options.encoding !== undefined) { - if (typeof options.encoding !== 'string') { - throw new ERR_INVALID_ARG_TYPE('options.encoding', 'string', - options.encoding); - } + validateString(options.encoding, 'options.encoding'); try { new TextDecoder(options.encoding); } catch { diff --git a/lib/internal/test_runner/coverage.js b/lib/internal/test_runner/coverage.js index cdbece1eeae7..55151d64563f 100644 --- a/lib/internal/test_runner/coverage.js +++ b/lib/internal/test_runner/coverage.js @@ -20,6 +20,7 @@ const { } = primordials; const { copyFileSync, + globSync, mkdirSync, mkdtempSync, opendirSync, @@ -29,7 +30,7 @@ const { const { setupCoverageHooks } = require('internal/util'); const { tmpdir } = require('os'); const { join, resolve, relative } = require('path'); -const { fileURLToPath, URL } = require('internal/url'); +const { fileURLToPath, pathToFileURL, URL } = require('internal/url'); const { kMappings, SourceMap } = require('internal/source_map/source_map'); const { codes: { @@ -48,6 +49,7 @@ const kLineSplitRegex = /(?<=\r?\n)/u; const kStatusRegex = /\/\* node:coverage (?enable|disable) \*\//; const kTypeOnlyImportRegex = /^\s*import\s+type\b/u; const kTypeScriptSourceRegex = /\.(?:cts|mts|ts)$/u; +const kSourceFileGlob = '**/*.{cjs,cts,js,mjs,mts,ts}'; let stripTypeScriptTypesForCoverage; @@ -407,6 +409,10 @@ class TestCoverage { this.mergeCoverage(result, this.mapCoverageWithSourceMap(coverage)); } + if (this.options.coverageIncludeAll) { + this.#addUntestedFileCoverage(result); + } + return ArrayFrom(result.values()); } finally { if (dir) { @@ -415,6 +421,48 @@ class TestCoverage { } } + #addUntestedFileCoverage(merged) { + const files = globSync(kSourceFileGlob, { + __proto__: null, + cwd: this.options.cwd, + // Skip node_modules/, since `shouldSkipFileCoverage` would skip it anyway + exclude: (name) => name === 'node_modules', + }); + + for (let i = 0; i < files.length; ++i) { + const url = pathToFileURL(resolve(this.options.cwd, files[i])).href; + + if (merged.has(url) || this.shouldSkipFileCoverage(url)) { + continue; + } + + this.markTypeScriptOnlyLines(url); + const lines = this.getLines(url); + + if (!lines || lines.length === 0) { + continue; + } + + const lastLine = lines[lines.length - 1]; + + merged.set(url, { + __proto__: null, + url, + functions: [{ + __proto__: null, + functionName: '', + isBlockCoverage: false, + ranges: [{ + __proto__: null, + startOffset: 0, + endOffset: lastLine.startOffset + lastLine.src.length, + count: 0, + }], + }], + }); + } + } + mapCoverageWithSourceMap(coverage) { const { result } = coverage; diff --git a/lib/internal/test_runner/runner.js b/lib/internal/test_runner/runner.js index 3bfe719ce5be..4bfce346a592 100644 --- a/lib/internal/test_runner/runner.js +++ b/lib/internal/test_runner/runner.js @@ -723,6 +723,7 @@ function run(options = kEmptyObject) { only, globPatterns, coverage = false, + coverageIncludeAll = false, lineCoverage = 0, branchCoverage = 0, functionCoverage = 0, @@ -882,6 +883,7 @@ function run(options = kEmptyObject) { validateOneOf(isolation, 'options.isolation', ['process', 'none']); validateBoolean(coverage, 'options.coverage'); + validateBoolean(coverageIncludeAll, 'options.coverageIncludeAll'); if (coverageExcludeGlobs != null) { if (!ArrayIsArray(coverageExcludeGlobs)) { coverageExcludeGlobs = [coverageExcludeGlobs]; @@ -923,6 +925,7 @@ function run(options = kEmptyObject) { ...parseCommandLine(), setup, // This line can be removed when parseCommandLine() is removed here. coverage, + coverageIncludeAll, coverageExcludeGlobs, coverageIncludeGlobs, rerunFailuresFilePath, diff --git a/lib/internal/test_runner/utils.js b/lib/internal/test_runner/utils.js index 9937d9592a29..3590d3ef79b4 100644 --- a/lib/internal/test_runner/utils.js +++ b/lib/internal/test_runner/utils.js @@ -245,6 +245,7 @@ function parseCommandLine() { const isTestRunner = getOptionValue('--test'); const coverage = getOptionValue('--experimental-test-coverage'); + const coverageIncludeAll = getOptionValue('--test-coverage-include-all'); const forceExit = getOptionValue('--test-force-exit'); const sourceMaps = getOptionValue('--enable-source-maps'); const updateSnapshots = getOptionValue('--test-update-snapshots'); @@ -415,6 +416,7 @@ function parseCommandLine() { isTestRunner, concurrency, coverage, + coverageIncludeAll, coverageExcludeGlobs, coverageIncludeGlobs, destinations, diff --git a/lib/internal/vfs/providers/archive.js b/lib/internal/vfs/providers/archive.js new file mode 100644 index 000000000000..4cc65f02836f --- /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 ZipFileHandle 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 ZipProvider 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 ZipFileHandle(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 ZipFileHandle(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 = { + ZipProvider, +}; diff --git a/lib/internal/webstreams/readablestream.js b/lib/internal/webstreams/readablestream.js index 9b6dc4b4ad21..b25166957614 100644 --- a/lib/internal/webstreams/readablestream.js +++ b/lib/internal/webstreams/readablestream.js @@ -7,7 +7,6 @@ const { ArrayBufferPrototypeSlice, ArrayBufferPrototypeTransfer, ArrayPrototypePush, - ArrayPrototypeShift, DataView, FunctionPrototypeBind, FunctionPrototypeCall, @@ -1258,7 +1257,7 @@ class ReadableByteStreamController { byteOffset, bytesFilled, byteLength, - } = this[kState].pendingPullIntos[0]; + } = this[kState].pendingPullIntos.peek(); const view = new Uint8Array( buffer, @@ -1341,9 +1340,11 @@ class ReadableByteStreamController { pendingPullIntos, } = this[kState]; if (pendingPullIntos.length > 0) { - const firstPendingPullInto = pendingPullIntos[0]; + const firstPendingPullInto = pendingPullIntos.peek(); firstPendingPullInto.type = 'none'; - this[kState].pendingPullIntos = [firstPendingPullInto]; + const queue = new Queue(); + queue.push(firstPendingPullInto); + this[kState].pendingPullIntos = queue; } } @@ -1804,29 +1805,42 @@ function readableStreamDefaultTee(stream, cloneForBranch2) { let branch2; const cancelPromise = PromiseWithResolvers(); + // At most one read is ever in flight (`reading` guards pullAlgorithm), + // so one read request object and one forwarding microtask function are + // reused for every chunk; the chunk travels through `pendingChunk`. + // The request is materialized lazily on the first pull so that tee() + // itself stays allocation-light. + let pendingChunk; + let readRequest; + function forwardChunk() { + reading = false; + const value1 = pendingChunk; + let value2 = pendingChunk; + pendingChunk = undefined; + if (!canceled2 && cloneForBranch2) { + value2 = structuredClone(value2); + } + if (!canceled1) { + readableStreamDefaultControllerEnqueue( + branch1[kState].controller, + value1); + } + if (!canceled2) { + readableStreamDefaultControllerEnqueue( + branch2[kState].controller, + value2); + } + } + async function pullAlgorithm() { if (reading) return; reading = true; - const readRequest = { + readRequest ??= { [kChunk](value) { - queueMicrotask(() => { - reading = false; - const value1 = value; - let value2 = value; - if (!canceled2 && cloneForBranch2) { - value2 = structuredClone(value2); - } - if (!canceled1) { - readableStreamDefaultControllerEnqueue( - branch1[kState].controller, - value1); - } - if (!canceled2) { - readableStreamDefaultControllerEnqueue( - branch2[kState].controller, - value2); - } - }); + // The microtask is required by the spec (ReadableStreamTee's + // "chunk steps" queue one). + pendingChunk = value; + queueMicrotask(forwardChunk); }, [kClose]() { // The `process.nextTick()` is not part of the spec. @@ -1920,6 +1934,55 @@ function readableByteStreamTee(stream) { ); } + // As in readableStreamDefaultTee, only one read is ever in flight, so + // the default-reader read request and its forwarding microtask are + // shared across all chunks. + let pendingChunk; + function forwardChunk() { + readAgainForBranch1 = false; + readAgainForBranch2 = false; + const chunk1 = pendingChunk; + let chunk2 = pendingChunk; + pendingChunk = undefined; + + if (!canceled1 && !canceled2) { + try { + chunk2 = cloneAsUint8Array(chunk1); + } catch (error) { + readableByteStreamControllerError( + branch1[kState].controller, + error, + ); + readableByteStreamControllerError( + branch2[kState].controller, + error, + ); + cancelDeferred.resolve(readableStreamCancel(stream, error)); + return; + } + } + if (!canceled1) { + readableByteStreamControllerEnqueue( + branch1[kState].controller, + chunk1, + ); + } + if (!canceled2) { + readableByteStreamControllerEnqueue( + branch2[kState].controller, + chunk2, + ); + } + reading = false; + + if (readAgainForBranch1) { + pull1Algorithm(); + } else if (readAgainForBranch2) { + pull2Algorithm(); + } + } + + let defaultReadRequest; function pullWithDefaultReader() { if (isReadableStreamBYOBReader(reader)) { readableStreamBYOBReaderRelease(reader); @@ -1927,50 +1990,10 @@ function readableByteStreamTee(stream) { forwardReaderError(reader); } - const readRequest = { + defaultReadRequest ??= { [kChunk](chunk) { - queueMicrotask(() => { - readAgainForBranch1 = false; - readAgainForBranch2 = false; - const chunk1 = chunk; - let chunk2 = chunk; - - if (!canceled1 && !canceled2) { - try { - chunk2 = cloneAsUint8Array(chunk); - } catch (error) { - readableByteStreamControllerError( - branch1[kState].controller, - error, - ); - readableByteStreamControllerError( - branch2[kState].controller, - error, - ); - cancelDeferred.resolve(readableStreamCancel(stream, error)); - return; - } - } - if (!canceled1) { - readableByteStreamControllerEnqueue( - branch1[kState].controller, - chunk1, - ); - } - if (!canceled2) { - readableByteStreamControllerEnqueue( - branch2[kState].controller, - chunk2, - ); - } - reading = false; - - if (readAgainForBranch1) { - pull1Algorithm(); - } else if (readAgainForBranch2) { - pull2Algorithm(); - } - }); + pendingChunk = chunk; + queueMicrotask(forwardChunk); }, [kClose]() { reading = false; @@ -1995,8 +2018,7 @@ function readableByteStreamTee(stream) { reading = false; }, }; - - readableStreamDefaultReaderRead(reader, readRequest); + readableStreamDefaultReaderRead(reader, defaultReadRequest); } function pullWithBYOBReader(view, forBranch2) { @@ -2820,7 +2842,7 @@ function readableByteStreamControllerClose(controller) { } if (pendingPullIntos.length) { - const firstPendingPullInto = pendingPullIntos[0]; + const firstPendingPullInto = pendingPullIntos.peek(); if (firstPendingPullInto.bytesFilled % firstPendingPullInto.elementSize !== 0) { const error = new ERR_INVALID_STATE.TypeError('Partial read'); readableByteStreamControllerError(controller, error); @@ -2877,7 +2899,7 @@ function readableByteStreamControllerClearAlgorithms(controller) { function readableByteStreamControllerClearPendingPullIntos(controller) { readableByteStreamControllerInvalidateBYOBRequest(controller); - controller[kState].pendingPullIntos = []; + controller[kState].pendingPullIntos = kEmptyQueue; } function readableByteStreamControllerGetDesiredSize(controller) { @@ -2979,7 +3001,7 @@ function readableByteStreamControllerPullInto( type: 'byob', }; if (pendingPullIntos.length) { - ArrayPrototypePush(pendingPullIntos, desc); + pendingPullIntos.push(desc); readableStreamAddReadIntoRequest(stream, readIntoRequest); return; } @@ -3005,17 +3027,28 @@ function readableByteStreamControllerPullInto( return; } } - ArrayPrototypePush(pendingPullIntos, desc); + materializePendingPullIntos(controller[kState]).push(desc); readableStreamAddReadIntoRequest(stream, readIntoRequest); readableByteStreamControllerCallPullIfNeeded(controller); } +// Pending pull-into descriptor queues start out as (and are reset to) +// the shared immutable empty queue so that constructing a byte stream +// never allocates descriptor storage; the two push sites that can +// observe an empty queue materialize a real Queue on first use. +function materializePendingPullIntos(state) { + const pendingPullIntos = state.pendingPullIntos; + if (pendingPullIntos === kEmptyQueue) + return state.pendingPullIntos = new Queue(); + return pendingPullIntos; +} + function readableByteStreamControllerRespondInternal(controller, bytesWritten) { const { stream, pendingPullIntos, } = controller[kState]; - const desc = pendingPullIntos[0]; + const desc = pendingPullIntos.peek(); readableByteStreamControllerInvalidateBYOBRequest(controller); if (stream[kState].state === 'closed') { if (bytesWritten) @@ -3040,7 +3073,7 @@ function readableByteStreamControllerRespond(controller, bytesWritten) { stream, } = controller[kState]; assert(pendingPullIntos.length); - const desc = pendingPullIntos[0]; + const desc = pendingPullIntos.peek(); if (stream[kState].state === 'closed') { if (bytesWritten !== 0) @@ -3085,7 +3118,7 @@ function readableByteStreamControllerFillHeadPullIntoDescriptor( pendingPullIntos, byobRequest, } = controller[kState]; - assert(!pendingPullIntos.length || pendingPullIntos[0] === desc); + assert(!pendingPullIntos.length || pendingPullIntos.peek() === desc); assert(byobRequest === null); desc.bytesFilled += size; } @@ -3108,7 +3141,7 @@ function readableByteStreamControllerEnqueue(controller, chunk) { const transferredBuffer = ArrayBufferPrototypeTransfer(buffer); if (pendingPullIntos.length) { - const firstPendingPullInto = pendingPullIntos[0]; + const firstPendingPullInto = pendingPullIntos.peek(); if (ArrayBufferPrototypeGetDetached(firstPendingPullInto.buffer)) { throw new ERR_INVALID_STATE.TypeError( @@ -3155,7 +3188,7 @@ function readableByteStreamControllerEnqueue(controller, chunk) { } else { assert(!queue.length); if (pendingPullIntos.length) { - assert(pendingPullIntos[0].type === 'default'); + assert(pendingPullIntos.peek().type === 'default'); readableByteStreamControllerShiftPendingPullInto(controller); } const transferredView = @@ -3324,7 +3357,7 @@ function readableByteStreamControllerProcessPullIntoDescriptorsUsingQueue( while (pendingPullIntos.length) { if (!controller[kState].queueTotalSize) break; - const desc = pendingPullIntos[0]; + const desc = pendingPullIntos.peek(); if (readableByteStreamControllerFillPullIntoDescriptorFromQueue( controller, desc)) { @@ -3404,7 +3437,7 @@ function readableByteStreamControllerRespondWithNewView(controller, view) { } = controller[kState]; assert(pendingPullIntos.length); - const desc = pendingPullIntos[0]; + const desc = pendingPullIntos.peek(); assert(stream[kState].state !== 'errored'); const viewByteLength = ArrayBufferViewGetByteLength(view); @@ -3444,7 +3477,7 @@ function readableByteStreamControllerRespondWithNewView(controller, view) { function readableByteStreamControllerShiftPendingPullInto(controller) { assert(controller[kState].byobRequest === null); - return ArrayPrototypeShift(controller[kState].pendingPullIntos); + return controller[kState].pendingPullIntos.shift(); } function readableByteStreamControllerCallPullIfNeeded(controller) { @@ -3540,7 +3573,6 @@ function readableByteStreamControllerProcessReadRequestsUsingQueue(controller) { function readableByteStreamControllerPullSteps(controller, readRequest) { const { - pendingPullIntos, queueTotalSize, stream, } = controller[kState]; @@ -3559,8 +3591,7 @@ function readableByteStreamControllerPullSteps(controller, readRequest) { if (autoAllocateChunkSize !== undefined) { try { const buffer = new ArrayBuffer(autoAllocateChunkSize); - ArrayPrototypePush( - pendingPullIntos, + materializePendingPullIntos(controller[kState]).push( { buffer, bufferByteLength: autoAllocateChunkSize, @@ -3610,7 +3641,7 @@ function setupReadableByteStreamController( pullAlgorithm, cancelAlgorithm, autoAllocateChunkSize, - pendingPullIntos: [], + pendingPullIntos: kEmptyQueue, }; stream[kState].controller = controller; diff --git a/lib/internal/zip.js b/lib/internal/zip.js new file mode 100644 index 000000000000..1889a2eea9da --- /dev/null +++ b/lib/internal/zip.js @@ -0,0 +1,44 @@ +'use strict'; + +// Public entry point for ZIP archive support in `node:zlib`. The +// implementation is split across `internal/zip/`: +// +// constants shared signatures, flags, symbols, and small values +// binary bounds-checked reads and buffer coercion +// content-size the module-global in-memory decompression ceiling +// dos MS-DOS date/time and CP437 legacy name/text decoding +// extra-fields TLV extra-field parsing and building +// headers reader-side header structures and archive-end location +// header-builders writer-side header/record builders +// compression deflate/inflate/zstd plumbing and member decoding +// fs-util fd read/write helpers +// entry ZipEntry +// archive createZipArchive()/zipFiles() serialization +// buffer ZipBuffer +// file ZipFile +// +// This barrel re-exports only the surface `lib/zlib.js` consumes. + +const { ZipEntry } = require('internal/zip/entry'); +const { ZipBuffer } = require('internal/zip/buffer'); +const { ZipFile } = require('internal/zip/file'); +const { + createZipArchive, + createZipArchiveSync, + zipFiles, +} = require('internal/zip/archive'); +const { + getMaxZipContentSize, + setMaxZipContentSize, +} = require('internal/zip/content-size'); + +module.exports = { + ZipEntry, + ZipFile, + ZipBuffer, + createZipArchive, + createZipArchiveSync, + zipFiles, + getMaxZipContentSize, + setMaxZipContentSize, +}; diff --git a/lib/internal/zip/archive.js b/lib/internal/zip/archive.js new file mode 100644 index 000000000000..7693ce1297f2 --- /dev/null +++ b/lib/internal/zip/archive.js @@ -0,0 +1,321 @@ +'use strict'; + +// Archive serialization: `createZipArchive()`/`createZipArchiveSync()` and +// the `zipFiles()` on-disk variant, the `generateZipArchive()` generator they +// build on (auto-switching to Zip64 as offsets/counts overflow), and the +// shared archive-option normalizer. + +const { + ArrayPrototypePush, + JSONStringify, + NumberMAX_SAFE_INTEGER, + StringPrototypeEndsWith, + SymbolAsyncDispose, + SymbolAsyncIterator, + SymbolDispose, + SymbolIterator, +} = primordials; + +const { + codes: { + ERR_ZIP_ARCHIVE_TOO_LARGE, + ERR_ZIP_UNSUPPORTED_FEATURE, + }, +} = require('internal/errors'); +const { + validateBoolean, + validateInteger, + validateObject, + validateString, +} = require('internal/validators'); +const { isUint8Array } = require('internal/util/types'); +const { Buffer } = require('buffer'); +const { Readable } = require('stream'); +const fs = require('fs'); +const { + EMPTY_BUFFER, + SENTINEL16, + kFinalize, +} = require('internal/zip/constants'); +const { + buildArchiveTrailer, +} = require('internal/zip/header-builders'); +const { + fsStatAsync, + fsLstatAsync, + fsReadlinkAsync, +} = require('internal/zip/fs-util'); +const { ZipEntry } = require('internal/zip/entry'); + +/** + * `createZipArchive()`/`createZipArchiveSync()` (and the `ZipBuffer` + * `toBuffer()`/`toBufferSync()` methods that forward to them) take a single + * optional `options` argument that doubles as a plain archive comment: a + * string is shorthand for `{ comment: options }`. + * @param {string | { comment?: string, baseOffset?: number }} [options] + * @returns {{ comment: string | undefined, baseOffset: number }} + */ +function normalizeArchiveOptions(options) { + if (options === undefined) return { comment: undefined, baseOffset: 0 }; + if (typeof options === 'string') return { comment: options, baseOffset: 0 }; + validateObject(options, 'options'); + const { comment, baseOffset = 0 } = options; + // A Buffer comment is an internal convenience (used when round-tripping an + // existing archive) that preserves the original bytes without forcing them + // through a decode/re-encode cycle that would corrupt non-UTF-8 comments. + if (comment !== undefined && !isUint8Array(comment)) { + validateString(comment, 'options.comment'); + } + validateInteger(baseOffset, 'options.baseOffset', 0, NumberMAX_SAFE_INTEGER); + return { comment, baseOffset }; +} + +/** + * Serializes `entries` (a (async) iterable of `ZipEntry`) into a `Readable` + * stream of archive byte chunks, automatically switching to Zip64 structures + * once the entry count or any offset/size exceeds the classic 32-/16-bit + * limits. + * + * `options.baseOffset` shifts every local/central header offset the archive + * records by that many bytes, so the emitted bytes are self-describing even + * when something else is written before them - for example, appending the + * archive after `baseOffset` bytes already written to the same file, rather + * than at its start. + * @param {Iterable | AsyncIterable} entries + * @param {string | { comment?: string, baseOffset?: number }} [options] + * @returns {Readable} + */ +function createZipArchive(entries, options) { + return Readable.from(generateZipArchive(entries, options), { objectMode: false }); +} + +/** + * Creates an archive from files on disk. `files` is an iterable of + * `[sourcePath, entryName]` pairs - an array, a `Map`, the result of + * `Object.entries()`, a generator, and so on. Each entry captures the file's + * Unix mode and modification time; a directory becomes a directory entry and a + * regular file's contents are streamed in without being buffered in memory. + * + * With `options.followSymlinks` (default `true`) a symbolic link is resolved + * and archived as the file it points to; with it `false` the link itself is + * stored as a symlink entry whose content is the target path. + * @param {Iterable<[string, string]>} files + * @param {string | { followSymlinks?: boolean, comment?: string, baseOffset?: number }} [options] + * @returns {import('stream').Readable} + */ +function zipFiles(files, options) { + const followSymlinks = options?.followSymlinks ?? true; + validateBoolean(followSymlinks, 'options.followSymlinks'); + return createZipArchive(fileEntries(files, followSymlinks), options); +} + +// Turn each `[sourcePath, entryName]` pair into a `ZipEntry`, stat-ing the +// source to capture its mode/mtime and picking the symlink/directory/file +// entry shape; the file-backed variant streams contents rather than buffering. +async function* fileEntries(files, followSymlinks) { + for await (const pair of files) { + const sourcePath = pair[0]; + const name = pair[1]; + validateString(sourcePath, 'sourcePath'); + validateString(name, 'name'); + // Following links resolves through to the target (stat); otherwise the + // link itself is inspected (lstat) and stored as a symlink entry. + const stats = followSymlinks ? await fsStatAsync(sourcePath) : await fsLstatAsync(sourcePath); + const options = { __proto__: null, mode: stats.mode & 0o7777, modified: stats.mtime }; + if (stats.isSymbolicLink()) { + yield ZipEntry.createSymlink(name, await fsReadlinkAsync(sourcePath), options); + } else if (stats.isDirectory()) { + const dirName = StringPrototypeEndsWith(name, '/') ? name : `${name}/`; + yield await ZipEntry.create(dirName, EMPTY_BUFFER, options); + } else { + // Open the file ourselves and stream from that exact descriptor instead + // of re-resolving the path with createReadStream(). Re-opening by path + // would let a symlink swapped in after the classification above redirect + // the read - a TOCTOU that defeats followSymlinks:false. O_NOFOLLOW makes + // a final-component symlink fail the open outright when not following; + // the fstat then confirms a regular file, never a FIFO, device, or + // socket (which as a stream source could block or emit unbounded data). + // Metadata comes from that same fstat so it describes the inode we read. + const flags = followSymlinks ? + fs.constants.O_RDONLY : + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0); + const handle = await fs.promises.open(sourcePath, flags); + let ownsHandle = true; + try { + const stat = await handle.stat(); + if (!stat.isFile()) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE( + `cannot archive ${JSONStringify(sourcePath)}: not a regular file`); + } + const fileOptions = { + __proto__: null, + mode: stat.mode & 0o7777, + modified: stat.mtime, + }; + // The stream adopts the handle and closes it when it ends or is + // destroyed (autoClose); if the consumer stops before taking the + // entry, the finally destroys the stream, releasing the descriptor. + const stream = fs.createReadStream(undefined, { fd: handle }); + ownsHandle = false; + let handedOff = false; + try { + yield ZipEntry.createStream(name, stream, fileOptions); + handedOff = true; + } finally { + if (!handedOff) stream.destroy(); + } + } finally { + if (ownsHandle) await handle.close(); + } + } + } +} + +// Encode the archive comment (a string, or raw bytes when round-tripping a +// non-UTF-8 comment) and enforce the 16-bit length field (sec. 4.3.16). +function normalizeCommentBuffer(comment) { + if (comment === undefined) return EMPTY_BUFFER; + const buffer = isUint8Array(comment) ? comment : Buffer.from(comment, 'utf8'); + if (buffer.length > SENTINEL16) { + throw new ERR_ZIP_ARCHIVE_TOO_LARGE( + 'the archive comment must not exceed 65535 bytes when encoded as UTF-8'); + } + return buffer; +} + +// Dispose the entries still queued behind an interrupted serialization. A +// ZipEntry handed to the archive is owned by it, so when the consumer destroys +// the output stream early - which runs `generateZipArchive`'s `finally` - the +// entries it never reached must be released too (a streaming entry may hold a +// file read stream). The entry currently being serialized is torn down by the +// `for await ... of entry` loop's own return propagation, so only the queue +// behind it is handled here. +// +// A materialized source (an array, `Map`, `Object.entries()`) has already +// created every entry, so each remaining one is pulled and disposed. A lazy +// async source is instead `return()`ed: forcing it to produce every remaining +// entry could open thousands of descriptors just to close them, so it is +// signalled to stop and release its own in-flight resource (see +// `fileEntries()`). +async function disposeQueuedEntries(iterator, isAsync) { + if (isAsync) { + if (typeof iterator.return === 'function') await iterator.return(); + return; + } + for (let next = iterator.next(); !next.done; next = iterator.next()) { + const entry = next.value; + if (typeof entry?.[SymbolAsyncDispose] === 'function') await entry[SymbolAsyncDispose](); + } +} + +// Core serializer backing `createZipArchive()`: emits each entry's local header +// + data, then the central directory, then the end-of-central-directory record +// (APPNOTE sec. 4.3.6 archive layout), promoting to Zip64 end records +// (sec. 4.3.14/4.3.15) once any count/offset/size overflows its classic field. +// +// The entries are owned by this generator: it drives the iterator by hand +// (rather than `for await`) so that if the consumer destroys the returned +// stream partway - `Readable.from()` then calls this generator's `return()` - +// the `finally` can dispose every entry that was never fully serialized. A +// synchronous source is stepped without `await` so a pulled-but-not-yet-used +// entry cannot be stranded by a return injected at the `await`. +async function* generateZipArchive(entries, options) { + const { comment, baseOffset } = normalizeArchiveOptions(options); + const commentBuffer = normalizeCommentBuffer(comment); + const centralHeaders = []; + let pos = baseOffset; + const isAsync = entries[SymbolAsyncIterator] !== undefined; + const iterator = isAsync ? entries[SymbolAsyncIterator]() : entries[SymbolIterator](); + let completed = false; + try { + while (true) { + const next = isAsync ? await iterator.next() : iterator.next(); + if (next.done) break; + const entry = next.value; + const start = pos; + for await (const chunk of entry) { + yield chunk; + pos += chunk.length; + } + ArrayPrototypePush(centralHeaders, entry[kFinalize](start)); + } + const centralDirectoryOffset = pos; + for (let i = 0; i < centralHeaders.length; i++) { + const chunk = centralHeaders[i]; + yield chunk; + pos += chunk.length; + } + const centralDirectorySize = pos - centralDirectoryOffset; + const count = centralHeaders.length; + const trailer = buildArchiveTrailer(count, centralDirectorySize, centralDirectoryOffset, commentBuffer); + for (let i = 0; i < trailer.length; i++) yield trailer[i]; + completed = true; + } finally { + if (!completed) await disposeQueuedEntries(iterator, isAsync); + } +} + +/** + * The synchronous counterpart of `createZipArchive()`. `entries` must be a + * plain (synchronous) `Iterable` of entries that don't require an + * asynchronous serialization pass - a streaming entry created with + * `ZipEntry.createStream()` throws when its turn to serialize comes up, the + * same as calling `entry[Symbol.iterator]()` on one directly. Blocks the + * event loop and further JavaScript execution until the whole archive + * (including any deflate passes) has been produced; see + * `zipEntry.contentSync()`. + * @param {Iterable} entries + * @param {string | { comment?: string, baseOffset?: number }} [options] + * @yields {Buffer} + */ +function* createZipArchiveSync(entries, options) { + const { comment, baseOffset } = normalizeArchiveOptions(options); + const commentBuffer = normalizeCommentBuffer(comment); + const centralHeaders = []; + let pos = baseOffset; + const iterator = entries[SymbolIterator](); + let completed = false; + // As in generateZipArchive(), the entries are owned here: a streaming entry + // throws when serialized synchronously, so on that throw (or an early + // return of this generator) dispose the entry that failed and every entry + // still queued behind it, releasing any sources they hold. + let current = null; + try { + for (let next = iterator.next(); !next.done; next = iterator.next()) { + current = next.value; + const start = pos; + for (const chunk of current) { + yield chunk; + pos += chunk.length; + } + ArrayPrototypePush(centralHeaders, current[kFinalize](start)); + current = null; + } + const centralDirectoryOffset = pos; + for (let i = 0; i < centralHeaders.length; i++) { + const chunk = centralHeaders[i]; + yield chunk; + pos += chunk.length; + } + const centralDirectorySize = pos - centralDirectoryOffset; + const count = centralHeaders.length; + const trailer = buildArchiveTrailer(count, centralDirectorySize, centralDirectoryOffset, commentBuffer); + for (let i = 0; i < trailer.length; i++) yield trailer[i]; + completed = true; + } finally { + if (!completed) { + if (typeof current?.[SymbolDispose] === 'function') current[SymbolDispose](); + for (let next = iterator.next(); !next.done; next = iterator.next()) { + const entry = next.value; + if (typeof entry?.[SymbolDispose] === 'function') entry[SymbolDispose](); + } + } + } +} + +module.exports = { + normalizeArchiveOptions, + createZipArchive, + createZipArchiveSync, + zipFiles, +}; diff --git a/lib/internal/zip/binary.js b/lib/internal/zip/binary.js new file mode 100644 index 000000000000..3aa6b89a5cd3 --- /dev/null +++ b/lib/internal/zip/binary.js @@ -0,0 +1,87 @@ +'use strict'; + +// Low-level binary helpers: bounds-checked archive ranges, safe 64-bit +// integer read/write, and buffer coercion of user input. + +const { + BigInt, + Number, + NumberIsInteger, +} = primordials; + +const { + codes: { + ERR_INVALID_ARG_TYPE, + ERR_ZIP_INVALID_ARCHIVE, + }, +} = require('internal/errors'); +const { + isAnyArrayBuffer, + isArrayBufferView, + isUint8Array, +} = require('internal/util/types'); +const { Buffer } = require('buffer'); +const { BIGINT_MAX_SAFE_INTEGER } = require('internal/zip/constants'); + +// Reject an [offset, offset + length) slice that escapes the archive buffer +// before it is used to read a record: guards every offset taken from archive +// bytes against corrupt or hostile values. +function validateArchiveRange(buffer, offset, length, what) { + if ( + !NumberIsInteger(offset) || + offset < 0 || + !NumberIsInteger(length) || + length < 0 || + offset + length > buffer.length + ) { + throw new ERR_ZIP_INVALID_ARCHIVE(`${what} is out of bounds`); + } +} + +// Read a little-endian u64 (sizes/offsets, Zip64) that must land in the JS +// safe-integer range; a field past the buffer or beyond that range means a +// corrupt or hostile archive. +function readSafeUint64(buffer, offset) { + if (offset + 8 > buffer.length) { + throw new ERR_ZIP_INVALID_ARCHIVE('64-bit field is out of bounds'); + } + const value = buffer.readBigUInt64LE(offset); + if (value > BIGINT_MAX_SAFE_INTEGER) { + throw new ERR_ZIP_INVALID_ARCHIVE('64-bit field exceeds the safe integer range'); + } + return Number(value); +} + +// Write a JS number as a little-endian u64; the write paths only feed values +// already bounded by the safe-integer range, so no range check is needed here. +function writeSafeUint64(buffer, offset, value) { + buffer.writeBigUInt64LE(BigInt(value), offset); +} + + +// Coerce user-supplied binary input to a Buffer, aliasing the same memory +// (no copy) for a TypedArray/DataView/ArrayBuffer and rejecting anything else. +// internal/crypto/util.js's getArrayBufferOrView() covers similar coercion but +// returns the view unchanged (and accepts strings with an encoding); this +// helper exists because the ZIP code needs an actual Buffer over that memory. +function toBuffer(value, name) { + if (isUint8Array(value)) { + return Buffer.isBuffer(value) ? + value : Buffer.from(value.buffer, value.byteOffset, value.byteLength); + } + if (isArrayBufferView(value)) { + return Buffer.from(value.buffer, value.byteOffset, value.byteLength); + } + if (isAnyArrayBuffer(value)) { + return Buffer.from(value); + } + throw new ERR_INVALID_ARG_TYPE( + name, ['Buffer', 'TypedArray', 'DataView', 'ArrayBuffer'], value); +} + +module.exports = { + validateArchiveRange, + readSafeUint64, + writeSafeUint64, + toBuffer, +}; diff --git a/lib/internal/zip/buffer.js b/lib/internal/zip/buffer.js new file mode 100644 index 000000000000..582d8ae799af --- /dev/null +++ b/lib/internal/zip/buffer.js @@ -0,0 +1,186 @@ +'use strict'; + +// `ZipBuffer`: an in-memory, writable view over the entries of an archive +// held in a `Buffer`, serializing the current set back out with +// `toBuffer()`/`toBufferSync()`. + +const { + ArrayPrototypePush, + FunctionPrototypeCall, + Map, + MapPrototypeClear, + MapPrototypeDelete, + MapPrototypeEntries, + MapPrototypeGet, + MapPrototypeGetSize, + MapPrototypeHas, + MapPrototypeKeys, + MapPrototypeSet, + SymbolDispose, + SymbolIterator, + SymbolToStringTag, +} = primordials; + +const { + codes: { + ERR_INVALID_ARG_TYPE, + ERR_ZIP_ENTRY_NOT_FOUND, + }, +} = require('internal/errors'); +const { + validateFunction, + validateString, +} = require('internal/validators'); +const { Buffer } = require('buffer'); +const { toBuffer } = require('internal/zip/binary'); +const { decodeZipText } = require('internal/zip/dos'); +const { findArchiveEnd } = require('internal/zip/headers'); +const { + readArchiveEntries, + ZipEntry, +} = require('internal/zip/entry'); +const { + createZipArchive, + createZipArchiveSync, + normalizeArchiveOptions, +} = require('internal/zip/archive'); + +/** + * An in-memory view over the entries of a ZIP archive, writable in place: + * entries can be added or removed, and `toBuffer()` serializes the current + * set of entries into a fresh archive. + */ +class ZipBuffer { + #entries = new Map(); + #comment; + + /** + * Parses an existing archive's central directory into an in-memory, + * editable map of entries keyed by name. + * @param {Buffer | TypedArray | DataView | ArrayBuffer} buffer + */ + constructor(buffer) { + const buf = toBuffer(buffer, 'buffer'); + // Locate the archive end once; it supplies both the comment and the + // central-directory bounds for the entry walk. + const end = findArchiveEnd(buf); + this.#comment = end.comment; + for (const entry of readArchiveEntries(buf, end)) { + MapPrototypeSet(this.#entries, entry.name, entry); + } + } + get writable() { return true; } + // The EOCD comment has no encoding flag; apply the same UTF-8/CP437 + // heuristic as unflagged member names and comments. + get comment() { return decodeZipText(this.#comment, 0); } + has(name) { + validateString(name, 'name'); + return MapPrototypeHas(this.#entries, name); + } + get(name) { + validateString(name, 'name'); + const entry = MapPrototypeGet(this.#entries, name); + if (entry === undefined) throw new ERR_ZIP_ENTRY_NOT_FOUND(name); + return entry; + } + /** + * Adds an already-built entry, keyed by its own name (replacing any + * existing entry of that name). + * @param {ZipEntry} entry + * @returns {ZipEntry} + */ + addEntry(entry) { + if (!(entry instanceof ZipEntry)) { + throw new ERR_INVALID_ARG_TYPE('entry', 'ZipEntry', entry); + } + MapPrototypeSet(this.#entries, entry.name, entry); + return entry; + } + /** + * Builds an entry from in-memory `data` and adds it (replacing any entry of + * the same name). + * @param {string} filename + * @param {Buffer | TypedArray | DataView | ArrayBuffer} data + * @param {{ comment?: string, mode?: number, modified?: Date, method?: 'deflate' | 'store' | 'zstd' }} [options] + * @returns {Promise} + */ + async add(filename, data, options) { + return this.addEntry(await ZipEntry.create(filename, data, options)); + } + /** + * The synchronous counterpart of `add()`. Blocks the event loop and + * further JavaScript execution until done; see `zipEntry.contentSync()`. + * @param {string} filename + * @param {Buffer | TypedArray | DataView | ArrayBuffer} data + * @param {{ comment?: string, mode?: number, modified?: Date, method?: 'deflate' | 'store' | 'zstd' }} [options] + * @returns {ZipEntry} + */ + addSync(filename, data, options) { + return this.addEntry(ZipEntry.createSync(filename, data, options)); + } + /** + * @param {string} name + * @returns {boolean} + */ + delete(name) { + validateString(name, 'name'); + return MapPrototypeDelete(this.#entries, name); + } + clear() { + MapPrototypeClear(this.#entries); + } + keys() { return MapPrototypeKeys(this.#entries); } + *values() { + for (const name of this.keys()) yield this.get(name); + } + *entries() { + for (const name of this.keys()) yield [name, this.get(name)]; + } + get size() { return MapPrototypeGetSize(this.#entries); } + [SymbolIterator]() { return this.entries(); } + get [SymbolToStringTag]() { return 'ZipBuffer'; } + forEach(callback, thisArg) { + validateFunction(callback, 'callback'); + for (const { 0: key, 1: value } of MapPrototypeEntries(this.#entries)) { + FunctionPrototypeCall(callback, thisArg === undefined ? this : thisArg, value, key, this); + } + } + /** + * Serializes the current set of entries into a fresh archive. + * @param {string | { comment?: string, baseOffset?: number }} [options] + * @returns {Promise} + */ + async toBuffer(options) { + const { comment, baseOffset } = normalizeArchiveOptions(options); + const chunks = []; + // Defaulting to the raw #comment bytes (not the decoded string) + // round-trips a non-UTF-8 archive comment unchanged. + for await (const chunk of createZipArchive(this.values(), { comment: comment ?? this.#comment, baseOffset })) { + ArrayPrototypePush(chunks, chunk); + } + return Buffer.concat(chunks); + } + /** + * The synchronous counterpart of `toBuffer()`. Blocks the event loop and + * further JavaScript execution until the whole archive has been + * serialized; see `zipEntry.contentSync()`. + * @param {string | { comment?: string, baseOffset?: number }} [options] + * @returns {Buffer} + */ + toBufferSync(options) { + const { comment, baseOffset } = normalizeArchiveOptions(options); + const chunks = []; + for (const chunk of createZipArchiveSync(this.values(), { comment: comment ?? this.#comment, baseOffset })) { + ArrayPrototypePush(chunks, chunk); + } + return Buffer.concat(chunks); + } + // Dispose: drop all entries (this view holds no fd of its own). + [SymbolDispose]() { + MapPrototypeClear(this.#entries); + } +} + +module.exports = { + ZipBuffer, +}; diff --git a/lib/internal/zip/compression.js b/lib/internal/zip/compression.js new file mode 100644 index 000000000000..fd9a7373c365 --- /dev/null +++ b/lib/internal/zip/compression.js @@ -0,0 +1,303 @@ +'use strict'; + +// Compression/decompression plumbing over the lazily-required `zlib` facade: +// one-shot (async and sync) and streaming deflate/inflate/zstd helpers, plus +// the member decoders that add method dispatch, size bounding, and CRC-32 +// verification on top. + +const { + JSONStringify, + MathMin, + Promise, +} = primordials; + +const { + codes: { + ERR_ZIP_ENTRY_CORRUPT, + ERR_ZIP_ENTRY_TOO_LARGE, + ERR_ZIP_UNSUPPORTED_FEATURE, + }, +} = require('internal/errors'); +const { kMaxLength } = require('buffer'); +const { compose } = require('stream'); +const { crc32: crc32Native } = internalBinding('zlib'); +const { + FLAG_ENCRYPTED, + METHOD_STORE, + METHOD_DEFLATE, + METHOD_ZSTD, +} = require('internal/zip/constants'); + +// `internal/zip` is required from `lib/zlib.js`, so it must not require the +// public `zlib` facade at load time (its module.exports is not yet +// populated). Compression is only needed once an entry is actually read or +// written, well after `zlib.js` has finished loading, so a lazy reference is +// enough to break the cycle. +let zlib; +function lazyZlib() { + zlib ??= require('zlib'); + return zlib; +} + +// -- compression plumbing ------------------------------------------------------ + +// The one-shot (async/sync) and streaming helpers below are thin adapters that +// promisify or stream-wrap the lazy `zlib` facade; individually trivial, they +// exist only so the rest of the module never touches `zlib` directly. + +function deflateRawAsync(buffer) { + return new Promise((resolve, reject) => { + lazyZlib().deflateRaw(buffer, (err, result) => { + if (err) reject(err); + else resolve(result); + }); + }); +} + +function inflateRawAsync(buffer, options) { + return new Promise((resolve, reject) => { + lazyZlib().inflateRaw(buffer, options, (err, result) => { + if (err) reject(err); + else resolve(result); + }); + }); +} + +// Drive `source` through a zlib transform stream, returning an async-iterable +// stream of its output. `compose()` (pipeline-backed) wires error propagation +// both ways and tears the whole chain down when the consumer errors or stops +// early, so an abandoned iteration cannot leak the pipeline. +function pumpThroughTransform(source, transform) { + return compose(source, transform); +} + +function deflateRawStream(source) { + return pumpThroughTransform(source, lazyZlib().createDeflateRaw()); +} + +function inflateRawStream(source) { + return pumpThroughTransform(source, lazyZlib().createInflateRaw()); +} + +function zstdCompressAsync(buffer) { + return new Promise((resolve, reject) => { + lazyZlib().zstdCompress(buffer, (err, result) => { + if (err) reject(err); + else resolve(result); + }); + }); +} + +function zstdDecompressAsync(buffer, options) { + return new Promise((resolve, reject) => { + lazyZlib().zstdDecompress(buffer, options, (err, result) => { + if (err) reject(err); + else resolve(result); + }); + }); +} + +function zstdCompressStream(source) { + return pumpThroughTransform(source, lazyZlib().createZstdCompress()); +} + +function zstdDecompressStream(source) { + return pumpThroughTransform(source, lazyZlib().createZstdDecompress()); +} + + +function deflateRawSync(buffer) { + return lazyZlib().deflateRawSync(buffer); +} + +function zstdCompressSync(buffer) { + return lazyZlib().zstdCompressSync(buffer); +} + +/** + * @typedef {{ + * name: string, + * flags: number, + * method: number, + * crc32: number, + * uncompressedSize: number, + * }} ZipMemberInfo + */ + +// Shared entry guards for the member decoders below: encryption and +// unsupported compression methods are rejected up front, and when the caller +// bounds the output, a declared size beyond that bound fails before anything +// is decompressed or allocated. +function assertDecodable(info, options) { + if (info.flags & FLAG_ENCRYPTED) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE( + `entry ${JSONStringify(info.name)} is encrypted`); + } + if (info.method !== METHOD_STORE && info.method !== METHOD_DEFLATE && info.method !== METHOD_ZSTD) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE( + `entry ${JSONStringify(info.name)} uses compression method ${info.method}`); + } + if (options?.maxSize !== undefined && info.uncompressedSize > options.maxSize) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + `entry ${JSONStringify(info.name)} declares ${info.uncompressedSize} bytes, ` + + `exceeding the ${options.maxSize} byte limit`); + } +} + +// Bound one-shot decompression by the declared size, not just the caller's +// limit: a member that decompresses to more than it declares is corrupt by +// definition, so there is no reason to materialize more than `declared + 1` +// bytes (the +1 makes an overrun detectable) no matter how generous `maxSize` +// is. This keeps a tiny archive from forcing a `maxSize`-sized allocation. +function outputCap(info, options) { + return MathMin( + info.uncompressedSize + 1, options?.maxSize ?? kMaxLength, kMaxLength); +} + +// Map a one-shot decompression failure to a corrupt-entry error. +// `assertDecodable()` has already ensured `declared <= maxSize`, so hitting +// the output cap always means the stream produced more than the member +// declared. +function rethrowDecodeFailure(err, info, method) { + if (err?.code === 'ERR_BUFFER_TOO_LARGE') { + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(info.name)} ` + + `${method === METHOD_DEFLATE ? 'inflates' : 'decompresses'} beyond its ` + + `declared size of ${info.uncompressedSize} bytes`); + } + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(info.name)} failed to ` + + `${method === METHOD_DEFLATE ? 'inflate' : 'decompress'}: ${err.message}`); +} + +// Enforce the declared size and (unless opted out) the CRC-32 on a fully +// decoded member. +function checkDecoded(data, info, verify) { + if (data.length !== info.uncompressedSize) { + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(info.name)} produced ${data.length} bytes, expected ` + + `${info.uncompressedSize}`); + } + if (verify && crc32Native(data, 0) !== info.crc32) { + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(info.name)} failed CRC-32 verification`); + } +} + +/** + * Decodes one member's compressed byte stream: rejects encrypted entries and + * unsupported compression methods, inflates method 8 or decompresses method + * 93 (Zstandard), enforces the declared uncompressed size and verifies + * CRC-32 (on by default). + * @param {AsyncIterable} source + * @param {ZipMemberInfo} info + * @param {{ verify?: boolean, maxSize?: number }} [options] + * @yields {Buffer} + */ +async function* decodeMemberStream(source, info, options) { + assertDecodable(info, options); + const verify = options?.verify !== false; + let produced = 0; + let state = 0; + const decoded = info.method === METHOD_DEFLATE ? inflateRawStream(source) : + info.method === METHOD_ZSTD ? zstdDecompressStream(source) : source; + for await (const chunk of decoded) { + produced += chunk.length; + if (produced > info.uncompressedSize) { + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(info.name)} inflates beyond its declared size of ` + + `${info.uncompressedSize} bytes`); + } + if (verify) state = crc32Native(chunk, state); + yield chunk; + } + if (produced !== info.uncompressedSize) { + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(info.name)} is truncated: got ${produced} of ` + + `${info.uncompressedSize} bytes`); + } + if (verify && state !== info.crc32) { + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(info.name)} failed CRC-32 verification`); + } +} + +/** + * Decodes one member held completely in `compressed`, in one shot: the same + * guards, declared-size bounding, and CRC-32 verification as + * `decodeMemberStream()`, returning the whole decoded member. For the store + * method the input buffer itself is returned - callers that must hand out + * caller-owned memory copy it (see `zipEntry.content()`). + * @param {Buffer} compressed + * @param {ZipMemberInfo} info + * @param {{ verify?: boolean, maxSize?: number }} [options] + * @returns {Promise} + */ +async function decodeMemberAsync(compressed, info, options) { + assertDecodable(info, options); + const cap = outputCap(info, options); + let data; + if (info.method === METHOD_DEFLATE) { + try { + data = await inflateRawAsync(compressed, { maxOutputLength: cap }); + } catch (err) { + rethrowDecodeFailure(err, info, METHOD_DEFLATE); + } + } else if (info.method === METHOD_ZSTD) { + try { + data = await zstdDecompressAsync(compressed, { maxOutputLength: cap }); + } catch (err) { + rethrowDecodeFailure(err, info, METHOD_ZSTD); + } + } else { + data = compressed; + } + checkDecoded(data, info, options?.verify !== false); + return data; +} + +/** + * The synchronous counterpart of `decodeMemberAsync()`. There is no public + * synchronous incremental inflate API, so - unlike the streaming path - + * `compressed` must already be the member's complete compressed byte + * stream, and the whole result is produced (and verified) in one call + * rather than yielded incrementally. + * @param {Buffer} compressed + * @param {ZipMemberInfo} info + * @param {{ verify?: boolean, maxSize?: number }} [options] + * @returns {Buffer} + */ +function decodeMemberSync(compressed, info, options) { + assertDecodable(info, options); + const cap = outputCap(info, options); + let data; + if (info.method === METHOD_DEFLATE) { + try { + data = lazyZlib().inflateRawSync(compressed, { maxOutputLength: cap }); + } catch (err) { + rethrowDecodeFailure(err, info, METHOD_DEFLATE); + } + } else if (info.method === METHOD_ZSTD) { + try { + data = lazyZlib().zstdDecompressSync(compressed, { maxOutputLength: cap }); + } catch (err) { + rethrowDecodeFailure(err, info, METHOD_ZSTD); + } + } else { + data = compressed; + } + checkDecoded(data, info, options?.verify !== false); + return data; +} + +module.exports = { + deflateRawAsync, + deflateRawSync, + zstdCompressAsync, + zstdCompressSync, + deflateRawStream, + zstdCompressStream, + decodeMemberStream, + decodeMemberAsync, + decodeMemberSync, +}; diff --git a/lib/internal/zip/constants.js b/lib/internal/zip/constants.js new file mode 100644 index 000000000000..2fe9d684b7f4 --- /dev/null +++ b/lib/internal/zip/constants.js @@ -0,0 +1,109 @@ +'use strict'; + +// Shared constants, symbols, and tiny shared values for the ZIP +// implementation. This module is a leaf: it requires nothing from the rest of +// `internal/zip`, so every other zip module can require it without cycles. + +const { + BigInt, + NumberMAX_SAFE_INTEGER, + Symbol, +} = primordials; + +const { FastBuffer } = require('internal/buffer'); + +const EMPTY_BUFFER = new FastBuffer(); +const BIGINT_MAX_SAFE_INTEGER = BigInt(NumberMAX_SAFE_INTEGER); + +// ZIP record signatures (APPNOTE.TXT, PKWARE Inc.) +const SIG_LOCAL_FILE_HEADER = 0x04034b50; // sec. 4.3.7 +const SIG_DATA_DESCRIPTOR = 0x08074b50; // sec. 4.3.9 +const SIG_CENTRAL_FILE_HEADER = 0x02014b50; // sec. 4.3.12 +const SIG_ZIP64_EOCD_RECORD = 0x06064b50; // sec. 4.3.14 +const SIG_ZIP64_EOCD_LOCATOR = 0x07064b50; // sec. 4.3.15 +const SIG_EOCD = 0x06054b50; // sec. 4.3.16 + +const MADE_BY_UNIX = 3; // sec. 4.4.2 +const ZIP64_EXTRA_ID = 0x0001; // sec. 4.5.3 + +const SENTINEL16 = 0xffff; +const SENTINEL32 = 0xffffffff; + +const FLAG_ENCRYPTED = 0x0001; // sec. 4.4.4 bit 0 +const FLAG_DATA_DESCRIPTOR = 0x0008; // bit 3 +const FLAG_UTF8 = 0x0800; // bit 11: name/comment are UTF-8 (EFS) + +const METHOD_STORE = 0; // sec. 4.4.5 +const METHOD_DEFLATE = 8; // sec. 4.4.5 +const METHOD_ZSTD = 93; // sec. 4.4.5 + +const VERSION_DEFAULT = 20; // 2.0: deflate + directories (sec. 4.4.3) +const VERSION_ZIP64 = 45; // 4.5: Zip64 structures +const VERSION_ZSTD = 63; // 6.3: Zstandard compression (method 93) + +// The Zip64 EOCD record may carry an extensible data sector of arbitrary +// length between its fixed part and the locator, so it can start before any +// fixed-size tail read. When the locator points further back than the bytes +// at hand, callers re-read from the recorded offset - but only within this +// bound, so a hostile locator cannot demand an unbounded allocation. +const ZIP64_EOCD_MAX_LENGTH = 56 + 1024 * 1024; + +const S_IFREG = 0o100000; // Unix mode type bits: regular file +const S_IFDIR = 0o040000; // Unix mode type bits: directory +const S_IFLNK = 0o120000; // Unix mode type bits: symbolic link +const S_IFMT = 0o170000; // Unix mode type mask + +// Extra-field header IDs we consult on read (sec. 4.5). +const EXTRA_ID_NTFS = 0x000a; // NTFS times (100 ns since 1601) +const EXTRA_ID_EXT_TIMESTAMP = 0x5455; // Info-ZIP extended timestamp ("UT") +const EXTRA_ID_UNIX_OLD = 0x5855; // Info-ZIP Unix, original ("UX") +const EXTRA_ID_UNICODE_PATH = 0x7075; // Info-ZIP Unicode Path ("up") + +// Passed between `ZipEntry` and the archive writers/`ZipFile`: `kFinalize` +// asks an entry for its central-directory header at a known local offset; +// `kPromote` rebinds a just-serialized streaming entry to its on-disk copy. +const kFinalize = Symbol('kFinalize'); +const kPromote = Symbol('kPromote'); + +// The chunk size for reading a file-backed member's compressed bytes. +const READ_CHUNK_SIZE = 4 * 1024 * 1024; +// EOCD + max comment + Zip64 locator + Zip64 record + slack for an +// extensible data sector: the fixed-size tail `ZipFile.open()` reads first. +const TAIL_LENGTH = 22 + SENTINEL16 + 20 + 56 + 4096; + +module.exports = { + EMPTY_BUFFER, + BIGINT_MAX_SAFE_INTEGER, + SIG_LOCAL_FILE_HEADER, + SIG_DATA_DESCRIPTOR, + SIG_CENTRAL_FILE_HEADER, + SIG_ZIP64_EOCD_RECORD, + SIG_ZIP64_EOCD_LOCATOR, + SIG_EOCD, + MADE_BY_UNIX, + ZIP64_EXTRA_ID, + SENTINEL16, + SENTINEL32, + FLAG_ENCRYPTED, + FLAG_DATA_DESCRIPTOR, + FLAG_UTF8, + METHOD_STORE, + METHOD_DEFLATE, + METHOD_ZSTD, + VERSION_DEFAULT, + VERSION_ZIP64, + VERSION_ZSTD, + ZIP64_EOCD_MAX_LENGTH, + S_IFREG, + S_IFDIR, + S_IFLNK, + S_IFMT, + EXTRA_ID_NTFS, + EXTRA_ID_EXT_TIMESTAMP, + EXTRA_ID_UNIX_OLD, + EXTRA_ID_UNICODE_PATH, + kFinalize, + kPromote, + READ_CHUNK_SIZE, + TAIL_LENGTH, +}; diff --git a/lib/internal/zip/content-size.js b/lib/internal/zip/content-size.js new file mode 100644 index 000000000000..0693e86f1568 --- /dev/null +++ b/lib/internal/zip/content-size.js @@ -0,0 +1,41 @@ +'use strict'; + +// The module-global default ceiling on in-memory member decompression, and +// its public getter/setter. Kept in its own module so every read path sees +// the one mutable value. + +const { validateInteger } = require('internal/validators'); + +// A default ceiling on the uncompressed size that the buffering read paths +// (`ZipEntry.prototype.content()`, and therefore `ZipBuffer`/`ZipFile` +// `get()`) will materialize in memory when the caller does not pass an +// explicit `maxSize`. An archive whose central directory declares a member +// larger than this is rejected before any large allocation happens. Callers +// that need larger members can either pass a per-call `maxSize` or raise the +// module default with `setMaxZipContentSize()`. The streaming read paths +// (`contentIterator()`, `ZipFile.prototype.stream()`) are bounded-memory by +// design and are not subject to this default. +const DEFAULT_MAX_ZIP_CONTENT_SIZE = 256 * 1024 * 1024; // 256 MiB +let maxZipContentSize = DEFAULT_MAX_ZIP_CONTENT_SIZE; + +/** + * @returns {number} + */ +function getMaxZipContentSize() { + return maxZipContentSize; +} + +/** + * @param {number} size + * @returns {void} + */ +function setMaxZipContentSize(size) { + validateInteger(size, 'size', 0); + maxZipContentSize = size; +} + +module.exports = { + DEFAULT_MAX_ZIP_CONTENT_SIZE, + getMaxZipContentSize, + setMaxZipContentSize, +}; diff --git a/lib/internal/zip/dos.js b/lib/internal/zip/dos.js new file mode 100644 index 000000000000..d3a41323b87e --- /dev/null +++ b/lib/internal/zip/dos.js @@ -0,0 +1,138 @@ +'use strict'; + +// DOS/IBM legacy decoding: MS-DOS date/time fields (sec. 4.4.6) and the +// historical Code Page 437 name/comment encoding, plus the modern +// UTF-8/Unicode-Path handling layered on top of them. + +const { + Date, + NumberIsNaN, + StringFromCharCode, +} = primordials; + +const { + codes: { + ERR_INVALID_ARG_VALUE, + }, +} = require('internal/errors'); +const { crc32: crc32Native } = internalBinding('zlib'); +const { isUtf8 } = internalBinding('buffer'); +const { + FLAG_UTF8, + EXTRA_ID_UNICODE_PATH, +} = require('internal/zip/constants'); +const { forEachExtraField } = require('internal/zip/extra-fields'); + +// DOS date/time (sec. 4.4.6): local time by convention. +// time: bits 0-4 seconds/2, 5-10 minutes, 11-15 hours +// date: bits 0-4 day, 5-8 month, 9-15 years since 1980 +function decodeDosDateTime(time, date) { + // A zeroed/absent date field has month 0 and day 0, both invalid; the DOS + // epoch is 1980-01-01. Month/day 0 are treated as 1 so a zero field decodes + // to 1980-01-01 (and re-encodes to the same value). + return new Date( + ((date >>> 9) & 0x7f) + 1980, + ((date >>> 5) & 0x0f || 1) - 1, + (date & 0x1f) || 1, + (time >>> 11) & 0x1f, + (time >>> 5) & 0x3f, + (time & 0x1f) * 2, + ); +} + +// Encode a Date into packed MS-DOS time/date fields (sec. 4.4.6), clamping to +// the representable range 1980-01-01 .. 2107-12-31. +function encodeDosDateTime(value) { + const year = value.getFullYear(); + if (NumberIsNaN(year)) { + throw new ERR_INVALID_ARG_VALUE('modified', value, 'must be a valid Date'); + } + if (year < 1980) return { time: 0, date: (1 << 5) | 1 }; // Clamp to 1980-01-01 00:00:00 + if (year > 2107) { + // Clamp to 2107-12-31 23:59:58 + return { + time: (23 << 11) | (59 << 5) | 29, + date: (127 << 9) | (12 << 5) | 31, + }; + } + const date = + ((year - 1980) << 9) | ((value.getMonth() + 1) << 5) | value.getDate(); + const time = + (value.getHours() << 11) | + (value.getMinutes() << 5) | + (value.getSeconds() >>> 1); + return { time, date }; +} + +// Code Page 437 high half (0x80-0xFF) -> Unicode. Names without the UTF-8 +// language-encoding flag (bit 11) are historically CP437, which every real +// tool (Info-ZIP, Windows Explorer) assumes; bytes 0x00-0x7F are ASCII. +const CP437_HIGH = [ + 0x00c7, 0x00fc, 0x00e9, 0x00e2, 0x00e4, 0x00e0, 0x00e5, 0x00e7, + 0x00ea, 0x00eb, 0x00e8, 0x00ef, 0x00ee, 0x00ec, 0x00c4, 0x00c5, + 0x00c9, 0x00e6, 0x00c6, 0x00f4, 0x00f6, 0x00f2, 0x00fb, 0x00f9, + 0x00ff, 0x00d6, 0x00dc, 0x00a2, 0x00a3, 0x00a5, 0x20a7, 0x0192, + 0x00e1, 0x00ed, 0x00f3, 0x00fa, 0x00f1, 0x00d1, 0x00aa, 0x00ba, + 0x00bf, 0x2310, 0x00ac, 0x00bd, 0x00bc, 0x00a1, 0x00ab, 0x00bb, + 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556, + 0x2555, 0x2563, 0x2551, 0x2557, 0x255d, 0x255c, 0x255b, 0x2510, + 0x2514, 0x2534, 0x252c, 0x251c, 0x2500, 0x253c, 0x255e, 0x255f, + 0x255a, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256c, 0x2567, + 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256b, + 0x256a, 0x2518, 0x250c, 0x2588, 0x2584, 0x258c, 0x2590, 0x2580, + 0x03b1, 0x00df, 0x0393, 0x03c0, 0x03a3, 0x03c3, 0x00b5, 0x03c4, + 0x03a6, 0x0398, 0x03a9, 0x03b4, 0x221e, 0x03c6, 0x03b5, 0x2229, + 0x2261, 0x00b1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00f7, 0x2248, + 0x00b0, 0x2219, 0x00b7, 0x221a, 0x207f, 0x00b2, 0x25a0, 0x00a0, +]; + +// Decode a CP437-encoded name/comment to a JS string via the table above. +function decodeCp437(buffer) { + let out = ''; + for (let i = 0; i < buffer.length; i++) { + const b = buffer[i]; + out += StringFromCharCode(b < 0x80 ? b : CP437_HIGH[b - 0x80]); + } + return out; +} + +// The UTF-8 name from an Info-ZIP Unicode Path extra field (sec. 4.6.9), but +// only when its version is 1 and its CRC-32 matches the standard-field name +// bytes (so a stale extra left over from a rename is ignored). Otherwise null. +function unicodePathName(extra, standardNameBuffer) { + let result = null; + forEachExtraField(extra, (id, body) => { + if (result !== null || id !== EXTRA_ID_UNICODE_PATH || body.length < 5) return; + if (body[0] !== 1) return; + // The crc32 binding returns an unsigned uint32, directly comparable. + if (body.readUInt32LE(1) !== crc32Native(standardNameBuffer, 0)) return; + result = body.toString('utf8', 5); + }); + return result; +} + +// Decode an entry name: prefer a valid Unicode Path extra field, else the +// UTF-8/CP437 heuristic below. +function decodeZipName(nameBuffer, flags, extra) { + if (extra?.length) { + const unicode = unicodePathName(extra, nameBuffer); + if (unicode !== null) return unicode; + } + return decodeZipText(nameBuffer, flags); +} + +// Decode a name/comment: UTF-8 when bit 11 says so, or when the bytes are +// valid UTF-8 anyway - plenty of real tools (pre-JDK7 java.util.zip among +// them) wrote UTF-8 names without ever setting the flag. Only genuinely +// non-UTF-8 bytes take the historical CP437 default. +function decodeZipText(buffer, flags) { + if ((flags & FLAG_UTF8) || isUtf8(buffer)) return buffer.toString('utf8'); + return decodeCp437(buffer); +} + +module.exports = { + decodeDosDateTime, + encodeDosDateTime, + decodeZipName, + decodeZipText, +}; diff --git a/lib/internal/zip/entry.js b/lib/internal/zip/entry.js new file mode 100644 index 000000000000..4eab161db9b2 --- /dev/null +++ b/lib/internal/zip/entry.js @@ -0,0 +1,962 @@ +'use strict'; + +// `ZipEntry`: a single archive member. Reads (buffered and streaming), +// (de)serialization, and the `create()`/`createSync()`/`createStream()`/ +// `createSymlink()` builders, plus the `createEntryMeta()` helper that +// normalizes their options into the internal metadata record. + +const { + ArrayPrototypePush, + ArrayPrototypeSlice, + ArrayPrototypeSort, + Date, + DateNow, + JSONStringify, + MathFloor, + MathMin, + NumberMAX_SAFE_INTEGER, + StringPrototypeEndsWith, + SymbolAsyncDispose, + SymbolAsyncIterator, + SymbolDispose, + SymbolIterator, +} = primordials; + +const { + codes: { + ERR_INVALID_ARG_TYPE, + ERR_INVALID_ARG_VALUE, + ERR_INVALID_STATE, + ERR_ZIP_ENTRY_TOO_LARGE, + ERR_ZIP_INVALID_ARCHIVE, + ERR_ZIP_UNSUPPORTED_FEATURE, + }, +} = require('internal/errors'); +const { + validateInteger, + validateString, + validateUint32, +} = require('internal/validators'); +const { + isDate, + isUint8Array, +} = require('internal/util/types'); +const { Buffer, kMaxLength } = require('buffer'); +const { crc32: crc32Native } = internalBinding('zlib'); +const { + EMPTY_BUFFER, + SIG_LOCAL_FILE_HEADER, + SENTINEL16, + FLAG_DATA_DESCRIPTOR, + FLAG_UTF8, + MADE_BY_UNIX, + METHOD_STORE, + METHOD_DEFLATE, + METHOD_ZSTD, + S_IFREG, + S_IFDIR, + S_IFLNK, + S_IFMT, + READ_CHUNK_SIZE, + kFinalize, + kPromote, +} = require('internal/zip/constants'); +const { + toBuffer, + validateArchiveRange, +} = require('internal/zip/binary'); +const { + extraFieldMtime, + stripZip64Extra, +} = require('internal/zip/extra-fields'); +const { + decodeZipName, + decodeZipText, +} = require('internal/zip/dos'); +const { + CentralFileHeader, + LocalFileHeader, + findArchiveEnd, +} = require('internal/zip/headers'); +const { + buildLocalHeader, + buildCentralHeader, + buildDataDescriptor64, +} = require('internal/zip/header-builders'); +const { + deflateRawAsync, + deflateRawSync, + zstdCompressAsync, + zstdCompressSync, + deflateRawStream, + zstdCompressStream, + decodeMemberStream, + decodeMemberAsync, + decodeMemberSync, +} = require('internal/zip/compression'); +const { + readFdFully, + readFdFullySync, +} = require('internal/zip/fs-util'); +const { getMaxZipContentSize } = require('internal/zip/content-size'); + +// The whole (UTC) second to record in an extended-timestamp extra field when +// `mtimeMs` cannot be represented exactly by the 2-second-resolution, +// local-time DOS date/time fields (sub-second parts and odd seconds alike), +// or null when the DOS fields suffice or the value does not fit the extra +// field's signed 32-bit Unix-seconds range (through 2038). One rule, shared +// by createEntryMeta() and #finalizeMeta() so the two cannot drift. +function extendedMtimeSeconds(mtimeMs) { + const seconds = MathFloor(mtimeMs / 1000); + return (mtimeMs % 2000 !== 0 && seconds >= -2147483648 && seconds <= 2147483647) ? + seconds : null; +} + +// Normalize the public builder options into the internal metadata record: +// name/comment bytes, the UTF-8 flag, the Unix mode packed into the external +// attributes (sec. 4.4.15), and the DOS/extended-timestamp fields. +function createEntryMeta(filename, options) { + validateString(filename, 'filename'); + const name = Buffer.from(filename, 'utf8'); + if (name.length === 0) { + throw new ERR_INVALID_ARG_VALUE('filename', filename, 'must not be empty'); + } + if (name.length > SENTINEL16) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + 'the entry name must not exceed 65535 bytes when encoded as UTF-8'); + } + let comment = EMPTY_BUFFER; + if (options?.comment !== undefined) { + validateString(options.comment, 'options.comment'); + comment = Buffer.from(options.comment, 'utf8'); + if (comment.length > SENTINEL16) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + 'the entry comment must not exceed 65535 bytes when encoded as UTF-8'); + } + } + const isSymlink = options?.symlink === true; + const isDirectory = !isSymlink && StringPrototypeEndsWith(filename, '/'); + const mode = options?.mode ?? (isSymlink ? 0o777 : isDirectory ? 0o755 : 0o644); + validateUint32(mode, 'options.mode'); + // Default to the current time at the DOS fields' 2-second resolution, so a + // default entry needs no extended-timestamp extra field (see below). + const modified = options?.modified ?? new Date(MathFloor(DateNow() / 2000) * 2000); + if (!isDate(modified)) { + throw new ERR_INVALID_ARG_TYPE('options.modified', 'Date', modified); + } + if (options?.method !== undefined && + options.method !== 'deflate' && options.method !== 'store' && options.method !== 'zstd') { + throw new ERR_INVALID_ARG_VALUE( + 'options.method', options.method, "must be 'deflate', 'store', or 'zstd'"); + } + const typeBits = isSymlink ? S_IFLNK : isDirectory ? S_IFDIR : S_IFREG; + const unixAttrs = (typeBits | (mode & 0o7777)) & SENTINEL16; + const external = ((unixAttrs << 16) | (isDirectory ? 0x10 : 0)) >>> 0; + // Record the whole (UTC) second in an extended-timestamp extra field when + // the DOS fields cannot represent the time exactly; see + // extendedMtimeSeconds(). + const extendedMtime = extendedMtimeSeconds(modified.getTime()); + return { + name, + comment, + extra: EMPTY_BUFFER, + flags: FLAG_UTF8, + method: 0, + crc: 0, + compressedSize: 0, + uncompressedSize: 0, + modified, + extendedMtime, + external, + internal: 0, + madeBy: MADE_BY_UNIX, + pending: true, + }; +} + +/** + * A single file or directory inside a ZIP archive: reading, writing, and + * (de)serializing one archive member. + */ +class ZipEntry { + #central; + #local; + #content; + #source = null; + #meta = null; + #serialized = false; + // When #fd is non-null the entry is "file-backed": it holds no content + // buffer, only a descriptor *handle* ({ fd, closed }) shared with the + // owning ZipFile plus the local-header offset, and reads its compressed + // bytes from disk on demand (see #compressedBytes/#rawChunks). The shared + // handle lets close() invalidate every outstanding entry at once, so a + // read never falls through to a bare (possibly reused) fd number. + // #contentOffset caches the resolved start of the compressed data (a + // number, not a buffer) once the local header has been read. + #fd = null; + #localOffset = -1; + #contentOffset = -1; + + /** + * @private + */ + constructor(central, local, content, fd = null, localOffset = -1) { + this.#central = central; + this.#local = local; + this.#content = content; + this.#fd = fd; + this.#localOffset = localOffset; + } + + // Whether the content is stored in compressed form, whatever the method. + get compressed() { return this.method !== METHOD_STORE; } + get rawContent() { return this.#content; } + get method() { + return this.#meta ? this.#meta.method : this.#central.compressionMethod; + } + get flags() { + return this.#meta ? this.#meta.flags : (this.#local ?? this.#central).flags; + } + get crc32() { + if (this.#meta) { + this.#assertNotPending(); + return this.#meta.crc; + } + return this.#central.crc32; + } + get name() { + // The central directory is authoritative; a mismatched local-header name + // is deliberately ignored (defends against parser-confusion attacks). + // Both branches use the same decoding (Unicode Path extra, then + // UTF-8/CP437), so serialization - which snapshots the raw bytes into + // #meta - never changes what this getter reports. + return this.#meta ? + decodeZipName(this.#meta.name, this.#meta.flags, this.#meta.extra) : + this.#central.fileName; + } + get nameBuffer() { + return this.#meta ? this.#meta.name : this.#central.fileNameBuffer; + } + get comment() { + return this.#meta ? + decodeZipText(this.#meta.comment, this.#meta.flags) : + this.#central.fileComment; + } + get size() { + if (this.#meta) { + this.#assertNotPending(); + return this.#meta.uncompressedSize; + } + return this.#central.uncompressedSize; + } + get compressedSize() { + if (this.#meta) { + this.#assertNotPending(); + return this.#meta.compressedSize; + } + return this.#central.compressedSize; + } + get modified() { + if (this.#meta) return this.#meta.modified; + // Prefer an extra-field timestamp (absolute, higher-resolution) over the + // coarse local-time DOS date/time fields when a foreign archive carries + // one; consult both the central and local headers. A file-backed entry + // starts out with only its central header, but some tools (7-Zip, for + // one) write their high-fidelity timestamp only into the local header - + // resolve it lazily (a small, one-time positioned read) rather than + // silently reporting a coarser time than the archive carries, and fall + // back to the central data when the local header cannot be read. + if (this.#fd !== null && this.#local === null) { + try { + this.#resolveLocalHeaderSync(); + } catch { + // A malformed local header fails loudly on the content read paths; + // for metadata, the central directory alone has to do. + } + } + return extraFieldMtime(this.#central.extraField, this.#local?.extraField) ?? + this.#central.lastModified; + } + get mode() { + // The external attributes' high 16 bits hold Unix permissions only when + // the entry was made by a Unix host (sec. 4.4.2/4.4.15) - the same rule + // as `CentralFileHeader.prototype.mode`, which the non-meta branch + // defers to. + if (this.#meta) { + return this.#meta.madeBy === MADE_BY_UNIX ? + (this.#meta.external >>> 16) & 0o7777 : 0; + } + return this.#central.mode; + } + get isSymlink() { + // Derived from the made-by host and the external attributes' Unix type + // bits in both branches, so it survives serialization (which preserves + // both verbatim); this also makes a fresh `createSymlink()` entry report + // itself as one. + if (this.#meta) { + return this.#meta.madeBy === MADE_BY_UNIX && + ((this.#meta.external >>> 16) & S_IFMT) === S_IFLNK; + } + return this.#central.isSymlink; + } + get isFile() { return !this.isDirectory && !this.isSymlink; } + get isDirectory() { return StringPrototypeEndsWith(this.name, '/'); } + + // Guard: reject metadata/content reads on a write-streaming entry whose + // sizes and CRC are not yet known (still pending serialization). + #assertNotPending() { + if (this.#meta?.pending) { + throw new ERR_INVALID_STATE( + 'this streaming entry has not finished serializing yet'); + } + } + + // Snapshot this (parsed) entry's central-directory data into a + // re-serializable #meta record, memoized. Needed so a round-tripped entry + // can be re-emitted from stable, extra-field-aware values rather than raw + // header bytes; clears the data-descriptor flag (see below). + #finalizeMeta() { + if (this.#meta) { + this.#assertNotPending(); + return this.#meta; + } + const central = this.#central; + // Descriptor entries (bit 3) are re-emitted with known sizes/CRC and bit + // 3 cleared: full re-serialization emits a fresh local header from this + // same record, so it never reproduces a bit-3 local header without a + // data descriptor. (This invariant does NOT hold for `ZipFile`'s + // in-place central-directory rewrite, which leaves local headers on disk + // untouched - that path re-asserts bit 3 via `[kFinalize]`; see there.) + // Sizes come from the central directory + // (Zip64-aware); Zip64 extras are regenerated as needed, and all other + // extra-field records (Unicode Path, NTFS/UT timestamps, ...) are + // preserved so a re-serialized entry keeps its name encoding and + // timestamps. + const extra = stripZip64Extra(central.extraField); + // Snapshot the resolved (extra-field-aware) time, not the raw DOS field, + // so serialization does not degrade what `modified` reports. When that + // time cannot be represented exactly in the DOS fields and no preserved + // extra record carries it, record it in an extended-timestamp extra + // (see extendedMtimeSeconds(); same rule as createEntryMeta()). + const modified = this.modified; + const extendedMtime = extraFieldMtime(extra) === null ? + extendedMtimeSeconds(modified.getTime()) : null; + const meta = { + name: central.fileNameBuffer, + comment: central.fileCommentBuffer, + extra, + flags: central.flags & ~FLAG_DATA_DESCRIPTOR, + method: central.compressionMethod, + crc: central.crc32, + compressedSize: central.compressedSize, + uncompressedSize: central.uncompressedSize, + modified, + extendedMtime, + external: central.externalFileAttributes, + internal: central.internalFileAttributes, + // Preserve the creator's host byte (sec. 4.4.2): the external + // attributes are only interpretable relative to it. + madeBy: central.version >>> 8, + pending: false, + }; + this.#meta = meta; + return meta; + } + + // The live numeric descriptor for a file-backed entry, or a clean state + // error if the ZipFile that owns it has since been closed - never a raw + // (possibly OS-reused) fd number. Returns null for an in-memory entry. + // A file-backed entry holds a descriptor *handle* ({ fd, closed }) shared + // with its ZipFile, not a bare fd, so close() is observable here. + #liveDescriptor() { + const handle = this.#fd; + if (handle === null) return null; + if (handle.closed) { + throw new ERR_INVALID_STATE( + 'cannot read a ZipEntry after its backing ZipFile has been closed'); + } + return handle.fd; + } + + // Read (and cache) this file-backed entry's local file header - whose + // length (fixed 30 bytes plus variable name/extra fields) is only known + // from the file itself, not from the central directory. Resolving it also + // yields the offset where the compressed data begins, and gives the + // `modified` getter access to a local-header-only timestamp extra. + async #resolveLocalHeader() { + if (this.#local !== null) return this.#local; + const fd = this.#liveDescriptor(); + const fixed = Buffer.allocUnsafe(30); + await readFdFully(fd, fixed, this.#localOffset); + if (fixed.readUInt32LE(0) !== SIG_LOCAL_FILE_HEADER) { + throw new ERR_ZIP_INVALID_ARCHIVE( + `entry ${JSONStringify(this.name)} has an invalid local file header`); + } + const length = LocalFileHeader.length(fixed, 0); + const full = Buffer.allocUnsafe(length); + fixed.copy(full, 0); + if (length > 30) { + await readFdFully(fd, full.subarray(30), this.#localOffset + 30); + } + this.#local = new LocalFileHeader(full, 0); + this.#contentOffset = this.#localOffset + length; + return this.#local; + } + // Sync counterpart of #resolveLocalHeader(). + #resolveLocalHeaderSync() { + if (this.#local !== null) return this.#local; + const fd = this.#liveDescriptor(); + const fixed = Buffer.allocUnsafe(30); + readFdFullySync(fd, fixed, this.#localOffset); + if (fixed.readUInt32LE(0) !== SIG_LOCAL_FILE_HEADER) { + throw new ERR_ZIP_INVALID_ARCHIVE( + `entry ${JSONStringify(this.name)} has an invalid local file header`); + } + const length = LocalFileHeader.length(fixed, 0); + const full = Buffer.allocUnsafe(length); + fixed.copy(full, 0); + if (length > 30) { + readFdFullySync(fd, full.subarray(30), this.#localOffset + 30); + } + this.#local = new LocalFileHeader(full, 0); + this.#contentOffset = this.#localOffset + length; + return this.#local; + } + // The offset where this file-backed entry's compressed data begins, reading + // the local header first if that has not been resolved yet. + async #resolveContentOffset() { + if (this.#contentOffset < 0) await this.#resolveLocalHeader(); + return this.#contentOffset; + } + // Sync counterpart of #resolveContentOffset(). + #resolveContentOffsetSync() { + if (this.#contentOffset < 0) this.#resolveLocalHeaderSync(); + return this.#contentOffset; + } + // The in-memory raw bytes, or a clean state error when there are none - a + // write-streaming entry (`createStream()`) has no readable content until it + // has been serialized into a backing archive (after which `addEntry()` + // promotes it to file-backed; see [kPromote]). + #inMemoryCompressed() { + if (this.#content === null) { + throw new ERR_INVALID_STATE( + 'the content of a streaming entry is not available for reading'); + } + return this.#content; + } + // The entry's raw (still-compressed) bytes. For an in-memory entry this is + // the entry's retained buffer - shared memory, NOT a copy: it may alias + // the source archive (`ZipEntry.read()`) or the caller's original `data` + // (`create()` when storing). For a file-backed entry it is freshly read + // from disk and caller-owned. Paths that hand these bytes out undecoded + // (the store method) must copy the in-memory case; see `content()`. + async #compressedBytes() { + if (this.#fd === null) return this.#inMemoryCompressed(); + const size = this.compressedSize; + // A member at or beyond the maximum Buffer length cannot be materialized + // in one allocation; stream it instead. (kMaxLength equals the safe + // integer ceiling on 64-bit, so a larger size fails to parse anyway.) + if (size >= kMaxLength) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + `entry ${JSONStringify(this.name)} is too large to buffer ` + + `(${size} compressed bytes); use contentIterator() instead`); + } + const start = await this.#resolveContentOffset(); + const compressed = Buffer.allocUnsafe(size); + await readFdFully(this.#liveDescriptor(), compressed, start); + return compressed; + } + // Sync counterpart of #compressedBytes(). + #compressedBytesSync() { + if (this.#fd === null) return this.#inMemoryCompressed(); + const size = this.compressedSize; + if (size >= kMaxLength) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + `entry ${JSONStringify(this.name)} is too large to buffer ` + + `(${size} compressed bytes); use contentIterator() instead`); + } + const start = this.#resolveContentOffsetSync(); + const compressed = Buffer.allocUnsafe(size); + readFdFullySync(this.#liveDescriptor(), compressed, start); + return compressed; + } + // The entry's raw compressed bytes as a bounded-memory chunk stream, read + // straight from disk (file-backed entries only). Nothing is retained. + async *#rawChunks() { + this.#liveDescriptor(); + let pos = await this.#resolveContentOffset(); + let remaining = this.compressedSize; + while (remaining > 0) { + const take = MathMin(READ_CHUNK_SIZE, remaining); + const chunk = Buffer.allocUnsafe(take); + // Re-check per chunk: the ZipFile may be closed mid-stream. + await readFdFully(this.#liveDescriptor(), chunk, pos); + pos += take; + remaining -= take; + yield chunk; + } + } + // Sync counterpart of #rawChunks(). + *#rawChunksSync() { + this.#liveDescriptor(); + let pos = this.#resolveContentOffsetSync(); + let remaining = this.compressedSize; + while (remaining > 0) { + const take = MathMin(READ_CHUNK_SIZE, remaining); + const chunk = Buffer.allocUnsafe(take); + // Re-check per chunk: the ZipFile may be closed mid-stream. + readFdFullySync(this.#liveDescriptor(), chunk, pos); + pos += take; + remaining -= take; + yield chunk; + } + } + + /** + * Reads, decompresses, and (by default) CRC-32-verifies the whole entry + * into a single `Buffer`, enforcing the declared-size and `maxSize` limits. + * @param {{ verify?: boolean, maxSize?: number }} [options] + * @returns {Promise} + */ + async content(options) { + const declared = this.size; + const maxSize = options?.maxSize ?? getMaxZipContentSize(); + if (declared > maxSize) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + `entry ${JSONStringify(this.name)} declares ${declared} bytes, ` + + `exceeding the ${maxSize} byte limit`); + } + const compressed = await this.#compressedBytes(); + const data = await decodeMemberAsync(compressed, { + name: this.name, + flags: this.flags, + method: this.method, + crc32: this.crc32, + uncompressedSize: declared, + }, { verify: options?.verify, maxSize }); + // `data === compressed` only on the store path; copy the in-memory case + // (the entry's retained buffer, see #compressedBytes()) so the result is + // caller-owned on every path. + return data === compressed && this.#fd === null ? Buffer.from(data) : data; + } + + /** + * The synchronous counterpart of `content()`. Blocks the event loop and + * further JavaScript execution until the whole entry has been read and, if + * applicable, inflated - use only where synchronous I/O is appropriate + * (for example, short-lived scripts or startup code), not in code that + * must stay responsive. + * @param {{ verify?: boolean, maxSize?: number }} [options] + * @returns {Buffer} + */ + contentSync(options) { + const declared = this.size; + const maxSize = options?.maxSize ?? getMaxZipContentSize(); + if (declared > maxSize) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + `entry ${JSONStringify(this.name)} declares ${declared} bytes, ` + + `exceeding the ${maxSize} byte limit`); + } + const compressed = this.#compressedBytesSync(); + const data = decodeMemberSync(compressed, { + name: this.name, + flags: this.flags, + method: this.method, + crc32: this.crc32, + uncompressedSize: declared, + }, { verify: options?.verify, maxSize }); + // `data === compressed` only on the store path; copy the in-memory case + // so the result is caller-owned on every path (see content()). + return data === compressed && this.#fd === null ? Buffer.from(data) : data; + } + + // The raw (still-compressed) bytes as an async iterable, without buffering + // the whole member: straight from disk for a file-backed entry, or the + // single in-memory buffer otherwise. Throws synchronously for a pending + // write-streaming entry, whose content is not yet available for reading. + #rawSource() { + if (this.#fd !== null) return this.#rawChunks(); + const content = this.#inMemoryCompressed(); + return (async function* () { + if (content.length) yield content; + })(); + } + + /** + * Yields the entry's decompressed content as a bounded-memory async + * iterator of `Buffer` chunks, decompressing on the way and (by default) + * verifying CRC-32. For a file-backed entry (from `ZipFile.get()`) the + * compressed bytes are read from disk as they are consumed and nothing is + * retained. + * @param {{ verify?: boolean, maxSize?: number }} [options] + * @returns {AsyncGenerator} + */ + contentIterator(options) { + // No default maxSize here, unlike content(): streaming is the + // bounded-memory path for arbitrarily large members, so imposing content()'s + // buffer-oriented ceiling would defeat its purpose (and cap legitimate + // multi-gigabyte reads). Output is still bounded per chunk to the declared + // uncompressed size; a caller that wants an explicit cap passes + // options.maxSize. + return decodeMemberStream(this.#rawSource(), { + name: this.name, + flags: this.flags, + method: this.method, + crc32: this.crc32, + uncompressedSize: this.size, + }, options); + } + + // Synchronous serialization: emit the local file header (sec. 4.3.7) + // followed by the raw (already-compressed) content bytes. Streaming + // (source-backed) entries cannot be serialized this way. + *[SymbolIterator]() { + if (this.#source) { + throw new ERR_INVALID_STATE('a streaming entry cannot be serialized synchronously'); + } + const meta = this.#finalizeMeta(); + yield buildLocalHeader(meta); + if (this.#fd !== null) { + yield* this.#rawChunksSync(); + } else if (this.#content?.length) { + yield this.#content; + } + } + + // Asynchronous serialization. For a write-streaming entry, drain the + // source once, computing CRC-32 and sizes on the fly and emitting a Zip64 + // data descriptor after the content (sec. 4.3.9); otherwise defer to the + // buffered/file-backed paths. + async *[SymbolAsyncIterator]() { + const source = this.#source; + if (!source) { + if (this.#fd !== null) { + yield buildLocalHeader(this.#finalizeMeta()); + yield* this.#rawChunks(); + return; + } + yield* this[SymbolIterator](); + return; + } + if (this.#serialized) { + throw new ERR_INVALID_STATE('a streaming entry can only be serialized once'); + } + this.#serialized = true; + const meta = this.#meta; + yield buildLocalHeader(meta); + let state = 0; + let uncompressedSize = 0; + let compressedSize = 0; + const counted = (async function* () { + for await (const chunk of source) { + if (!isUint8Array(chunk)) { + throw new ERR_INVALID_ARG_TYPE('chunk', 'Uint8Array', chunk); + } + if (!chunk.length) continue; + state = crc32Native(chunk, state); + uncompressedSize += chunk.length; + yield chunk; + } + })(); + const output = meta.method === METHOD_DEFLATE ? deflateRawStream(counted) : + meta.method === METHOD_ZSTD ? zstdCompressStream(counted) : counted; + for await (const chunk of output) { + compressedSize += chunk.length; + yield chunk; + } + meta.crc = state; + meta.uncompressedSize = uncompressedSize; + meta.compressedSize = compressedSize; + meta.pending = false; + yield buildDataDescriptor64(meta.crc, compressedSize, uncompressedSize); + } + + // Release the entry's write-source, if it has one. Only a streaming entry + // (`createStream()`) owns a source - a caller-supplied `AsyncIterable`, + // often a file read stream holding a descriptor - and once that entry is + // handed to `createZipArchive()` the caller can no longer reach it, so the + // archive machinery disposes it when the archive finishes or its output + // stream is destroyed early (see `generateZipArchive()`); a caller holding + // an unused streaming entry can dispose it directly. In-memory and + // file-backed entries hold no source - a file-backed entry's descriptor + // belongs to the `ZipFile`, never to the entry - so disposing them is a + // no-op. Disposal is idempotent and marks the entry spent, so a disposed + // streaming entry can no longer be serialized. + #releaseSource() { + const source = this.#source; + this.#source = null; + this.#serialized = true; + return source; + } + [SymbolDispose]() { + const source = this.#releaseSource(); + if (source === null) return; + if (typeof source.destroy === 'function') source.destroy(); + else if (typeof source.return === 'function') source.return(); + } + async [SymbolAsyncDispose]() { + const source = this.#releaseSource(); + if (source === null) return; + if (typeof source[SymbolAsyncDispose] === 'function') await source[SymbolAsyncDispose](); + else if (typeof source.destroy === 'function') source.destroy(); + else if (typeof source.return === 'function') await source.return(); + } + + /** + * Builds this entry's central-directory header (sec. 4.3.12) recording its + * local-header start; called by the archive writer once the offset is fixed. + * + * `preserveDescriptorFlag` is set by `ZipFile`'s in-place + * central-directory rewrite, which never regenerates local headers: when + * the on-disk local header advertises a data descriptor (bit 3, which + * `#finalizeMeta()` clears for full re-serialization), the rebuilt central + * header must keep advertising it too, or the two headers would contradict + * each other (sec. 4.3.12 expects them to agree). Full re-serialization + * emits a fresh, bit-3-free local header from the same meta, so there the + * cleared flag is the consistent one. + * @private + * @param {number} localOffset + * @param {boolean} [preserveDescriptorFlag] + * @returns {Buffer} + */ + [kFinalize](localOffset, preserveDescriptorFlag = false) { + validateInteger(localOffset, 'localOffset', 0, NumberMAX_SAFE_INTEGER); + const meta = this.#finalizeMeta(); + if (preserveDescriptorFlag && this.#central !== null && + (this.#central.flags & FLAG_DATA_DESCRIPTOR) !== 0) { + return buildCentralHeader( + { ...meta, flags: meta.flags | FLAG_DATA_DESCRIPTOR }, localOffset); + } + return buildCentralHeader(meta, localOffset); + } + + // Rebind a just-serialized write-streaming entry to its on-disk copy so it + // stops being dead weight: after `addEntry()`/`addEntrySync()` writes the + // entry into `fd` at `localOffset` (its local-header start), the spent + // source is dropped and the entry becomes a readable, re-serializable + // file-backed entry (valid while `fd` stays open). Only a spent stream + // entry - one with neither an in-memory buffer nor an existing backing fd - + // is promoted; in-memory and already-file-backed entries are left as they + // are. The kept `#meta` still supplies the (now-final) name/sizes/crc. + [kPromote](fd, localOffset) { + if (this.#content !== null || this.#fd !== null) return; + this.#fd = fd; + this.#localOffset = localOffset; + this.#contentOffset = -1; + this.#source = null; + this.#serialized = false; + // The descriptor flag has served its purpose: the sizes and CRC are + // known now, and the fd-backed serialization path emits plain headers + // and never writes a descriptor. Left set, a re-serialization would + // advertise (bit 3) a data descriptor that never follows - a corrupt + // archive for any reader that honors the flag. + this.#meta.flags &= ~FLAG_DATA_DESCRIPTOR; + } + + /** + * Parses an in-memory archive, walking the central directory (sec. 4.3.12) + * and each referenced local header (sec. 4.3.7), and yields one read-only + * `ZipEntry` per member. + * @param {Buffer | TypedArray | DataView | ArrayBuffer} buffer + * @yields {ZipEntry} + */ + static *read(buffer) { + const buf = toBuffer(buffer, 'buffer'); + yield* readArchiveEntries(buf, findArchiveEnd(buf)); + } + + /** + * Builds a ready-to-serialize entry from in-memory `data`, compressing it + * with the chosen method (falling back to store when that does not shrink + * it) and recording the CRC-32 and sizes. When the entry ends up stored + * (explicitly or via the fallback) it retains `data`'s memory rather than + * copying it, and the CRC-32 is recorded now - mutating `data` afterwards + * would corrupt the entry on write. + * @param {string} filename + * @param {Buffer | TypedArray | DataView | ArrayBuffer} data + * @param {{ + * comment?: string, + * mode?: number, + * modified?: Date, + * method?: 'deflate' | 'store' | 'zstd', + * }} [options] + * @returns {Promise} + */ + // Shared head of create()/createSync(): validate, snapshot the CRC-32 and + // uncompressed size, and pick the compression method (store is forced for + // directories and empty content). The compression pass itself is the only + // thing the async/sync builders do differently. + static #prepareCreate(filename, data, options) { + const meta = createEntryMeta(filename, options); + const content = toBuffer(data, 'data'); + const isDirectory = StringPrototypeEndsWith(filename, '/'); + if (isDirectory && content.length) { + throw new ERR_INVALID_ARG_VALUE('data', data, 'must be empty for a directory entry'); + } + meta.crc = crc32Native(content, 0); + meta.uncompressedSize = content.length; + const method = + isDirectory || content.length === 0 || options?.method === 'store' ? METHOD_STORE : + options?.method === 'zstd' ? METHOD_ZSTD : METHOD_DEFLATE; + return { meta, content, method }; + } + + // Shared tail of create()/createSync(): keep the compressed bytes only + // when the pass actually shrank the content (otherwise store the original, + // which also retains the caller's memory - see create()'s JSDoc), fill in + // the final sizes, and construct the entry. `compressed` is null when + // `method` is store. + static #finishCreate(meta, content, method, compressed) { + let finalContent = content; + if (compressed !== null && compressed.length < content.length) { + finalContent = compressed; + } else if (compressed !== null) { + method = METHOD_STORE; // Compression did not help; fall back to storing + } + meta.method = method; + meta.compressedSize = finalContent.length; + meta.pending = false; + const entry = new ZipEntry(null, null, finalContent); + entry.#meta = meta; + return entry; + } + + static async create(filename, data, options) { + const { meta, content, method } = ZipEntry.#prepareCreate(filename, data, options); + const compressed = + method === METHOD_DEFLATE ? await deflateRawAsync(content) : + method === METHOD_ZSTD ? await zstdCompressAsync(content) : null; + return ZipEntry.#finishCreate(meta, content, method, compressed); + } + + /** + * The synchronous counterpart of `create()`. Blocks the event loop and + * further JavaScript execution until done (including the deflate pass); + * see `contentSync()`. Retains `data`'s memory when storing, same as + * `create()`. + * @param {string} filename + * @param {Buffer | TypedArray | DataView | ArrayBuffer} data + * @param {{ + * comment?: string, + * mode?: number, + * modified?: Date, + * method?: 'deflate' | 'store' | 'zstd', + * }} [options] + * @returns {ZipEntry} + */ + static createSync(filename, data, options) { + const { meta, content, method } = ZipEntry.#prepareCreate(filename, data, options); + const compressed = + method === METHOD_DEFLATE ? deflateRawSync(content) : + method === METHOD_ZSTD ? zstdCompressSync(content) : null; + return ZipEntry.#finishCreate(meta, content, method, compressed); + } + + /** + * Builds a write-streaming entry whose content is drained from `source` + * only at serialization time; its sizes/CRC are unknown until then, so it + * sets the data-descriptor flag (bit 3, sec. 4.3.9) and stays pending. + * @param {string} filename + * @param {AsyncIterable} source + * @param {{ comment?: string, mode?: number, modified?: Date, method?: 'deflate' | 'store' | 'zstd' }} [options] + * @returns {ZipEntry} + */ + static createStream(filename, source, options) { + const meta = createEntryMeta(filename, options); + if (StringPrototypeEndsWith(filename, '/')) { + throw new ERR_INVALID_ARG_VALUE('filename', filename, 'a directory entry cannot be streamed'); + } + meta.flags |= FLAG_DATA_DESCRIPTOR; + meta.method = options?.method === 'store' ? METHOD_STORE : + options?.method === 'zstd' ? METHOD_ZSTD : METHOD_DEFLATE; + meta.pending = true; + const entry = new ZipEntry(null, null, null); + entry.#meta = meta; + entry.#source = source; + return entry; + } + + /** + * Creates a symbolic-link entry: a stored entry whose content is the link + * target and whose Unix mode type bits are `S_IFLNK`. + * @param {string} filename + * @param {string} target The link target path. + * @param {{ comment?: string, mode?: number, modified?: Date }} [options] + * @returns {ZipEntry} + */ + static createSymlink(filename, target, options) { + validateString(target, 'target'); + const meta = createEntryMeta(filename, { + __proto__: null, + comment: options?.comment, + mode: options?.mode, + modified: options?.modified, + symlink: true, + }); + const content = Buffer.from(target, 'utf8'); + meta.crc = crc32Native(content, 0); + meta.uncompressedSize = content.length; + meta.method = METHOD_STORE; + meta.compressedSize = content.length; + meta.pending = false; + const entry = new ZipEntry(null, null, content); + entry.#meta = meta; + return entry; + } +} + +// Walk the central directory described by `end` (a findArchiveEnd() result +// for `buf`), yielding one read-only ZipEntry per member. Split from +// `ZipEntry.read()` so `ZipBuffer` - which also needs the archive-end record +// for its comment - can locate the archive end once and share the result +// instead of scanning for it twice. All records are parsed and their member +// ranges cross-checked before the first entry is yielded: members must be +// disjoint and precede the central directory, or one small file could quote +// the same data region from N records - the "quoted overlap" zip-bomb shape +// (CVE-2024-0450 in other implementations). Here the local headers have +// been read, so the check uses each member's exact end (`ZipFile` applies +// the same rule with a lower bound; see `validateMemberBounds()`). +function* readArchiveEntries(buf, end) { + let pos = end.centralDirectoryOffset; + const cdEnd = end.centralDirectoryOffset + end.centralDirectorySize; + const parsed = []; + for (let index = 0; index < end.totalRecords; index++) { + const central = new CentralFileHeader(buf, pos); + if (pos + central.byteLength > cdEnd) { + throw new ERR_ZIP_INVALID_ARCHIVE('central directory header is out of bounds'); + } + if (central.diskNumber !== 0) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported'); + } + const localOffset = central.localFileHeaderOffset + end.prefix; + const local = new LocalFileHeader(buf, localOffset); + const dataStart = localOffset + local.byteLength; + const length = central.compressedSize; + validateArchiveRange(buf, dataStart, length, 'entry data'); + const content = length ? buf.subarray(dataStart, dataStart + length) : EMPTY_BUFFER; + ArrayPrototypePush(parsed, { + entry: new ZipEntry(central, local, content), + start: localOffset, + dataEnd: dataStart + length, + }); + pos = central.byteOffset + central.byteLength; + } + // Sort a separate range list; entries themselves are yielded in central + // directory order. + const ranges = ArrayPrototypeSort(ArrayPrototypeSlice(parsed), (a, b) => a.start - b.start); + for (let i = 0; i < ranges.length; i++) { + const bound = i + 1 < ranges.length ? ranges[i + 1].start : end.centralDirectoryOffset; + if (ranges[i].dataEnd > bound) { + throw new ERR_ZIP_INVALID_ARCHIVE( + `entry ${JSONStringify(ranges[i].entry.name)} overlaps the next ` + + 'entry or the central directory (possible zip bomb)'); + } + } + for (let i = 0; i < parsed.length; i++) yield parsed[i].entry; +} + +module.exports = { + createEntryMeta, + readArchiveEntries, + ZipEntry, +}; diff --git a/lib/internal/zip/extra-fields.js b/lib/internal/zip/extra-fields.js new file mode 100644 index 000000000000..0816268411f1 --- /dev/null +++ b/lib/internal/zip/extra-fields.js @@ -0,0 +1,199 @@ +'use strict'; + +// TLV extra-field parsing and building (sec. 4.5): the generic record walker, +// the Zip64 extended-information field, modification-time extras +// (NTFS/UT/UX), the round-trip-preserving strip of Zip64 records, and the +// extended-timestamp builder used on the write path. + +const { + ArrayPrototypePush, + Date, + Number, +} = primordials; + +const { + codes: { + ERR_ZIP_INVALID_ARCHIVE, + }, +} = require('internal/errors'); +const { Buffer } = require('buffer'); +const { + EMPTY_BUFFER, + ZIP64_EXTRA_ID, + EXTRA_ID_NTFS, + EXTRA_ID_EXT_TIMESTAMP, + EXTRA_ID_UNIX_OLD, +} = require('internal/zip/constants'); +const { readSafeUint64 } = require('internal/zip/binary'); + +// Zip64 extended information extra field (sec. 4.5.3). Walks the TLV records +// itself, and unlike forEachExtraField() below it throws on a malformed +// record: it only runs when a classic header field holds an overflow +// sentinel, so the Zip64 record is required and a malformed extra field +// hides required data. +function parseZip64Extra(extra, want) { + const wanted = + want.uncompressedSize || + want.compressedSize || + want.localFileHeaderOffset || + want.diskNumber; + if (!wanted) return {}; + let pos = 0; + while (pos + 4 <= extra.length) { + const id = extra.readUInt16LE(pos); + const size = extra.readUInt16LE(pos + 2); + if (pos + 4 + size > extra.length) { + throw new ERR_ZIP_INVALID_ARCHIVE('extra field is malformed'); + } + if (id === ZIP64_EXTRA_ID) { + const result = {}; + const body = pos + 4; + const end = body + size; + // APPNOTE 4.5.3: the field order is fixed - uncompressed size (8), + // compressed size (8), local header offset (8), disk number (4) - and + // each field MUST appear only when the corresponding classic field + // holds its overflow sentinel. When the record length matches exactly + // the fields the sentinels call for, parse them packed per spec. + // Real-world writers violate the "only" rule and emit fields for + // non-sentinel values too (commonly all four); such a record is longer + // than the wanted set, and is instead parsed positionally against the + // full layout - the fixed order makes both readings unambiguous. + const wantedSize = + (want.uncompressedSize ? 8 : 0) + + (want.compressedSize ? 8 : 0) + + (want.localFileHeaderOffset ? 8 : 0) + + (want.diskNumber ? 4 : 0); + const packed = size === wantedSize; + let cursor = body; + const take = (fullLayoutOffset, bytes) => { + const at = packed ? cursor : body + fullLayoutOffset; + if (at + bytes > end) { + throw new ERR_ZIP_INVALID_ARCHIVE( + 'the Zip64 extended information extra field is truncated'); + } + cursor += bytes; + return bytes === 8 ? readSafeUint64(extra, at) : extra.readUInt32LE(at); + }; + if (want.uncompressedSize) result.uncompressedSize = take(0, 8); + if (want.compressedSize) result.compressedSize = take(8, 8); + if (want.localFileHeaderOffset) result.localFileHeaderOffset = take(16, 8); + if (want.diskNumber) result.diskNumber = take(24, 4); + return result; + } + pos += 4 + size; + } + throw new ERR_ZIP_INVALID_ARCHIVE( + 'a field is 0xFFFFFFFF but the Zip64 extended information extra field is missing'); +} + +// Walk TLV extra-field records - id(2), size(2), body(size) - invoking `cb` +// per record. Stops silently at the first malformed/overrunning record - +// deliberately laxer than parseZip64Extra() above: this walker only feeds +// advisory metadata (timestamps, Unicode names), and a malformed extra +// should not make the archive unreadable. +function forEachExtraField(extra, cb) { + let pos = 0; + while (pos + 4 <= extra.length) { + const id = extra.readUInt16LE(pos); + const size = extra.readUInt16LE(pos + 2); + if (pos + 4 + size > extra.length) break; + cb(id, extra.subarray(pos + 4, pos + 4 + size)); + pos += 4 + size; + } +} + +// The mtime from an NTFS extra field (id 0x000a, sec. 4.5.5): a reserved +// dword then tagged sub-records; tag 1 carries the FILETIME mtime/atime/ctime. +function parseNtfsMtime(body) { + let result = null; + let pos = 4; // Skip the reserved dword. + while (pos + 4 <= body.length) { + const tag = body.readUInt16LE(pos); + const size = body.readUInt16LE(pos + 2); + if (pos + 4 + size > body.length) break; + if (tag === 1 && size >= 8) { + // Windows FILETIME: 100 ns ticks since 1601-01-01 UTC. + const ticks = body.readBigUInt64LE(pos + 4); + result = new Date(Number(ticks / 10000n) - 11644473600000); + } + pos += 4 + size; + } + return result; +} + +// The mtime from an Info-ZIP extended timestamp extra field ("UT", 0x5455; +// listed in APPNOTE's third-party ID table sec. 4.6.1, format defined in +// Info-ZIP's extrafld.txt). +function parseExtTimestampMtime(body) { + // flags(1) then present times; bit 0 => mtime present (signed Unix seconds). + if (body.length < 5 || (body[0] & 1) === 0) return null; + return new Date(body.readInt32LE(1) * 1000); +} + +// The mtime from an Info-ZIP original Unix extra field ("UX", id 0x5855). +function parseUnixOldMtime(body) { + // atime(4), mtime(4) as signed Unix seconds (uid/gid follow only locally). + if (body.length < 8) return null; + return new Date(body.readInt32LE(4) * 1000); +} + +// The highest-fidelity modification time carried in the given extra fields, or +// null when none is present. NTFS (100 ns) beats the extended timestamp (1 s, +// UTC) beats Info-ZIP Unix (1 s); all are absolute instants, unlike the coarse +// local-time DOS date/time fields. +function extraFieldMtime(...extras) { + let ntfs = null; + let ext = null; + let unix = null; + for (const extra of extras) { + if (!extra?.length) continue; + forEachExtraField(extra, (id, body) => { + if (id === EXTRA_ID_NTFS) ntfs ??= parseNtfsMtime(body); + else if (id === EXTRA_ID_EXT_TIMESTAMP) ext ??= parseExtTimestampMtime(body); + else if (id === EXTRA_ID_UNIX_OLD) unix ??= parseUnixOldMtime(body); + }); + } + return ntfs ?? ext ?? unix; +} + +// A copy of `extra` without any Zip64 record (id 0x0001): Zip64 data is +// regenerated from the final sizes/offsets on serialization, but every other +// record (Unicode Path, NTFS/UT timestamps, ...) must survive a round trip - +// dropping them silently renames entries whose display name lives in the +// Unicode Path extra and discards high-fidelity timestamps. +function stripZip64Extra(extra) { + if (!extra.length) return EMPTY_BUFFER; + const parts = []; + let total = 0; + forEachExtraField(extra, (id, body) => { + if (id === ZIP64_EXTRA_ID) return; + const record = Buffer.allocUnsafe(4 + body.length); + record.writeUInt16LE(id, 0); + record.writeUInt16LE(body.length, 2); + body.copy(record, 4); + ArrayPrototypePush(parts, record); + total += record.length; + }); + if (parts.length === 0) return EMPTY_BUFFER; + return Buffer.concat(parts, total); +} + +// Info-ZIP extended timestamp extra field ("UT", 0x5455; see +// parseExtTimestampMtime() above for the format's provenance): a flags byte +// (bit 0 = modification time present) followed by the whole (UTC) second. +function buildExtTimestampExtra(seconds) { + const buffer = Buffer.allocUnsafe(9); + buffer.writeUInt16LE(EXTRA_ID_EXT_TIMESTAMP, 0); + buffer.writeUInt16LE(5, 2); + buffer.writeUInt8(0x01, 4); + buffer.writeInt32LE(seconds, 5); + return buffer; +} + +module.exports = { + parseZip64Extra, + forEachExtraField, + extraFieldMtime, + stripZip64Extra, + buildExtTimestampExtra, +}; diff --git a/lib/internal/zip/file.js b/lib/internal/zip/file.js new file mode 100644 index 000000000000..b43c091e8630 --- /dev/null +++ b/lib/internal/zip/file.js @@ -0,0 +1,780 @@ +'use strict'; + +// `ZipFile`: random-access, in-place-writable view over an archive on disk +// (a raw fd), plus `buildCentralDirectoryChunks()` which rebuilds the central +// directory (and its Zip64/EOCD trailer) for the in-place rewrite path. + +const { + ArrayPrototypePush, + ArrayPrototypeSort, + FunctionPrototypeCall, + JSONStringify, + Map, + MapPrototypeClear, + MapPrototypeDelete, + MapPrototypeEntries, + MapPrototypeGet, + MapPrototypeGetSize, + MapPrototypeHas, + MapPrototypeKeys, + MapPrototypeSet, + MathMin, + PromisePrototypeThen, + PromiseResolve, + SymbolAsyncDispose, + SymbolAsyncIterator, + SymbolDispose, + SymbolIterator, + SymbolToStringTag, +} = primordials; + +const { + codes: { + ERR_INVALID_ARG_TYPE, + ERR_INVALID_STATE, + ERR_ZIP_ARCHIVE_TOO_LARGE, + ERR_ZIP_ENTRY_NOT_FOUND, + ERR_ZIP_INVALID_ARCHIVE, + ERR_ZIP_NOT_WRITABLE, + }, +} = require('internal/errors'); +const { + validateBoolean, + validateFunction, + validateString, +} = require('internal/validators'); +const { Buffer, kMaxLength } = require('buffer'); +const { Readable } = require('stream'); +const fs = require('fs'); +const { + SIG_LOCAL_FILE_HEADER, + TAIL_LENGTH, + kFinalize, + kPromote, +} = require('internal/zip/constants'); +const { + buildArchiveTrailer, +} = require('internal/zip/header-builders'); +const { + CentralFileHeader, + LocalFileHeader, + findArchiveEnd, + readCentralDirectory, +} = require('internal/zip/headers'); +const { decodeZipText } = require('internal/zip/dos'); +const { + fsOpenAsync, + fsCloseAsync, + fsFstatAsync, + fsFtruncateAsync, + readFdFully, + readFdFullySync, + writeFdFully, + writeFdFullySync, +} = require('internal/zip/fs-util'); +const { ZipEntry } = require('internal/zip/entry'); +const { + createZipArchive, + createZipArchiveSync, +} = require('internal/zip/archive'); + +/** + * Builds a fresh central directory (sec. 4.3.12) plus its trailer (the Zip64 + * end record/locator when needed and the end-of-central-directory record; + * see `buildArchiveTrailer()`) for `records`, an array of + * `{ entry, localOffset }` pairs already in their final order and at their + * final (possibly pre-existing, possibly freshly written) offsets. + * @param {Array<{ entry: ZipEntry, localOffset: number }>} records + * @param {number} centralDirectoryOffset + * @param {Buffer} comment + * @returns {{ centralHeaders: Buffer[], chunks: Buffer[] }} + */ +function buildCentralDirectoryChunks(records, centralDirectoryOffset, comment) { + const centralHeaders = []; + let centralDirectorySize = 0; + for (let i = 0; i < records.length; i++) { + // `true`: this rewrite leaves local headers on disk untouched, so an + // entry whose local header advertises a data descriptor (bit 3) must + // keep advertising it in the rebuilt central header; see [kFinalize]. + const header = records[i].entry[kFinalize](records[i].localOffset, true); + ArrayPrototypePush(centralHeaders, header); + centralDirectorySize += header.length; + } + const count = records.length; + const chunks = []; + for (let i = 0; i < centralHeaders.length; i++) ArrayPrototypePush(chunks, centralHeaders[i]); + const trailer = buildArchiveTrailer(count, centralDirectorySize, centralDirectoryOffset, comment); + for (let i = 0; i < trailer.length; i++) ArrayPrototypePush(chunks, trailer[i]); + return { centralHeaders, chunks }; +} + +// Shared post-location validation for open()/openSync(): the archive tail +// has been found; make sure the central directory it describes can actually +// be buffered and lies inside the file. +function checkArchiveEnd(end, size) { + if (end.centralDirectorySize > kMaxLength) { + throw new ERR_ZIP_ARCHIVE_TOO_LARGE('the central directory is too large to buffer'); + } + if (end.centralDirectoryOffset + end.centralDirectorySize > size) { + throw new ERR_ZIP_INVALID_ARCHIVE('central directory is out of bounds'); + } +} + +// Given the fixed 30-byte local header read at `offset`, return where the +// member's data ends. The data starts after the full local header (fixed + +// file name + extra field), whose length lives only in the local header, not +// the central directory - so the exact end can only be measured by reading it. +// A malformed local header (bad signature) is not rejected here: fall back to +// the 30-byte minimum so the archive stays openable (a read of that entry will +// surface the error) while the member is still bounded by a safe lower bound. +// Well-formed headers - the case an overlap attack must use to be readable - +// are measured exactly, matching the read path and the in-memory reader. +function localHeaderEnd(fixed, offset, compressedSize) { + if (fixed.readUInt32LE(0) !== SIG_LOCAL_FILE_HEADER) { + return offset + 30 + compressedSize; + } + return offset + LocalFileHeader.length(fixed, 0) + compressedSize; +} + +// Reject any member whose actual compressed bytes cannot lie inside the file, +// and any pair of members whose data ranges overlap each other or the central +// directory. The buffered read paths (`ZipEntry.prototype.content()` and +// friends) allocate `compressedSize` bytes before reading, so without the +// bounds check a tiny file whose central directory lies about a member's size +// could force an allocation of up to `kMaxLength` bytes. The overlap check +// counters the "quoted overlap" zip-bomb shape (CVE-2024-0450 in other +// implementations): N records quoting the same data region turn one small file +// into N full-size extractions, while real archives lay members out +// disjointly. The exact data range depends on the local header's length, which +// is read here (one small read per member) so the check matches the read path +// and the in-memory reader (`readArchiveEntries()`) rather than trusting a +// looser lower bound. +async function validateMemberBounds(fd, headers, prefix, size, centralDirectoryOffset) { + const members = []; + for (let i = 0; i < headers.length; i++) { + const header = headers[i]; + const offset = header.localFileHeaderOffset + prefix; + const fixed = Buffer.allocUnsafe(30); + await readFdFully(fd, fixed, offset); + const dataEnd = localHeaderEnd(fixed, offset, header.compressedSize); + checkMemberRange(members, header.fileName, offset, dataEnd, size); + } + checkMemberOverlap(members, centralDirectoryOffset); +} + +// Sync counterpart of validateMemberBounds(). +function validateMemberBoundsSync(fd, headers, prefix, size, centralDirectoryOffset) { + const members = []; + for (let i = 0; i < headers.length; i++) { + const header = headers[i]; + const offset = header.localFileHeaderOffset + prefix; + const fixed = Buffer.allocUnsafe(30); + readFdFullySync(fd, fixed, offset); + const dataEnd = localHeaderEnd(fixed, offset, header.compressedSize); + checkMemberRange(members, header.fileName, offset, dataEnd, size); + } + checkMemberOverlap(members, centralDirectoryOffset); +} + +// Record a member's [offset, dataEnd) range, rejecting one that runs past the +// end of the file. +function checkMemberRange(members, fileName, offset, dataEnd, size) { + if (dataEnd > size) { + throw new ERR_ZIP_INVALID_ARCHIVE( + `entry ${JSONStringify(fileName)} data is out of bounds`); + } + ArrayPrototypePush(members, { fileName, offset, dataEnd }); +} + +// Sorted by local-header start, reject any member whose data runs into the +// next member's local header or into the central directory. +function checkMemberOverlap(members, centralDirectoryOffset) { + ArrayPrototypeSort(members, (a, b) => a.offset - b.offset); + for (let i = 0; i < members.length; i++) { + const bound = i + 1 < members.length ? members[i + 1].offset : centralDirectoryOffset; + if (members[i].dataEnd > bound) { + throw new ERR_ZIP_INVALID_ARCHIVE( + `entry ${JSONStringify(members[i].fileName)} overlaps the next ` + + 'entry or the central directory (possible zip bomb)'); + } + } +} + +/** + * A random-access view over the entries of a ZIP archive on disk. Only the + * archive tail and central directory are read up front; individual member + * content is read lazily and on demand. Writable when opened with + * `{ writable: true }`: adding or deleting an entry rewrites the central + * directory in place, appending new entry content where the old central + * directory used to be. + * + * Every method has a `*Sync` counterpart. The synchronous methods block the + * Node.js event loop and further JavaScript execution until the operation + * completes - use them only where synchronous I/O is appropriate (for + * example, short-lived scripts or startup code), never in code that must + * stay responsive. A synchronous method throws `ERR_INVALID_STATE` if called + * while an asynchronous `addEntry()`/`add()`/`delete()`/`close()` on the same + * `ZipFile` has not settled yet, since letting the two interleave could + * corrupt the archive. + */ +class ZipFile { + // Shared descriptor handle ({ fd, closed }) handed to every ZipEntry this + // archive produces, so close() can invalidate them all at once. `#closing` + // is set synchronously the moment close()/closeSync() is called, gating any + // further public call; `handle.closed` is set once the fd is actually gone, + // gating reads through already-handed-out entries. Neither read ever falls + // through to a bare (possibly OS-reused) descriptor number. + #handle; + #closing = false; + #closePromise = null; + #writable; + #comment; + #centralDirectoryOffset; + #entries = new Map(); + #queue = PromiseResolve(); + #pendingAsyncOps = 0; + + /** + * Builds the by-name entry map from the already-parsed central headers, + * recording each member's local-header offset (adjusted by any prefix). + * @private + */ + constructor(fd, centralHeaders, prefix, centralDirectoryOffset, comment, writable) { + this.#handle = { fd, closed: false }; + this.#writable = writable; + this.#comment = comment; + this.#centralDirectoryOffset = centralDirectoryOffset; + for (let i = 0; i < centralHeaders.length; i++) { + const central = centralHeaders[i]; + MapPrototypeSet(this.#entries, central.fileName, { + central, + entry: undefined, + localOffset: central.localFileHeaderOffset + prefix, + }); + } + } + get writable() { return this.#writable; } + // The EOCD comment has no encoding flag; apply the same UTF-8/CP437 + // heuristic as unflagged member names and comments. + get comment() { return decodeZipText(this.#comment, 0); } + // Guard: throw unless this archive was opened writable. + #assertWritable() { + if (!this.#writable) throw new ERR_ZIP_NOT_WRITABLE(); + } + // Guard: reject any operation once close() has been initiated, so a + // request never reaches a closed (or reused) descriptor. Set synchronously + // by close()/closeSync() so an add() issued after them cannot slip through. + #assertOpen() { + if (this.#closing) { + throw new ERR_INVALID_STATE('the ZipFile has been closed'); + } + } + // Guard: reject a synchronous call while an async mutation is still in + // flight, since interleaving the two could corrupt the archive. + #assertNotBusy() { + if (this.#pendingAsyncOps > 0) { + throw new ERR_INVALID_STATE( + 'cannot call a synchronous ZipFile method while an asynchronous ' + + 'add(), addEntry(), delete(), or close() call has not settled yet'); + } + } + // Serialize async mutations (add/delete/close) through a promise chain so + // they never overlap and corrupt the archive; the in-flight counter backs + // #assertNotBusy(). Failures do not break the chain (both arms continue it). + #enqueue(fn) { + this.#pendingAsyncOps++; + const run = async () => { + try { + return await fn(); + } finally { + this.#pendingAsyncOps--; + } + }; + const result = PromisePrototypeThen(this.#queue, run, run); + this.#queue = PromisePrototypeThen(result, () => undefined, () => undefined); + return result; + } + has(name) { + this.#assertOpen(); + validateString(name, 'name'); + return MapPrototypeHas(this.#entries, name); + } + // Return the lazy, file-backed ZipEntry handle for `info`, creating and + // caching it on first access. The handle stores only a descriptor and the + // local-header offset - never the member's content - so repeated `get()`s + // return the same lightweight object and no content buffer is retained by + // the ZipFile. Any read (`content()`, `contentIterator()`) goes to disk. + #handleFor(info) { + info.entry ??= new ZipEntry(info.central, null, null, this.#handle, info.localOffset); + return info.entry; + } + /** + * Returns a lazy, file-backed `ZipEntry` for `name`. Nothing is read from + * disk here and no content is buffered; the entry reads (and, for + * `content()`, decompresses) straight from the file on each access. The + * returned entry is valid only while this `ZipFile` is open. + * @param {string} name + * @returns {Promise} + */ + async get(name) { + this.#assertOpen(); + validateString(name, 'name'); + const info = MapPrototypeGet(this.#entries, name); + if (info === undefined) throw new ERR_ZIP_ENTRY_NOT_FOUND(name); + return this.#handleFor(info); + } + /** + * The synchronous counterpart of `get()`. Like `get()`, it reads nothing + * up front and buffers no content - it only builds the lazy handle - so it + * does not itself block on I/O; see the class-level note on synchronous + * methods for reads performed later through the returned entry. + * @param {string} name + * @returns {ZipEntry} + */ + getSync(name) { + this.#assertOpen(); + this.#assertNotBusy(); + validateString(name, 'name'); + const info = MapPrototypeGet(this.#entries, name); + if (info === undefined) throw new ERR_ZIP_ENTRY_NOT_FOUND(name); + return this.#handleFor(info); + } + /** + * Streams a member's decoded content without buffering the whole member, + * as a `Readable` (verifying CRC-32 by default; `{ verify: false }` to opt + * out). Sugar for wrapping `get(name).contentIterator(options)`; the + * compressed bytes are read from disk as the stream is consumed. + * @param {string} name + * @param {{ verify?: boolean, maxSize?: number }} [options] + * @returns {Promise} + */ + async stream(name, options) { + const entry = await this.get(name); + return Readable.from(entry.contentIterator(options), { objectMode: false }); + } + /** + * Writes `entry`'s serialized bytes where the central directory currently + * starts, then rewrites the central directory to include it. Replaces any + * existing entry of the same name (its bytes become dead space, reclaimed + * by `compact()`). + * @param {ZipEntry} entry + * @returns {Promise} + */ + async addEntry(entry) { + this.#assertWritable(); + this.#assertOpen(); + if (!(entry instanceof ZipEntry)) { + throw new ERR_INVALID_ARG_TYPE('entry', 'ZipEntry', entry); + } + return this.#enqueue(() => this.#doAdd(entry)); + } + /** + * Builds an entry from in-memory `data` and appends it; see `addEntry()`. + * @param {string} filename + * @param {Buffer | TypedArray | DataView | ArrayBuffer} data + * @param {{ comment?: string, mode?: number, modified?: Date, method?: 'deflate' | 'store' | 'zstd' }} [options] + * @returns {Promise} + */ + async add(filename, data, options) { + this.#assertWritable(); + this.#assertOpen(); + // Reserve the mutation synchronously (before the async ZipEntry.create()), + // so a close()/closeSync() issued right after cannot slip in front of it + // and tear down the descriptor this write depends on. + return this.#enqueue(async () => + this.#doAdd(await ZipEntry.create(filename, data, options))); + } + // Append the entry's bytes where the central directory currently starts, + // then rewrite the directory to include it. On write failure, restore the + // original directory (the partial write may have clobbered it) and rethrow; + // on success, promote a spent stream entry to its on-disk copy. + async #doAdd(entry) { + const localOffset = this.#centralDirectoryOffset; + let written = 0; + try { + for await (const chunk of entry) { + await writeFdFully(this.#handle.fd, chunk, localOffset + written); + written += chunk.length; + } + } catch (err) { + // The failed entry's bytes start where the old central directory + // started, so part of it may already be overwritten - while the EOCD + // still points at it. Nothing has been adopted into memory yet, so + // rebuild and rewrite the directory (and EOCD) at its original offset + // to leave the archive exactly as it was before the call. + try { + await this.#rewriteCentralDirectory(); + } catch { + // Restoring failed too (the device is likely full or gone); the + // original error is the actionable one. + } + throw err; + } + this.#centralDirectoryOffset = localOffset + written; + MapPrototypeSet(this.#entries, entry.name, { central: null, entry, localOffset }); + await this.#rewriteCentralDirectory(); + // The entry now has a stable home in this archive; if it was a spent + // streaming entry, rebind it to that on-disk copy so it stays readable. + entry[kPromote](this.#handle, localOffset); + return entry; + } + /** + * The synchronous counterpart of `addEntry()`. `entry` must not be a + * pending streaming entry (one created with `ZipEntry.createStream()`) - + * there is no synchronous way to drain its asynchronous source. Blocks the + * event loop until done; see the class-level note on synchronous methods. + * @param {ZipEntry} entry + * @returns {ZipEntry} + */ + addEntrySync(entry) { + this.#assertWritable(); + this.#assertOpen(); + this.#assertNotBusy(); + if (!(entry instanceof ZipEntry)) { + throw new ERR_INVALID_ARG_TYPE('entry', 'ZipEntry', entry); + } + const localOffset = this.#centralDirectoryOffset; + let written = 0; + try { + for (const chunk of entry) { + writeFdFullySync(this.#handle.fd, chunk, localOffset + written); + written += chunk.length; + } + } catch (err) { + // See #doAdd(): restore the (partially overwritten) central directory + // before surfacing the failure. + try { + this.#rewriteCentralDirectorySync(); + } catch { + // Restoring failed too; the original error is the actionable one. + } + throw err; + } + this.#centralDirectoryOffset = localOffset + written; + MapPrototypeSet(this.#entries, entry.name, { central: null, entry, localOffset }); + this.#rewriteCentralDirectorySync(); + entry[kPromote](this.#handle, localOffset); + return entry; + } + /** + * The synchronous counterpart of `add()`. Blocks the event loop until + * done (including the deflate pass); see the class-level note on + * synchronous methods. + * @param {string} filename + * @param {Buffer | TypedArray | DataView | ArrayBuffer} data + * @param {{ comment?: string, mode?: number, modified?: Date, method?: 'deflate' | 'store' | 'zstd' }} [options] + * @returns {ZipEntry} + */ + addSync(filename, data, options) { + this.#assertWritable(); + return this.addEntrySync(ZipEntry.createSync(filename, data, options)); + } + /** + * Removes an entry by name. The central directory is rewritten in place + * (no new content is written, so the archive does not grow); the removed + * entry's bytes become dead space, reclaimed by `compact()`. + * @param {string} name + * @returns {Promise} + */ + async delete(name) { + this.#assertWritable(); + this.#assertOpen(); + validateString(name, 'name'); + return this.#enqueue(() => this.#doDelete(name)); + } + // Drop the named entry and rewrite the central directory (no member bytes + // move, so the file does not grow); reports whether it existed. + async #doDelete(name) { + const existed = MapPrototypeDelete(this.#entries, name); + if (existed) await this.#rewriteCentralDirectory(); + return existed; + } + /** + * The synchronous counterpart of `delete()`. Blocks the event loop until + * done; see the class-level note on synchronous methods. + * @param {string} name + * @returns {boolean} + */ + deleteSync(name) { + this.#assertWritable(); + this.#assertOpen(); + this.#assertNotBusy(); + validateString(name, 'name'); + const existed = MapPrototypeDelete(this.#entries, name); + if (existed) this.#rewriteCentralDirectorySync(); + return existed; + } + // Snapshot the live entries as ordered { entry, localOffset } records for a + // central-directory rebuild, materializing a plain ZipEntry for any member + // not yet handed out as a lazy handle. + #liveRecords() { + const records = []; + const names = []; + for (const { 0: name, 1: value } of MapPrototypeEntries(this.#entries)) { + ArrayPrototypePush(records, { + entry: value.entry ?? new ZipEntry(value.central, null, null), + localOffset: value.localOffset, + }); + ArrayPrototypePush(names, name); + } + return { records, names }; + } + // Rebuild the central directory and its trailer (sec. 4.3.12/4.3.16) for the + // current live set and overwrite it in place at its current offset, + // truncating any leftover tail, then adopt the freshly written headers. + async #rewriteCentralDirectory() { + const { records, names } = this.#liveRecords(); + const { centralHeaders, chunks } = buildCentralDirectoryChunks( + records, this.#centralDirectoryOffset, this.#comment); + let pos = this.#centralDirectoryOffset; + for (let i = 0; i < chunks.length; i++) { + await writeFdFully(this.#handle.fd, chunks[i], pos); + pos += chunks[i].length; + } + await fsFtruncateAsync(this.#handle.fd, pos); + this.#adoptRewrittenCentralDirectory(names, centralHeaders, records); + } + // Sync counterpart of #rewriteCentralDirectory(). + #rewriteCentralDirectorySync() { + const { records, names } = this.#liveRecords(); + const { centralHeaders, chunks } = buildCentralDirectoryChunks( + records, this.#centralDirectoryOffset, this.#comment); + let pos = this.#centralDirectoryOffset; + for (let i = 0; i < chunks.length; i++) { + writeFdFullySync(this.#handle.fd, chunks[i], pos); + pos += chunks[i].length; + } + fs.ftruncateSync(this.#handle.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) { + this.#assertOpen(); + // Snapshot the live set now: a later addEntry()/delete() must not error + // out (or change) an archive stream that is already being produced. + // Reading the snapshot stays valid regardless of later mutations - + // neither addEntry() nor delete() moves existing member bytes. + const entries = []; + for (const { 1: info } of MapPrototypeEntries(this.#entries)) { + ArrayPrototypePush(entries, this.#handleFor(info)); + } + return createZipArchive(entries, { comment: comment ?? this.#comment }); + } + /** + * The synchronous counterpart of `compact()`. Blocks the event loop until + * the whole archive has been read and re-serialized; see the class-level + * note on synchronous methods. + * @param {string} [comment] + * @returns {Buffer} + */ + compactSync(comment) { + this.#assertOpen(); + this.#assertNotBusy(); + const entries = []; + for (const { 1: info } of MapPrototypeEntries(this.#entries)) { + ArrayPrototypePush(entries, this.#handleFor(info)); + } + const chunks = []; + for (const chunk of createZipArchiveSync(entries, { comment: comment ?? this.#comment })) { + ArrayPrototypePush(chunks, chunk); + } + return Buffer.concat(chunks); + } + keys() { this.#assertOpen(); return MapPrototypeKeys(this.#entries); } + *values() { + for (const name of this.keys()) yield this.get(name); + } + /** + * The synchronous counterpart of `values()`, yielding resolved `ZipEntry` + * values instead of `Promise`s. + * @yields {ZipEntry} + */ + *valuesSync() { + for (const name of this.keys()) yield this.getSync(name); + } + *entries() { + for (const name of this.keys()) yield [name, this.get(name)]; + } + /** + * The synchronous counterpart of `entries()`, yielding resolved `ZipEntry` + * values instead of `Promise`s. + * @yields {[string, ZipEntry]} + */ + *entriesSync() { + for (const name of this.keys()) yield [name, this.getSync(name)]; + } + // Async iteration yields each resolved ZipEntry (awaiting the lazy handles). + async *[SymbolAsyncIterator]() { + for (const promise of this.values()) yield await promise; + } + get size() { this.#assertOpen(); return MapPrototypeGetSize(this.#entries); } + [SymbolIterator]() { return this.entries(); } + get [SymbolToStringTag]() { return 'ZipFile'; } + forEach(callback, thisArg) { + validateFunction(callback, 'callback'); + for (const { 0: key, 1: value } of this.entries()) { + FunctionPrototypeCall(callback, thisArg === undefined ? this : thisArg, value, key, this); + } + } + /** + * The synchronous counterpart of `forEach()`, invoking `callback` with a + * resolved `ZipEntry` instead of a `Promise`. + * @param {Function} callback + * @param {*} [thisArg] + */ + forEachSync(callback, thisArg) { + validateFunction(callback, 'callback'); + for (const { 0: key, 1: value } of this.entriesSync()) { + FunctionPrototypeCall(callback, thisArg === undefined ? this : thisArg, value, key, this); + } + } + // Drop all entries and close the fd, queued behind any pending async ops. + // Idempotent: a second close() is a safe no-op, never a double-close of a + // (possibly reused) descriptor. `#closing` is set synchronously so any + // operation issued after this point is rejected rather than racing the fd. + close() { + if (this.#closing) return this.#closePromise ?? PromiseResolve(); + this.#closing = true; + this.#closePromise = this.#enqueue(async () => { + this.#handle.closed = true; + MapPrototypeClear(this.#entries); + await fsCloseAsync(this.#handle.fd); + }); + return this.#closePromise; + } + /** + * The synchronous counterpart of `close()`; see the class-level note on + * synchronous methods. Idempotent. + */ + closeSync() { + if (this.#closing) return; + this.#assertNotBusy(); + this.#closing = true; + this.#handle.closed = true; + MapPrototypeClear(this.#entries); + fs.closeSync(this.#handle.fd); + } + async [SymbolAsyncDispose]() { + await this.close(); + } + [SymbolDispose]() { + this.closeSync(); + } + /** + * Opens an archive, reading only its tail and central directory up front + * (member content stays on disk). Pass `{ writable: true }` for in-place + * editing. + * @param {string} filename + * @param {{ writable?: boolean }} [options] + * @returns {Promise} + */ + static async open(filename, options) { + validateString(filename, 'filename'); + const writable = options?.writable ?? false; + validateBoolean(writable, 'options.writable'); + const fd = await fsOpenAsync(filename, writable ? 'r+' : 'r'); + try { + const stat = await fsFstatAsync(fd); + const size = stat.size; + const tailLength = MathMin(size, TAIL_LENGTH); + const tail = Buffer.allocUnsafe(tailLength); + await readFdFully(fd, tail, size - tailLength); + let end = findArchiveEnd(tail, size - tailLength); + if (end.needTailFrom !== undefined) { + // The Zip64 EOCD record's extensible data sector pushes the record + // start beyond the fixed-size tail; re-read from the recorded + // offset (bounded by ZIP64_EOCD_MAX_LENGTH inside findArchiveEnd). + const retry = Buffer.allocUnsafe(size - end.needTailFrom); + await readFdFully(fd, retry, end.needTailFrom); + end = findArchiveEnd(retry, end.needTailFrom); + if (end.needTailFrom !== undefined) { + throw new ERR_ZIP_INVALID_ARCHIVE( + 'Zip64 end of central directory record not found'); + } + } + checkArchiveEnd(end, size); + const directory = Buffer.allocUnsafe(end.centralDirectorySize); + await readFdFully(fd, directory, end.centralDirectoryOffset); + const headers = readCentralDirectory(directory, end.totalRecords); + await validateMemberBounds(fd, headers, end.prefix, size, end.centralDirectoryOffset); + return new ZipFile(fd, headers, end.prefix, end.centralDirectoryOffset, end.comment, writable); + } catch (err) { + try { + await fsCloseAsync(fd); + } catch { + // The archive failed to parse; the close error is not actionable. + } + throw err; + } + } + /** + * The synchronous counterpart of `open()`. Blocks the event loop and + * further JavaScript execution until the archive's tail and central + * directory have been read; see the class-level note on synchronous + * methods. + * @param {string} filename + * @param {{ writable?: boolean }} [options] + * @returns {ZipFile} + */ + static openSync(filename, options) { + validateString(filename, 'filename'); + const writable = options?.writable ?? false; + validateBoolean(writable, 'options.writable'); + const fd = fs.openSync(filename, writable ? 'r+' : 'r'); + try { + const size = fs.fstatSync(fd).size; + const tailLength = MathMin(size, TAIL_LENGTH); + const tail = Buffer.allocUnsafe(tailLength); + readFdFullySync(fd, tail, size - tailLength); + let end = findArchiveEnd(tail, size - tailLength); + if (end.needTailFrom !== undefined) { + // See open(): the Zip64 EOCD record starts before the fixed-size + // tail; re-read from the recorded offset. + const retry = Buffer.allocUnsafe(size - end.needTailFrom); + readFdFullySync(fd, retry, end.needTailFrom); + end = findArchiveEnd(retry, end.needTailFrom); + if (end.needTailFrom !== undefined) { + throw new ERR_ZIP_INVALID_ARCHIVE( + 'Zip64 end of central directory record not found'); + } + } + checkArchiveEnd(end, size); + const directory = Buffer.allocUnsafe(end.centralDirectorySize); + readFdFullySync(fd, directory, end.centralDirectoryOffset); + const headers = readCentralDirectory(directory, end.totalRecords); + validateMemberBoundsSync(fd, headers, end.prefix, size, end.centralDirectoryOffset); + return new ZipFile(fd, headers, end.prefix, end.centralDirectoryOffset, end.comment, writable); + } catch (err) { + try { + fs.closeSync(fd); + } catch { + // The archive failed to parse; the close error is not actionable. + } + throw err; + } + } +} + +module.exports = { + ZipFile, +}; diff --git a/lib/internal/zip/fs-util.js b/lib/internal/zip/fs-util.js new file mode 100644 index 000000000000..cfd20069d007 --- /dev/null +++ b/lib/internal/zip/fs-util.js @@ -0,0 +1,150 @@ +'use strict'; + +// Promise wrappers over the callback `fs` primitives (`ZipFile` works on a +// raw fd so one instance can serve both async and `Sync` methods), plus the +// short-read/short-write loops that guarantee a full transfer. + +const { + Promise, +} = primordials; + +const { + codes: { + ERR_INVALID_STATE, + ERR_ZIP_INVALID_ARCHIVE, + }, +} = require('internal/errors'); +const fs = require('fs'); + +// Promisified adapters over the callback `fs` primitives; individually trivial, +// they only exist so the async archive paths can `await` plain fd operations. +// +// `ZipFile` operates on a plain numeric file descriptor (rather than an +// `fs.promises` `FileHandle`) so that a single instance can support both the +// async and the `Sync` methods: `fs.read`/`fs.write`/`fs.fstat`/ +// `fs.ftruncate`/`fs.close` all accept a raw fd directly, same as their +// `*Sync` counterparts, so both call sites share one open file underneath. +function fsOpenAsync(path, flag) { + return new Promise((resolve, reject) => { + fs.open(path, flag, (err, fd) => (err ? reject(err) : resolve(fd))); + }); +} + +function fsStatAsync(path) { + return new Promise((resolve, reject) => { + fs.stat(path, (err, stats) => (err ? reject(err) : resolve(stats))); + }); +} + +function fsLstatAsync(path) { + return new Promise((resolve, reject) => { + fs.lstat(path, (err, stats) => (err ? reject(err) : resolve(stats))); + }); +} + +function fsReadlinkAsync(path) { + return new Promise((resolve, reject) => { + fs.readlink(path, 'utf8', (err, target) => (err ? reject(err) : resolve(target))); + }); +} + +function fsCloseAsync(fd) { + return new Promise((resolve, reject) => { + fs.close(fd, (err) => (err ? reject(err) : resolve())); + }); +} + +function fsFstatAsync(fd) { + return new Promise((resolve, reject) => { + fs.fstat(fd, (err, stats) => (err ? reject(err) : resolve(stats))); + }); +} + +function fsReadAsync(fd, buffer, offset, length, position) { + return new Promise((resolve, reject) => { + fs.read(fd, buffer, offset, length, position, (err, bytesRead) => (err ? reject(err) : resolve(bytesRead))); + }); +} + +function fsWriteAsync(fd, buffer, offset, length, position) { + return new Promise((resolve, reject) => { + fs.write(fd, buffer, offset, length, position, (err, bytesWritten) => (err ? reject(err) : resolve(bytesWritten))); + }); +} + +function fsFtruncateAsync(fd, len) { + return new Promise((resolve, reject) => { + fs.ftruncate(fd, len, (err) => (err ? reject(err) : resolve())); + }); +} + +// `read(2)` may return fewer bytes than requested without hitting EOF, so loop +// until `buffer` is entirely filled; a genuine short read (0 bytes) means the +// archive is truncated where a full header/record was expected. +async function readFdFully(fd, buffer, position) { + let done = 0; + while (done < buffer.length) { + const bytesRead = await fsReadAsync(fd, buffer, done, buffer.length - done, position + done); + if (bytesRead <= 0) { + throw new ERR_ZIP_INVALID_ARCHIVE('unexpected end of file'); + } + done += bytesRead; + } +} + +// Synchronous counterpart of `readFdFully()`; same short-read loop. +function readFdFullySync(fd, buffer, position) { + let done = 0; + while (done < buffer.length) { + const bytesRead = fs.readSync(fd, buffer, done, buffer.length - done, position + done); + if (bytesRead <= 0) { + throw new ERR_ZIP_INVALID_ARCHIVE('unexpected end of file'); + } + done += bytesRead; + } +} + +// `write(2)` may write fewer bytes than asked - most notably when an error +// (ENOSPC, EIO, a full NFS commit) strikes after partial progress, which +// surfaces as a short, error-free count. Advancing archive offsets by the +// intended length would then silently corrupt the file, so every archive +// write loops until the buffer is fully on disk; retrying the remainder +// re-encounters and surfaces the underlying error. +async function writeFdFully(fd, buffer, position) { + let done = 0; + while (done < buffer.length) { + const bytesWritten = + await fsWriteAsync(fd, buffer, done, buffer.length - done, position + done); + if (bytesWritten <= 0) { + throw new ERR_INVALID_STATE('a write to the archive made no progress'); + } + done += bytesWritten; + } +} + +// Synchronous counterpart of `writeFdFully()`; same short-write loop. +function writeFdFullySync(fd, buffer, position) { + let done = 0; + while (done < buffer.length) { + const bytesWritten = + fs.writeSync(fd, buffer, done, buffer.length - done, position + done); + if (bytesWritten <= 0) { + throw new ERR_INVALID_STATE('a write to the archive made no progress'); + } + done += bytesWritten; + } +} + +module.exports = { + fsOpenAsync, + fsStatAsync, + fsLstatAsync, + fsReadlinkAsync, + fsCloseAsync, + fsFstatAsync, + fsFtruncateAsync, + readFdFully, + readFdFullySync, + writeFdFully, + writeFdFullySync, +}; diff --git a/lib/internal/zip/header-builders.js b/lib/internal/zip/header-builders.js new file mode 100644 index 000000000000..4b48fbc53180 --- /dev/null +++ b/lib/internal/zip/header-builders.js @@ -0,0 +1,251 @@ +'use strict'; + +// Write-path header builders: local/central file headers, the Zip64 data +// descriptor, and the (Zip64) end-of-central-directory records, plus the +// version-needed and extra-length helpers they share. + +const { + ArrayPrototypePush, + MathMax, + MathMin, +} = primordials; + +const { + codes: { + ERR_ZIP_ENTRY_TOO_LARGE, + }, +} = require('internal/errors'); +const { Buffer } = require('buffer'); +const { + EMPTY_BUFFER, + SIG_LOCAL_FILE_HEADER, + SIG_DATA_DESCRIPTOR, + SIG_CENTRAL_FILE_HEADER, + SIG_ZIP64_EOCD_RECORD, + SIG_ZIP64_EOCD_LOCATOR, + SIG_EOCD, + MADE_BY_UNIX, + ZIP64_EXTRA_ID, + SENTINEL16, + SENTINEL32, + FLAG_DATA_DESCRIPTOR, + METHOD_ZSTD, + VERSION_DEFAULT, + VERSION_ZIP64, + VERSION_ZSTD, +} = require('internal/zip/constants'); +const { encodeDosDateTime } = require('internal/zip/dos'); +const { writeSafeUint64 } = require('internal/zip/binary'); +const { buildExtTimestampExtra } = require('internal/zip/extra-fields'); + +// The "version needed to extract" (sec. 4.4.3): the highest version any +// feature of the member demands - 6.3 for Zstandard, 4.5 for Zip64 +// structures, 2.0 otherwise. +function versionNeeded(meta, zip64) { + return MathMax( + zip64 ? VERSION_ZIP64 : VERSION_DEFAULT, + meta.method === METHOD_ZSTD ? VERSION_ZSTD : 0); +} + +// Guard the combined extra-field length against the 16-bit field it is stored +// in (sec. 4.4.11). +function checkExtraLength(extraLength) { + if (extraLength > SENTINEL16) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + 'the entry extra fields must not exceed 65535 bytes'); + } +} + +// Build a local file header (sec. 4.3.7). Streamed entries (unknown sizes/CRC) +// zero those fields and always carry a Zip64 extra so the trailing data +// descriptor can hold 64-bit sizes; oversized non-streamed entries get one too. +function buildLocalHeader(meta) { + const streaming = (meta.flags & FLAG_DATA_DESCRIPTOR) !== 0; + const zip64 = + streaming || + meta.compressedSize >= SENTINEL32 || + meta.uncompressedSize >= SENTINEL32; + const ts = meta.extendedMtime !== null ? buildExtTimestampExtra(meta.extendedMtime) : EMPTY_BUFFER; + const extraLength = (zip64 ? 20 : 0) + ts.length + meta.extra.length; + checkExtraLength(extraLength); + const buffer = Buffer.allocUnsafe(30 + meta.name.length + extraLength); + buffer.writeUInt32LE(SIG_LOCAL_FILE_HEADER, 0); + buffer.writeUInt16LE(versionNeeded(meta, zip64), 4); + buffer.writeUInt16LE(meta.flags, 6); + buffer.writeUInt16LE(meta.method, 8); + const { time, date } = encodeDosDateTime(meta.modified); + buffer.writeUInt16LE(time, 10); + buffer.writeUInt16LE(date, 12); + buffer.writeUInt32LE(streaming ? 0 : meta.crc, 14); + buffer.writeUInt32LE(zip64 ? SENTINEL32 : meta.compressedSize, 18); + buffer.writeUInt32LE(zip64 ? SENTINEL32 : meta.uncompressedSize, 22); + buffer.writeUInt16LE(meta.name.length, 26); + buffer.writeUInt16LE(extraLength, 28); + meta.name.copy(buffer, 30); + let pos = 30 + meta.name.length; + if (zip64) { + buffer.writeUInt16LE(ZIP64_EXTRA_ID, pos); + buffer.writeUInt16LE(16, pos + 2); + writeSafeUint64(buffer, pos + 4, streaming ? 0 : meta.uncompressedSize); + writeSafeUint64(buffer, pos + 12, streaming ? 0 : meta.compressedSize); + pos += 20; + } + ts.copy(buffer, pos); + pos += ts.length; + meta.extra.copy(buffer, pos); + return buffer; +} + +// Build a central directory file header (sec. 4.3.12). Each of the +// uncompressed size, compressed size, and local-header offset that overflows +// 32 bits is moved into the Zip64 extra field (sec. 4.5.3) in that order. +function buildCentralHeader(meta, localOffset) { + const zip64Streaming = (meta.flags & FLAG_DATA_DESCRIPTOR) !== 0; + const u64 = meta.uncompressedSize >= SENTINEL32; + const c64 = meta.compressedSize >= SENTINEL32; + const o64 = localOffset >= SENTINEL32; + const zip64Fields = (u64 ? 1 : 0) + (c64 ? 1 : 0) + (o64 ? 1 : 0); + const ts = meta.extendedMtime !== null ? buildExtTimestampExtra(meta.extendedMtime) : EMPTY_BUFFER; + const extraLength = (zip64Fields ? 4 + 8 * zip64Fields : 0) + ts.length + meta.extra.length; + checkExtraLength(extraLength); + const zip64 = zip64Streaming || zip64Fields > 0; + const version = versionNeeded(meta, zip64); + const buffer = Buffer.allocUnsafe( + 46 + meta.name.length + extraLength + meta.comment.length); + buffer.writeUInt32LE(SIG_CENTRAL_FILE_HEADER, 0); + // "Version made by" (sec. 4.4.2): the upper byte is the creator's host + // system, which determines how the external attributes (sec. 4.4.15) are + // interpreted. Preserve the original host for round-tripped entries - + // stamping everything as Unix would turn, say, a DOS entry's zeroed high + // bits into Unix mode 0000. Entries built by `createEntryMeta()` are Unix. + buffer.writeUInt16LE((meta.madeBy << 8) | version, 4); + buffer.writeUInt16LE(version, 6); + buffer.writeUInt16LE(meta.flags, 8); + buffer.writeUInt16LE(meta.method, 10); + const { time, date } = encodeDosDateTime(meta.modified); + buffer.writeUInt16LE(time, 12); + buffer.writeUInt16LE(date, 14); + buffer.writeUInt32LE(meta.crc, 16); + buffer.writeUInt32LE(c64 ? SENTINEL32 : meta.compressedSize, 20); + buffer.writeUInt32LE(u64 ? SENTINEL32 : meta.uncompressedSize, 24); + buffer.writeUInt16LE(meta.name.length, 28); + buffer.writeUInt16LE(extraLength, 30); + buffer.writeUInt16LE(meta.comment.length, 32); + buffer.writeUInt16LE(0, 34); // disk number + buffer.writeUInt16LE(meta.internal, 36); + buffer.writeUInt32LE(meta.external, 38); + buffer.writeUInt32LE(o64 ? SENTINEL32 : localOffset, 42); + meta.name.copy(buffer, 46); + let pos = 46 + meta.name.length; + if (zip64Fields) { + buffer.writeUInt16LE(ZIP64_EXTRA_ID, pos); + buffer.writeUInt16LE(8 * zip64Fields, pos + 2); + pos += 4; + if (u64) { + writeSafeUint64(buffer, pos, meta.uncompressedSize); + pos += 8; + } + if (c64) { + writeSafeUint64(buffer, pos, meta.compressedSize); + pos += 8; + } + if (o64) { + writeSafeUint64(buffer, pos, localOffset); + pos += 8; + } + } + ts.copy(buffer, pos); + pos += ts.length; + meta.extra.copy(buffer, pos); + pos += meta.extra.length; + meta.comment.copy(buffer, pos); + return buffer; +} + +// Zip64 data descriptor (sec. 4.3.9): emitted after a streamed entry, whose +// local header always carries a Zip64 extra field. +function buildDataDescriptor64(crc, compressedSize, uncompressedSize) { + const buffer = Buffer.allocUnsafe(24); + buffer.writeUInt32LE(SIG_DATA_DESCRIPTOR, 0); + buffer.writeUInt32LE(crc, 4); + writeSafeUint64(buffer, 8, compressedSize); + writeSafeUint64(buffer, 16, uncompressedSize); + return buffer; +} + +// Build the end of central directory record (sec. 4.3.16); fields that +// overflow their 16/32-bit slots are written as sentinels and the true values +// live in the Zip64 EOCD record. +function buildEndOfCentralDirectory(count, size, offset, comment) { + const buffer = Buffer.allocUnsafe(22 + comment.length); + buffer.writeUInt32LE(SIG_EOCD, 0); + buffer.writeUInt16LE(0, 4); // disk number + buffer.writeUInt16LE(0, 6); // Central directory disk number + buffer.writeUInt16LE(MathMin(count, SENTINEL16), 8); + buffer.writeUInt16LE(MathMin(count, SENTINEL16), 10); + buffer.writeUInt32LE(MathMin(size, SENTINEL32), 12); + buffer.writeUInt32LE(MathMin(offset, SENTINEL32), 16); + buffer.writeUInt16LE(comment.length, 20); + comment.copy(buffer, 22); + return buffer; +} + +// Build the Zip64 end of central directory record (sec. 4.3.14): the 64-bit +// counterpart to the EOCD, holding the real record count/size/offset. +function buildZip64EndRecord(count, size, offset) { + const buffer = Buffer.allocUnsafe(56); + buffer.writeUInt32LE(SIG_ZIP64_EOCD_RECORD, 0); + writeSafeUint64(buffer, 4, 44); // Size of the remainder of this record + buffer.writeUInt16LE((MADE_BY_UNIX << 8) | VERSION_ZIP64, 12); + buffer.writeUInt16LE(VERSION_ZIP64, 14); + buffer.writeUInt32LE(0, 16); // disk number + buffer.writeUInt32LE(0, 20); // Central directory disk number + writeSafeUint64(buffer, 24, count); + writeSafeUint64(buffer, 32, count); + writeSafeUint64(buffer, 40, size); + writeSafeUint64(buffer, 48, offset); + return buffer; +} + +// Build the Zip64 end of central directory locator (sec. 4.3.15): points the +// reader from just before the EOCD to the Zip64 EOCD record. +function buildZip64EndLocator(recordOffset) { + const buffer = Buffer.allocUnsafe(20); + buffer.writeUInt32LE(SIG_ZIP64_EOCD_LOCATOR, 0); + buffer.writeUInt32LE(0, 4); // Disk with the Zip64 EOCD record + writeSafeUint64(buffer, 8, recordOffset); + buffer.writeUInt32LE(1, 16); // total disks + return buffer; +} + +// The archive trailer: the Zip64 EOCD record and locator +// (sec. 4.3.14/4.3.15) when the record count, central directory offset, or +// central directory size overflows its classic 16-/32-bit field, followed +// by the end of central directory record (sec. 4.3.16). Shared by the +// streaming serializers and ZipFile's in-place rewrite so the Zip64 +// switchover rule lives in exactly one place. +function buildArchiveTrailer(count, centralDirectorySize, centralDirectoryOffset, comment) { + const chunks = []; + const zip64 = + count >= SENTINEL16 || + centralDirectoryOffset >= SENTINEL32 || + centralDirectorySize >= SENTINEL32; + if (zip64) { + const recordOffset = centralDirectoryOffset + centralDirectorySize; + ArrayPrototypePush(chunks, buildZip64EndRecord(count, centralDirectorySize, centralDirectoryOffset)); + ArrayPrototypePush(chunks, buildZip64EndLocator(recordOffset)); + } + ArrayPrototypePush(chunks, + buildEndOfCentralDirectory(count, centralDirectorySize, centralDirectoryOffset, comment)); + return chunks; +} + +module.exports = { + buildLocalHeader, + buildCentralHeader, + buildDataDescriptor64, + buildEndOfCentralDirectory, + buildZip64EndRecord, + buildZip64EndLocator, + buildArchiveTrailer, +}; diff --git a/lib/internal/zip/headers.js b/lib/internal/zip/headers.js new file mode 100644 index 000000000000..aadfa1ca3cde --- /dev/null +++ b/lib/internal/zip/headers.js @@ -0,0 +1,511 @@ +'use strict'; + +// Reader-side header structures (sec. 4.3): the end-of-central-directory +// record, the Zip64 EOCD record/locator, and the central/local file headers, +// plus `findArchiveEnd()` which locates them and `readCentralDirectory()` +// which walks the central directory into an array of headers. + +const { + ArrayPrototypePush, + MathMax, + Number, +} = primordials; + +const { + codes: { + ERR_ZIP_INVALID_ARCHIVE, + ERR_ZIP_UNSUPPORTED_FEATURE, + }, +} = require('internal/errors'); +const { + BIGINT_MAX_SAFE_INTEGER, + SIG_LOCAL_FILE_HEADER, + SIG_CENTRAL_FILE_HEADER, + SIG_ZIP64_EOCD_RECORD, + SIG_ZIP64_EOCD_LOCATOR, + SIG_EOCD, + MADE_BY_UNIX, + SENTINEL16, + SENTINEL32, + ZIP64_EOCD_MAX_LENGTH, + S_IFLNK, + S_IFMT, +} = require('internal/zip/constants'); +const { + validateArchiveRange, + readSafeUint64, +} = require('internal/zip/binary'); +const { parseZip64Extra } = require('internal/zip/extra-fields'); +const { + decodeDosDateTime, + decodeZipName, + decodeZipText, +} = require('internal/zip/dos'); + +// End of central directory record (sec. 4.3.16). +// Offset Bytes Description +// 0 4 Signature = 0x06054b50 +// 4 2 Number of this disk +// 6 2 Disk where central directory starts +// 8 2 Number of central directory records on this disk +// 10 2 Total number of central directory records +// 12 4 Size of central directory (bytes) +// 16 4 Offset of start of central directory +// 20 2 Comment length (n) +// 22 n Comment +class CentralEndHeader { + #buffer; + #offset; + constructor(buffer, offset = 0) { + validateArchiveRange(buffer, offset, 22, 'end of central directory record'); + if (buffer.readUInt32LE(offset) !== SIG_EOCD) { + throw new ERR_ZIP_INVALID_ARCHIVE('end of central directory signature is invalid'); + } + this.#buffer = buffer; + this.#offset = offset; + if (offset + this.byteLength > buffer.length) { + throw new ERR_ZIP_INVALID_ARCHIVE('end of central directory record is truncated'); + } + } + get byteLength() { return 22 + this.commentLength; } + get diskNumber() { return this.#buffer.readUInt16LE(this.#offset + 4); } + get centralDirectoryDiskNumber() { return this.#buffer.readUInt16LE(this.#offset + 6); } + get centralDirectoryDiskRecords() { return this.#buffer.readUInt16LE(this.#offset + 8); } + get centralDirectoryTotalRecords() { return this.#buffer.readUInt16LE(this.#offset + 10); } + get centralDirectorySize() { return this.#buffer.readUInt32LE(this.#offset + 12); } + get centralDirectoryOffset() { return this.#buffer.readUInt32LE(this.#offset + 16); } + get commentLength() { return this.#buffer.readUInt16LE(this.#offset + 20); } + get commentBuffer() { + const start = this.#offset + 22; + return this.#buffer.subarray(start, start + this.commentLength); + } +} + +// Zip64 end of central directory record (sec. 4.3.14). +// 0 4 Signature = 0x06064b50 +// 4 8 Size of remainder of this record +// 12 2 Version made by +// 14 2 Version needed to extract +// 16 4 Number of this disk +// 20 4 Disk where central directory starts +// 24 8 Number of central directory records on this disk +// 32 8 Total number of central directory records +// 40 8 Size of central directory +// 48 8 Offset of start of central directory +class Zip64EndRecord { + #buffer; + #offset; + constructor(buffer, offset = 0) { + validateArchiveRange(buffer, offset, 56, 'Zip64 end of central directory record'); + if (buffer.readUInt32LE(offset) !== SIG_ZIP64_EOCD_RECORD) { + throw new ERR_ZIP_INVALID_ARCHIVE( + 'Zip64 end of central directory signature is invalid'); + } + this.#buffer = buffer; + this.#offset = offset; + } + get diskNumber() { return this.#buffer.readUInt32LE(this.#offset + 16); } + get centralDirectoryDiskNumber() { return this.#buffer.readUInt32LE(this.#offset + 20); } + get centralDirectoryDiskRecords() { return readSafeUint64(this.#buffer, this.#offset + 24); } + get centralDirectoryTotalRecords() { return readSafeUint64(this.#buffer, this.#offset + 32); } + get centralDirectorySize() { return readSafeUint64(this.#buffer, this.#offset + 40); } + get centralDirectoryOffset() { return readSafeUint64(this.#buffer, this.#offset + 48); } +} + +// Zip64 end of central directory locator (sec. 4.3.15). +// 0 4 Signature = 0x07064b50 +// 4 4 Disk with the Zip64 end of central directory record +// 8 8 Offset of the Zip64 end of central directory record +// 16 4 Total number of disks +class Zip64EndLocator { + #buffer; + #offset; + constructor(buffer, offset = 0) { + validateArchiveRange(buffer, offset, 20, 'Zip64 end of central directory locator'); + if (buffer.readUInt32LE(offset) !== SIG_ZIP64_EOCD_LOCATOR) { + throw new ERR_ZIP_INVALID_ARCHIVE( + 'Zip64 end of central directory locator signature is invalid'); + } + this.#buffer = buffer; + this.#offset = offset; + } + get recordDiskNumber() { return this.#buffer.readUInt32LE(this.#offset + 4); } + // Spec field (sec. 4.3.15); not consumed as a getter - findArchiveEnd() + // reads the offset leniently via readBigUInt64LE instead, so a locator + // signature that is really comment data cannot turn into a hard parse + // error on an out-of-range value. + // get recordOffset() { return readSafeUint64(this.#buffer, this.#offset + 8); } + get totalDisks() { return this.#buffer.readUInt32LE(this.#offset + 16); } +} + +// Central directory file header (sec. 4.3.12). +// 0 4 Signature = 0x02014b50 +// 4 2 Version made by +// 6 2 Version needed to extract +// 8 2 General purpose bit flag +// 10 2 Compression method +// 12 2 Last modification time +// 14 2 Last modification date +// 16 4 CRC-32 +// 20 4 Compressed size +// 24 4 Uncompressed size +// 28 2 File name length (n) +// 30 2 Extra field length (m) +// 32 2 File comment length (k) +// 34 2 Disk number where file starts +// 36 2 Internal file attributes +// 38 4 External file attributes +// 42 4 Relative offset of local file header +// 46 n File name +// 46+n m Extra field +// 46+n+m k File comment +class CentralFileHeader { + #buffer; + #offset; + #zip64 = null; + constructor(buffer, offset = 0) { + validateArchiveRange(buffer, offset, 46, 'central directory header'); + if (buffer.readUInt32LE(offset) !== SIG_CENTRAL_FILE_HEADER) { + throw new ERR_ZIP_INVALID_ARCHIVE('central directory header signature is invalid'); + } + this.#buffer = buffer; + this.#offset = offset; + if (offset + this.byteLength > buffer.length) { + throw new ERR_ZIP_INVALID_ARCHIVE('central directory header is truncated'); + } + } + get byteOffset() { return this.#offset; } + get byteLength() { + return 46 + this.fileNameLength + this.extraFieldLength + this.fileCommentLength; + } + get version() { return this.#buffer.readUInt16LE(this.#offset + 4); } + // Spec field "version needed to extract" (sec. 4.4.3); not consumed today. + // get versionNeeded() { return this.#buffer.readUInt16LE(this.#offset + 6); } + get flags() { return this.#buffer.readUInt16LE(this.#offset + 8); } + get compressionMethod() { return this.#buffer.readUInt16LE(this.#offset + 10); } + get lastModified() { + return decodeDosDateTime( + this.#buffer.readUInt16LE(this.#offset + 12), + this.#buffer.readUInt16LE(this.#offset + 14)); + } + get crc32() { return this.#buffer.readUInt32LE(this.#offset + 16); } + // Lazily parse the Zip64 extended-information extra field (sec. 4.5.3), + // supplying the true 64-bit values only for the classic fields that hold an + // overflow sentinel. Cached across getters. + #resolveZip64() { + if (this.#zip64 === null) { + this.#zip64 = parseZip64Extra(this.extraField, { + uncompressedSize: this.#buffer.readUInt32LE(this.#offset + 24) === SENTINEL32, + compressedSize: this.#buffer.readUInt32LE(this.#offset + 20) === SENTINEL32, + localFileHeaderOffset: this.#buffer.readUInt32LE(this.#offset + 42) === SENTINEL32, + diskNumber: this.#buffer.readUInt16LE(this.#offset + 34) === SENTINEL16, + }); + } + return this.#zip64; + } + get compressedSize() { + const value = this.#buffer.readUInt32LE(this.#offset + 20); + return value === SENTINEL32 ? this.#resolveZip64().compressedSize : value; + } + get uncompressedSize() { + const value = this.#buffer.readUInt32LE(this.#offset + 24); + return value === SENTINEL32 ? this.#resolveZip64().uncompressedSize : value; + } + get fileNameLength() { return this.#buffer.readUInt16LE(this.#offset + 28); } + get extraFieldLength() { return this.#buffer.readUInt16LE(this.#offset + 30); } + get fileCommentLength() { return this.#buffer.readUInt16LE(this.#offset + 32); } + get diskNumber() { + const value = this.#buffer.readUInt16LE(this.#offset + 34); + return value === SENTINEL16 ? this.#resolveZip64().diskNumber : value; + } + get internalFileAttributes() { return this.#buffer.readUInt16LE(this.#offset + 36); } + get externalFileAttributes() { return this.#buffer.readUInt32LE(this.#offset + 38); } + get localFileHeaderOffset() { + const value = this.#buffer.readUInt32LE(this.#offset + 42); + return value === SENTINEL32 ? this.#resolveZip64().localFileHeaderOffset : value; + } + get fileNameBuffer() { + const start = this.#offset + 46; + return this.#buffer.subarray(start, start + this.fileNameLength); + } + get fileName() { return decodeZipName(this.fileNameBuffer, this.flags, this.extraField); } + get extraField() { + const start = this.#offset + 46 + this.fileNameLength; + return this.#buffer.subarray(start, start + this.extraFieldLength); + } + get fileCommentBuffer() { + const start = this.#offset + 46 + this.fileNameLength + this.extraFieldLength; + return this.#buffer.subarray(start, start + this.fileCommentLength); + } + get fileComment() { return decodeZipText(this.fileCommentBuffer, this.flags); } + get isUnixMode() { return (this.version >>> 8) === MADE_BY_UNIX; } + get mode() { + // Low 12 bits: permissions plus setuid/setgid/sticky (sec. 4.4.15). + return this.isUnixMode ? (this.externalFileAttributes >>> 16) & 0o7777 : 0; + } + get isSymlink() { + return this.isUnixMode && + ((this.externalFileAttributes >>> 16) & S_IFMT) === S_IFLNK; + } +} + +// Local file header (sec. 4.3.7). +// 0 4 Signature = 0x04034b50 +// 4 2 Version needed to extract +// 6 2 General purpose bit flag +// 8 2 Compression method +// 10 2 Last modification time +// 12 2 Last modification date +// 14 4 CRC-32 +// 18 4 Compressed size +// 22 4 Uncompressed size +// 26 2 File name length (n) +// 28 2 Extra field length (m) +// 30 n File name +// 30+n m Extra field +class LocalFileHeader { + #buffer; + #offset; + constructor(buffer, offset = 0) { + validateArchiveRange(buffer, offset, 30, 'local file header'); + if (buffer.readUInt32LE(offset) !== SIG_LOCAL_FILE_HEADER) { + throw new ERR_ZIP_INVALID_ARCHIVE('local file header signature is invalid'); + } + this.#buffer = buffer; + this.#offset = offset; + if (offset + this.byteLength > buffer.length) { + throw new ERR_ZIP_INVALID_ARCHIVE('local file header is truncated'); + } + } + get byteLength() { return 30 + this.fileNameLength + this.extraFieldLength; } + get flags() { return this.#buffer.readUInt16LE(this.#offset + 6); } + // Spec field (sec. 4.4.5); the central directory's method is authoritative, + // so the local copy is not consumed today. + // get compressionMethod() { return this.#buffer.readUInt16LE(this.#offset + 8); } + get fileNameLength() { return this.#buffer.readUInt16LE(this.#offset + 26); } + get extraFieldLength() { return this.#buffer.readUInt16LE(this.#offset + 28); } + get extraField() { + const start = this.#offset + 30 + this.fileNameLength; + return this.#buffer.subarray(start, start + this.extraFieldLength); + } + // The local header also carries the name and modification time (sec. 4.3.7), + // but the central directory is authoritative for both - a mismatching local + // name is ignored by design - so only the flags and the extra field (which + // may hold a higher-resolution timestamp) are consumed here. + // get fileName() { + // const start = this.#offset + 30; + // return decodeZipName( + // this.#buffer.subarray(start, start + this.fileNameLength), this.flags, this.extraField); + // } + // get lastModified() { + // return decodeDosDateTime(this.#buffer.readUInt16LE(this.#offset + 10), + // this.#buffer.readUInt16LE(this.#offset + 12)); + // } + // Total on-disk length of the local header at `offset` (sec. 4.3.7), read + // straight from the length fields without constructing a header; 0 if the + // fixed part does not fit. Lets the reader skip to the entry data. + static length(buffer, offset) { + if (offset + 30 > buffer.length) return 0; + return 30 + buffer.readUInt16LE(offset + 26) + buffer.readUInt16LE(offset + 28); + } +} + +/** + * Locates and validates the end-of-archive structures (EOCD, and the Zip64 + * EOCD locator/record when present) in `buffer`. `base` is the absolute + * offset of `buffer[0]` when `buffer` is only the tail of a larger file; all + * returned offsets are absolute. `buffer` must extend to the end of the + * archive. + * + * When a required Zip64 EOCD record starts before `buffer[0]` (its + * extensible data sector can push it beyond any fixed-size tail read), + * returns `{ needTailFrom }` instead: the caller must retry with a buffer + * that starts at that absolute offset (still ending at the end of the + * archive). Never returned when `base` is 0. + * @returns {{ + * prefix: number, + * totalRecords: number, + * centralDirectoryOffset: number, + * centralDirectorySize: number, + * comment: Buffer, + * } | { needTailFrom: number }} + */ +function findArchiveEnd(buffer, base = 0) { + if (buffer.length < 22) { + throw new ERR_ZIP_INVALID_ARCHIVE('no end of central directory record found'); + } + const min = MathMax(0, buffer.length - (22 + SENTINEL16)); + let eocdPos = -1; + // Pass 1: the comment must reach exactly to the end of the buffer (this + // rejects a stray EOCD-looking signature inside an earlier comment). + for (let pos = buffer.length - 22; pos >= min; pos--) { + if (buffer.readUInt32LE(pos) !== SIG_EOCD) continue; + if (pos + 22 + buffer.readUInt16LE(pos + 20) !== buffer.length) continue; + eocdPos = pos; + break; + } + if (eocdPos < 0) { + // Pass 2: tolerate trailing padding after the EOCD (some streaming + // writers pad their output to a fixed block size); take the last + // candidate found. + for (let pos = buffer.length - 22; pos >= min; pos--) { + if (buffer.readUInt32LE(pos) !== SIG_EOCD) continue; + if (pos + 22 + buffer.readUInt16LE(pos + 20) > buffer.length) continue; + eocdPos = pos; + break; + } + } + if (eocdPos < 0) { + throw new ERR_ZIP_INVALID_ARCHIVE('no end of central directory record found'); + } + const eocd = new CentralEndHeader(buffer, eocdPos); + let totalRecords = eocd.centralDirectoryTotalRecords; + let centralDirectorySize = eocd.centralDirectorySize; + let centralDirectoryOffset = eocd.centralDirectoryOffset; + let prefix; + // A classic field at its maximum is an overflow sentinel that makes the + // Zip64 record mandatory. Otherwise the classic fields are authoritative, + // and a Zip64 locator signature in the preceding bytes is not proof of a + // Zip64 archive - it may be the tail of a file comment that happens to + // contain those four bytes - so a failed Zip64 lookup falls back to the + // classic fields instead of rejecting the archive. + const needsZip64 = + eocd.diskNumber === SENTINEL16 || + eocd.centralDirectoryDiskNumber === SENTINEL16 || + eocd.centralDirectoryDiskRecords === SENTINEL16 || + totalRecords === SENTINEL16 || + centralDirectorySize === SENTINEL32 || + centralDirectoryOffset === SENTINEL32; + let zip64 = null; + let recordPos = -1; + const locatorPos = eocdPos - 20; + if ( + locatorPos >= 0 && + buffer.readUInt32LE(locatorPos) === SIG_ZIP64_EOCD_LOCATOR + ) { + const locator = new Zip64EndLocator(buffer, locatorPos); + // Read the recorded offset leniently: on a coincidental signature these + // eight bytes are arbitrary comment data, which must not turn into a + // hard parse error. + const rawRecordOffset = buffer.readBigUInt64LE(locatorPos + 8); + const recordOffset = + rawRecordOffset <= BIGINT_MAX_SAFE_INTEGER ? Number(rawRecordOffset) : -1; + recordPos = recordOffset >= 0 ? recordOffset - base : -1; + if ( + !(recordPos >= 0 && + recordPos + 56 <= locatorPos && + buffer.readUInt32LE(recordPos) === SIG_ZIP64_EOCD_RECORD) + ) { + // Data was prepended to the archive, shifting the record; scan + // backward from the locator instead of trusting its recorded offset. + recordPos = -1; + const floor = MathMax(0, locatorPos - 56 - SENTINEL16); + for (let pos = locatorPos - 56; pos >= floor; pos--) { + if (buffer.readUInt32LE(pos) !== SIG_ZIP64_EOCD_RECORD) continue; + const size = buffer.readBigUInt64LE(pos + 4); + if (size >= 44n && pos + 12 + Number(size) === locatorPos) { + recordPos = pos; + break; + } + } + } + if (recordPos < 0 && needsZip64) { + // The record is required but does not lie inside `buffer`: its + // extensible data sector may extend it beyond any fixed-size tail + // read. When the locator points (plausibly) before the bytes at + // hand, ask the caller for a longer tail instead of failing. + if ( + recordOffset >= 0 && + recordOffset < base && + base + locatorPos - recordOffset <= ZIP64_EOCD_MAX_LENGTH + ) { + return { needTailFrom: recordOffset }; + } + throw new ERR_ZIP_INVALID_ARCHIVE('Zip64 end of central directory record not found'); + } + if (recordPos >= 0) { + if (locator.totalDisks > 1 || locator.recordDiskNumber !== 0) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported'); + } + zip64 = new Zip64EndRecord(buffer, recordPos); + } + } + // The `prefix` math assumes nothing sits between the central directory and + // the archive-end records. APPNOTE sec. 4.3.13 allows a digital-signature + // record there; such an archive shifts `prefix` by the signature's length + // and fails with a central-directory signature error rather than a targeted + // message - signed archives are essentially extinct, so none is emitted. + if (zip64 !== null) { + if (zip64.diskNumber !== 0 || zip64.centralDirectoryDiskNumber !== 0) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported'); + } + if (zip64.centralDirectoryDiskRecords !== zip64.centralDirectoryTotalRecords) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported'); + } + // A classic field either holds the overflow sentinel (its real value lives + // in the Zip64 record) or its own real value, which must then match the + // Zip64 record. A non-sentinel classic field that disagrees with Zip64 is a + // parser differential - a classic-only reader and this one would see + // different archives - so reject it rather than silently preferring Zip64. + if ( + (totalRecords !== SENTINEL16 && + totalRecords !== zip64.centralDirectoryTotalRecords) || + (centralDirectorySize !== SENTINEL32 && + centralDirectorySize !== zip64.centralDirectorySize) || + (centralDirectoryOffset !== SENTINEL32 && + centralDirectoryOffset !== zip64.centralDirectoryOffset) + ) { + throw new ERR_ZIP_INVALID_ARCHIVE( + 'Zip64 and classic end-of-central-directory records disagree'); + } + totalRecords = zip64.centralDirectoryTotalRecords; + centralDirectorySize = zip64.centralDirectorySize; + centralDirectoryOffset = zip64.centralDirectoryOffset; + prefix = base + recordPos - (centralDirectoryOffset + centralDirectorySize); + } else { + if (eocd.diskNumber !== 0 || eocd.centralDirectoryDiskNumber !== 0) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported'); + } + if (eocd.centralDirectoryDiskRecords !== totalRecords) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported'); + } + prefix = base + eocdPos - (centralDirectoryOffset + centralDirectorySize); + } + if (prefix < 0) { + throw new ERR_ZIP_INVALID_ARCHIVE('central directory does not fit inside the archive'); + } + if (totalRecords * 46 > centralDirectorySize) { + throw new ERR_ZIP_INVALID_ARCHIVE( + 'central directory record count is inconsistent with its size'); + } + return { + prefix, + totalRecords, + centralDirectoryOffset: centralDirectoryOffset + prefix, + centralDirectorySize, + comment: eocd.commentBuffer, + }; +} + +// Walk the contiguous run of `count` central directory file headers +// (sec. 4.3.12) into an array, rejecting multi-disk archives. +function readCentralDirectory(buffer, count) { + const result = []; + let pos = 0; + for (let index = 0; index < count; index++) { + const header = new CentralFileHeader(buffer, pos); + if (header.diskNumber !== 0) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported'); + } + ArrayPrototypePush(result, header); + pos += header.byteLength; + } + return result; +} + +module.exports = { + CentralFileHeader, + LocalFileHeader, + findArchiveEnd, + readCentralDirectory, +}; diff --git a/lib/vfs.js b/lib/vfs.js index 0d12229aca72..d1e2a75fec56 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 { ZipProvider } = require('internal/vfs/providers/archive'); /** * Creates a new VirtualFileSystem instance. @@ -34,4 +35,5 @@ module.exports = { VirtualProvider, MemoryProvider, RealFSProvider, + ZipProvider, }; diff --git a/lib/zlib.js b/lib/zlib.js index e6c5c420d551..29c61b51fc6f 100644 --- a/lib/zlib.js +++ b/lib/zlib.js @@ -31,7 +31,9 @@ const { ObjectFreeze, ObjectKeys, ObjectSetPrototypeOf, + ReflectApply, Symbol, + SymbolHasInstance, Uint32Array, Uint8Array, } = primordials; @@ -51,6 +53,7 @@ const { Transform, finished } = require('stream'); const { assignFunctionName, deprecateInstantiation, + emitExperimentalWarning, } = require('internal/util'); const { isArrayBufferView, @@ -73,6 +76,16 @@ const { validateFiniteNumber, } = require('internal/validators'); const { FastBuffer } = require('internal/buffer'); +const { + ZipEntry, + ZipFile, + ZipBuffer, + createZipArchive, + createZipArchiveSync, + zipFiles, + getMaxZipContentSize, + setMaxZipContentSize, +} = require('internal/zip'); const kFlushFlag = Symbol('kFlushFlag'); const kError = Symbol('kError'); @@ -995,6 +1008,62 @@ function createProperty(ctor) { }; } +// ZIP archive support is experimental. The warning fires when the API is +// *used*, not when node:zlib is imported: the ESM facade reads every export to +// build its bindings (see BuiltinModule.syncExports), so warning on property +// access would fire on a bare `import 'node:zlib'`. Each public class is a thin +// subclass whose factory methods (and, for ZipBuffer, its constructor) warn; +// each function is a warn-on-call wrapper. A `[Symbol.hasInstance]` override +// keeps `instanceof` matching instances produced by the internal (unwrapped) +// implementation. The rest of node:zlib is stable and never warns. +function emitZipExperimentalWarning() { + emitExperimentalWarning('The zlib ZIP archive API'); +} + +function experimentalZipFunction(fn, thisArg) { + return function(...args) { + emitZipExperimentalWarning(); + return ReflectApply(fn, thisArg, args); + }; +} + +function experimentalZipProperty(value) { + return { __proto__: null, configurable: true, enumerable: true, value }; +} + +// Thin subclasses that gate *use* of the experimental ZIP API behind the +// warning while leaving `instanceof` (and the public class name) intact. +class ExperimentalZipEntry extends ZipEntry { + static [SymbolHasInstance](instance) { return instance instanceof ZipEntry; } +} + +class ExperimentalZipFile extends ZipFile { + static [SymbolHasInstance](instance) { return instance instanceof ZipFile; } +} + +class ExperimentalZipBuffer extends ZipBuffer { + static [SymbolHasInstance](instance) { return instance instanceof ZipBuffer; } + constructor(buffer) { emitZipExperimentalWarning(); super(buffer); } +} + +// Shadow each public factory with a warn-on-call wrapper, and restore the +// public class name that subclassing changed. +for (const { 0: Wrapper, 1: Raw, 2: factories } of [ + [ExperimentalZipEntry, ZipEntry, ['read', 'create', 'createSync', 'createStream', 'createSymlink']], + [ExperimentalZipFile, ZipFile, ['open', 'openSync']], + [ExperimentalZipBuffer, ZipBuffer, []], +]) { + for (const name of factories) { + ObjectDefineProperty(Wrapper, name, { + __proto__: null, + configurable: true, + writable: true, + value: experimentalZipFunction(Raw[name], Raw), + }); + } + ObjectDefineProperty(Wrapper, 'name', { __proto__: null, value: Raw.name }); +} + function crc32(data, value = 0) { if (typeof data !== 'string' && !isArrayBufferView(data)) { throw new ERR_INVALID_ARG_TYPE('data', ['Buffer', 'TypedArray', 'DataView', 'string'], data); @@ -1077,6 +1146,16 @@ ObjectDefineProperties(module.exports, { writable: false, value: ObjectFreeze(codes), }, + + // ZIP archive support (experimental). + ZipEntry: experimentalZipProperty(ExperimentalZipEntry), + ZipFile: experimentalZipProperty(ExperimentalZipFile), + ZipBuffer: experimentalZipProperty(ExperimentalZipBuffer), + createZipArchive: experimentalZipProperty(experimentalZipFunction(createZipArchive)), + createZipArchiveSync: experimentalZipProperty(experimentalZipFunction(createZipArchiveSync)), + zipFiles: experimentalZipProperty(experimentalZipFunction(zipFiles)), + getMaxZipContentSize: experimentalZipProperty(experimentalZipFunction(getMaxZipContentSize)), + setMaxZipContentSize: experimentalZipProperty(experimentalZipFunction(setMaxZipContentSize)), }); // These should be considered deprecated diff --git a/src/dtls/dtls.h b/src/dtls/dtls.h index 6f1737347433..1faed3910e21 100644 --- a/src/dtls/dtls.h +++ b/src/dtls/dtls.h @@ -58,16 +58,18 @@ void RecordTimestampStat(Stats* stats) { V(MESSAGES_SENT, messages_sent) \ V(RETRANSMIT_COUNT, retransmit_count) -// State indices shared between C++ and JS via AliasedStruct/DataView. +// State "indices" shared between C++ and JS via AliasedStruct/DataView. These +// are BYTE OFFSETS into the state struct, not sequential indices: session_count +// is a uint32, so `busy`, which follows it, sits at byte offset 8. The +// static_asserts in dtls_endpoint.cc pin these to the actual struct layout. // Keep in sync with lib/internal/dtls/state.js. enum DTLSEndpointStateIndex { IDX_ENDPOINT_STATE_BOUND = 0, - IDX_ENDPOINT_STATE_LISTENING, - IDX_ENDPOINT_STATE_CLOSING, - IDX_ENDPOINT_STATE_DESTROYED, - IDX_ENDPOINT_STATE_SESSION_COUNT, - IDX_ENDPOINT_STATE_BUSY, - IDX_ENDPOINT_STATE_COUNT + IDX_ENDPOINT_STATE_LISTENING = 1, + IDX_ENDPOINT_STATE_CLOSING = 2, + IDX_ENDPOINT_STATE_DESTROYED = 3, + IDX_ENDPOINT_STATE_SESSION_COUNT = 4, + IDX_ENDPOINT_STATE_BUSY = 8, }; enum DTLSSessionStateIndex { diff --git a/src/dtls/dtls_context.cc b/src/dtls/dtls_context.cc index ca5003df46c2..d59ee74feeb9 100644 --- a/src/dtls/dtls_context.cc +++ b/src/dtls/dtls_context.cc @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -35,6 +36,11 @@ namespace dtls { namespace { // The cookie secret is 32 bytes (256 bits). constexpr size_t kCookieSecretLen = 32; +// Cookies are bound to a coarse time window so they expire. A cookie is +// accepted for the window it was minted in and the immediately preceding one, +// giving ~30-60s of validity -- ample for the cookie exchange while bounding +// how long a captured cookie can be replayed. +constexpr uint64_t kCookieWindowNs = 30ull * 1000 * 1000 * 1000; } // namespace DTLSContext::DTLSContext(Environment* env, @@ -317,8 +323,11 @@ void DTLSContext::SetALPN(const FunctionCallbackInfo& args) { ctx->alpn_protos_.assign(data, data + len); SSL_CTX_set_alpn_select_cb(ctx->ctx_.get(), ALPNSelectCallback, ctx); } else { - // Client: advertise protocols to the server. - SSL_CTX_set_alpn_protos(ctx->ctx_.get(), data, len); + // Client: advertise protocols to the server. Returns 0 on success. + if (SSL_CTX_set_alpn_protos(ctx->ctx_.get(), data, len) != 0) { + return THROW_ERR_CRYPTO_OPERATION_FAILED( + env, "SSL_CTX_set_alpn_protos failed"); + } } } @@ -368,64 +377,77 @@ void DTLSContext::SetECDHCurve(const FunctionCallbackInfo& args) { } } -// HMAC-SHA256 based cookie generation using the peer's address. -// During DTLSv1_listen(), the peer address is taken from -// DTLSContext::current_cookie_peer_ (set synchronously before the call). -// During session handshake, the peer address is taken from the +// HMAC-SHA256 cookie derived from the peer's address and a coarse time window +// so cookies expire (see kCookieWindowNs). During DTLSv1_listen() the peer +// address comes from DTLSContext::current_cookie_peer_ (set synchronously +// before the call); during the session handshake it comes from the // DTLSSession stored in SSL app_data. -int DTLSContext::CookieGenerateCallback(SSL* ssl, - unsigned char* cookie, - unsigned int* cookie_len) { +bool DTLSContext::ComputeCookie(SSL* ssl, + uint64_t window, + unsigned char* out, + unsigned int* out_len) { SSL_CTX* ctx = SSL_get_SSL_CTX(ssl); DTLSContext* dtls_ctx = static_cast(SSL_CTX_get_app_data(ctx)); CHECK_NOT_NULL(dtls_ctx); - unsigned char addr_buf[sizeof(struct sockaddr_storage)]; + // Message = peer address bytes followed by the 8-byte window counter. + unsigned char msg[sizeof(struct sockaddr_storage) + sizeof(uint64_t)]; size_t addr_len = 0; void* app_data = SSL_get_app_data(ssl); if (app_data != nullptr) { // Session handshake path. - auto* session = static_cast(app_data); - const sockaddr* sa = session->remote_address().data(); + const sockaddr* sa = + static_cast(app_data)->remote_address().data(); addr_len = SocketAddress::GetLength(sa); - memcpy(addr_buf, sa, addr_len); + memcpy(msg, sa, addr_len); } else { - // DTLSv1_listen path — use the peer address stored on the context. + // DTLSv1_listen path -- use the peer address stored on the context. const sockaddr* sa = dtls_ctx->current_cookie_peer_.data(); addr_len = SocketAddress::GetLength(sa); - memcpy(addr_buf, sa, addr_len); + memcpy(msg, sa, addr_len); + } + + // Append the window counter in a fixed byte order. + for (size_t i = 0; i < sizeof(uint64_t); i++) { + msg[addr_len + i] = static_cast((window >> (8 * i)) & 0xff); } - unsigned int hmac_len = 0; unsigned char* result = HMAC(EVP_sha256(), dtls_ctx->cookie_secret_.data(), dtls_ctx->cookie_secret_.size(), - addr_buf, - addr_len, - cookie, - &hmac_len); - - if (result == nullptr) return 0; + msg, + addr_len + sizeof(uint64_t), + out, + out_len); + return result != nullptr; +} - *cookie_len = hmac_len; - return 1; +int DTLSContext::CookieGenerateCallback(SSL* ssl, + unsigned char* cookie, + unsigned int* cookie_len) { + const uint64_t window = uv_hrtime() / kCookieWindowNs; + return ComputeCookie(ssl, window, cookie, cookie_len) ? 1 : 0; } int DTLSContext::CookieVerifyCallback(SSL* ssl, const unsigned char* cookie, unsigned int cookie_len) { - // Generate the expected cookie and compare. + const uint64_t window = uv_hrtime() / kCookieWindowNs; + + // Accept a cookie minted in the current window or the immediately preceding + // one, so a handshake that straddles a window boundary still succeeds. unsigned char expected[EVP_MAX_MD_SIZE]; unsigned int expected_len = 0; - - if (CookieGenerateCallback(ssl, expected, &expected_len) != 1) { - return 0; + for (int i = 0; i < 2; i++) { + if (i == 1 && window == 0) break; + if (ComputeCookie(ssl, window - i, expected, &expected_len) && + cookie_len == expected_len && + CRYPTO_memcmp(cookie, expected, expected_len) == 0) { + return 1; + } } - - if (cookie_len != expected_len) return 0; - - return CRYPTO_memcmp(cookie, expected, expected_len) == 0 ? 1 : 0; + return 0; } int DTLSContext::ALPNSelectCallback(SSL* ssl, diff --git a/src/dtls/dtls_context.h b/src/dtls/dtls_context.h index 11d8113d3081..5c994ea769de 100644 --- a/src/dtls/dtls_context.h +++ b/src/dtls/dtls_context.h @@ -57,6 +57,14 @@ class DTLSContext final : public BaseObject { static void LoadDefaultCAs(const v8::FunctionCallbackInfo& args); static void SetECDHCurve(const v8::FunctionCallbackInfo& args); + // Compute the address-and-time-window-bound cookie for |window| into |out| + // (which must have room for EVP_MAX_MD_SIZE bytes). Shared by the cookie + // generate/verify callbacks. + static bool ComputeCookie(SSL* ssl, + uint64_t window, + unsigned char* out, + unsigned int* out_len); + // Automatic DTLS cookie callbacks static int CookieGenerateCallback(SSL* ssl, unsigned char* cookie, diff --git a/src/dtls/dtls_endpoint.cc b/src/dtls/dtls_endpoint.cc index 9433241a2d14..cc2aec6b58e3 100644 --- a/src/dtls/dtls_endpoint.cc +++ b/src/dtls/dtls_endpoint.cc @@ -19,6 +19,7 @@ #include #include +#include #include namespace node { @@ -45,6 +46,21 @@ struct SendReq { }; } // namespace +// The endpoint state "indices" are byte offsets into DTLSEndpointStateData, +// accessed from JS via a DataView. Pin them to the actual struct layout so a +// mismatch (as once existed for `busy`, which follows a uint32) can't recur. +static_assert(IDX_ENDPOINT_STATE_BOUND == + offsetof(DTLSEndpointStateData, bound)); +static_assert(IDX_ENDPOINT_STATE_LISTENING == + offsetof(DTLSEndpointStateData, listening)); +static_assert(IDX_ENDPOINT_STATE_CLOSING == + offsetof(DTLSEndpointStateData, closing)); +static_assert(IDX_ENDPOINT_STATE_DESTROYED == + offsetof(DTLSEndpointStateData, destroyed)); +static_assert(IDX_ENDPOINT_STATE_SESSION_COUNT == + offsetof(DTLSEndpointStateData, session_count)); +static_assert(IDX_ENDPOINT_STATE_BUSY == offsetof(DTLSEndpointStateData, busy)); + DTLSEndpoint::DTLSEndpoint(Environment* env, Local wrap) : HandleWrap(env, wrap, @@ -155,7 +171,10 @@ int DTLSEndpoint::Listen(DTLSContext* context) { } BaseObjectPtr DTLSEndpoint::Connect(DTLSContext* context, - const SocketAddress& remote) { + const SocketAddress& remote, + const char* servername, + const char* verify_host, + bool verify_is_ip) { if (IsHandleClosing()) { THROW_ERR_INVALID_STATE(env(), "Endpoint is closing"); return {}; @@ -168,8 +187,14 @@ BaseObjectPtr DTLSEndpoint::Connect(DTLSContext* context, return {}; } - auto session = DTLSSession::Create( - env(), this, context->ssl_ctx(), remote, false /* is_server */); + auto session = DTLSSession::Create(env(), + this, + context->ssl_ctx(), + remote, + false /* is_server */, + servername, + verify_host, + verify_is_ip); if (!session) return {}; @@ -259,6 +284,11 @@ void DTLSEndpoint::CloseGracefully() { server_context_.reset(); + // Keep ourselves alive until OnClose() runs, so a garbage collection while + // uv_close() is in flight cannot collect the wrapper before the close is + // reported. Released in OnClose(). + self_ref_ = BaseObjectPtr(this); + // HandleWrap::Close() calls uv_close and manages the lifecycle. HandleWrap::Close(); } @@ -284,6 +314,9 @@ void DTLSEndpoint::Destroy() { state_->listening = 0; } + // Keep ourselves alive until OnClose() (see CloseGracefully()). + self_ref_ = BaseObjectPtr(this); + HandleWrap::Close(); } @@ -331,8 +364,16 @@ void DTLSEndpoint::SetCallbacks(Local callbacks) { void DTLSEndpoint::OnAlloc(uv_handle_t* handle, size_t suggested_size, uv_buf_t* buf) { - buf->base = new char[65536]; - buf->len = 65536; + DTLSEndpoint* endpoint = static_cast(handle->data); + // Reuse a single receive buffer. libuv delivers datagrams one at a time on + // this thread, and OnRecv fully consumes each datagram (copying it into the + // session's BIO) before the next OnAlloc, so a per-endpoint buffer suffices + // and avoids a heap allocation on every packet. + if (endpoint->recv_buf_.empty()) { + endpoint->recv_buf_.resize(65536); + } + buf->base = endpoint->recv_buf_.data(); + buf->len = endpoint->recv_buf_.size(); } void DTLSEndpoint::OnRecv(uv_udp_t* handle, @@ -342,13 +383,12 @@ void DTLSEndpoint::OnRecv(uv_udp_t* handle, unsigned int flags) { DTLSEndpoint* endpoint = static_cast(handle->data); + // buf->base is the endpoint's reusable recv_buf_; it is not freed here. if (nread == 0 && addr == nullptr) { - delete[] buf->base; return; } if (nread < 0) { - delete[] buf->base; HandleScope handle_scope(endpoint->env()->isolate()); Context::Scope context_scope(endpoint->env()->context()); Local argv[] = { @@ -363,7 +403,6 @@ void DTLSEndpoint::OnRecv(uv_udp_t* handle, } if (addr == nullptr) { - delete[] buf->base; return; } @@ -375,8 +414,6 @@ void DTLSEndpoint::OnRecv(uv_udp_t* handle, SocketAddress remote(addr); endpoint->ProcessDatagram( reinterpret_cast(buf->base), nread, remote); - - delete[] buf->base; } void DTLSEndpoint::OnSend(uv_udp_send_t* req, int status) { @@ -389,6 +426,17 @@ void DTLSEndpoint::OnClose() { state_->destroyed = 1; DTLS_STAT_RECORD_TIMESTAMP(DTLSEndpointStats, destroyed_at); + // Release the strong self-reference taken when the close was initiated. + // HandleWrap::OnClose still holds its own reference for the duration of this + // call, so this does not free us here. + self_ref_.reset(); + + // A close initiated outside CloseGracefully()/Destroy() (e.g. an endpoint + // abandoned mid-construction and closed at environment teardown) takes no + // self-reference, so its wrapper may already be collected. There is no JS + // side to notify in that case; skip it rather than touch a freed wrapper. + if (persistent().IsEmpty()) return; + Local cb = GetCallback(DTLS_CB_ENDPOINT_CLOSE); if (!cb.IsEmpty()) { Local argv[] = {}; @@ -532,6 +580,9 @@ void DTLSEndpoint::DoBind(const FunctionCallbackInfo& args) { return THROW_ERR_INVALID_ARG_VALUE(env, "Invalid address"); } + THROW_IF_INSUFFICIENT_PERMISSIONS( + env, permission::PermissionScope::kNet, addr.ToString()); + int err = endpoint->Bind(addr); if (err != 0) { return THROW_ERR_INVALID_STATE(env, uv_strerror(err)); @@ -576,7 +627,17 @@ void DTLSEndpoint::DoConnect(const FunctionCallbackInfo& args) { THROW_IF_INSUFFICIENT_PERMISSIONS( env, permission::PermissionScope::kNet, remote.ToString()); - auto session = endpoint->Connect(context, remote); + // Optional: servername (SNI), verifyHost (expected peer identity), and + // whether verifyHost is an IP literal. These are resolved in JS and applied + // to the client SSL before the handshake starts. + Utf8Value servername(env->isolate(), args[3]); + Utf8Value verify_host(env->isolate(), args[4]); + const char* servername_ptr = args[3]->IsString() ? *servername : nullptr; + const char* verify_host_ptr = args[4]->IsString() ? *verify_host : nullptr; + bool verify_is_ip = args[5]->IsTrue(); + + auto session = endpoint->Connect( + context, remote, servername_ptr, verify_host_ptr, verify_is_ip); if (session) { args.GetReturnValue().Set(session->object()); } @@ -641,6 +702,7 @@ void DTLSEndpoint::DoSetCallbacks(const FunctionCallbackInfo& args) { void DTLSEndpoint::MemoryInfo(MemoryTracker* tracker) const { tracker->TrackField("sessions", sessions_.size()); + tracker->TrackFieldWithSize("recv_buf", recv_buf_.size()); } } // namespace dtls diff --git a/src/dtls/dtls_endpoint.h b/src/dtls/dtls_endpoint.h index a6fe94fff5b8..bed49a7db0e0 100644 --- a/src/dtls/dtls_endpoint.h +++ b/src/dtls/dtls_endpoint.h @@ -14,6 +14,7 @@ #include #include +#include #include "dtls.h" #include "dtls_context.h" @@ -59,9 +60,15 @@ class DTLSEndpoint final : public HandleWrap { int Listen(DTLSContext* context); // Initiate a client connection to the given address. + // |servername|/|verify_host|/|verify_is_ip| configure SNI and peer identity + // verification on the client SSL before the handshake begins; see + // DTLSSession::Create. // Returns the created DTLSSession. BaseObjectPtr Connect(DTLSContext* context, - const SocketAddress& remote); + const SocketAddress& remote, + const char* servername = nullptr, + const char* verify_host = nullptr, + bool verify_is_ip = false); // Send a raw UDP datagram to the given address. // Called by DTLSSession to send encrypted packets. @@ -129,6 +136,11 @@ class DTLSEndpoint final : public HandleWrap { uv_udp_t handle_; + // Reusable receive buffer for uv_udp_recv (see OnAlloc). libuv delivers one + // datagram at a time and OnRecv consumes each before the next OnAlloc, so a + // single buffer per endpoint avoids a heap allocation on every packet. + std::vector recv_buf_; + // Session table: maps remote address -> session. std::unordered_map, @@ -144,6 +156,11 @@ class DTLSEndpoint final : public HandleWrap { AliasedStruct state_; AliasedStruct stats_; + // Strong self-reference held while a graceful close/destroy is in flight, so + // the wrapper is not garbage-collected before OnClose() runs and reports the + // close. Cleared in OnClose(). + BaseObjectPtr self_ref_; + bool listening_ = false; uint32_t mtu_ = 1200; // Conservative default MTU for data payload }; diff --git a/src/dtls/dtls_session.cc b/src/dtls/dtls_session.cc index 8bcd06a4ae71..02f1563abac7 100644 --- a/src/dtls/dtls_session.cc +++ b/src/dtls/dtls_session.cc @@ -18,6 +18,8 @@ #include #include #include +#include +#include #include @@ -53,6 +55,10 @@ DTLSSession::DTLSSession(Environment* env, retransmit_timer_(env, [this] { if (destroyed_) return; + // Keep ourselves alive across the callback: emitting + // an error or running Cycle() below can synchronously + // destroy this session, and this timer lives on it. + BaseObjectPtr strong_ref{this}; DTLS_STAT_INCREMENT(DTLSSessionStats, retransmit_count); int ret = DTLSv1_handle_timeout(ssl_.get()); @@ -115,7 +121,6 @@ Local DTLSSession::GetConstructorTemplate(Environment* env) { SetProtoMethod(isolate, tmpl, "getALPNProtocol", GetALPNProtocol); SetProtoMethod(isolate, tmpl, "exportKeyingMaterial", ExportKeyingMaterial); SetProtoMethod(isolate, tmpl, "getSRTPProfile", GetSRTPProfile); - SetProtoMethod(isolate, tmpl, "setServername", SetServername); SetProtoMethod(isolate, tmpl, "getServername", GetServername); env->set_dtls_session_constructor_template(tmpl); @@ -145,7 +150,6 @@ void DTLSSession::RegisterExternalReferences( registry->Register(GetALPNProtocol); registry->Register(ExportKeyingMaterial); registry->Register(GetSRTPProfile); - registry->Register(SetServername); registry->Register(GetServername); } @@ -153,7 +157,10 @@ BaseObjectPtr DTLSSession::Create(Environment* env, DTLSEndpoint* endpoint, SSL_CTX* ssl_ctx, const SocketAddress& remote, - bool is_server) { + bool is_server, + const char* servername, + const char* verify_host, + bool verify_is_ip) { // Create the SSL object. SSL* ssl_raw = SSL_new(ssl_ctx); if (ssl_raw == nullptr) { @@ -188,6 +195,43 @@ BaseObjectPtr DTLSSession::Create(Environment* env, SSL_set_accept_state(ssl.get()); } else { SSL_set_connect_state(ssl.get()); + + // Configure SNI and peer identity verification BEFORE the handshake + // starts. The caller (DTLSEndpoint::Connect) runs Cycle() immediately + // after Create() returns, which emits the ClientHello, so anything that + // must appear in that flight (SNI) has to be set here rather than via a + // post-construction setter. + if (servername != nullptr && servername[0] != '\0') { + if (!SSL_set_tlsext_host_name(ssl.get(), servername)) { + THROW_ERR_CRYPTO_OPERATION_FAILED(env, + "Failed to set servername (SNI)"); + return {}; + } + } + + // When identity verification is requested, bind the expected peer name + // (or IP) into the verification parameters. Combined with the context's + // SSL_VERIFY_PEER mode this makes a name mismatch fail the handshake, + // rather than accepting any certificate that merely chains to a trusted + // CA. A failure to configure it is fatal: proceeding would silently skip + // the identity check. + if (verify_host != nullptr && verify_host[0] != '\0') { + if (verify_is_ip) { + if (!X509_VERIFY_PARAM_set1_ip_asc(SSL_get0_param(ssl.get()), + verify_host)) { + THROW_ERR_CRYPTO_OPERATION_FAILED( + env, "Failed to set peer IP address for verification"); + return {}; + } + } else { + SSL_set_hostflags(ssl.get(), X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS); + if (!SSL_set1_host(ssl.get(), verify_host)) { + THROW_ERR_CRYPTO_OPERATION_FAILED( + env, "Failed to set peer hostname for verification"); + return {}; + } + } + } } // Create the JS wrapper object. @@ -246,6 +290,12 @@ void DTLSSession::Receive(const uint8_t* data, size_t len) { void DTLSSession::Cycle() { if (destroyed_) return; + // Pin a strong reference to ourselves for the duration of the pump. A JS + // callback dispatched below (message/handshake/error) can synchronously + // destroy this session, which removes the endpoint's only strong reference + // and would otherwise free `this` while we are still using ssl_/state_. + BaseObjectPtr strong_ref{this}; + // Prevent infinite recursion. if (++cycle_depth_ > 1) { cycle_depth_--; @@ -264,6 +314,9 @@ void DTLSSession::Cycle() { unsigned long ossl_err = ERR_get_error(); // NOLINT(runtime/int) char err_buf[256]; ERR_error_string_n(ossl_err, err_buf, sizeof(err_buf)); + // Flush any fatal alert OpenSSL queued for the peer before emitting the + // error, which tears the session down and detaches the endpoint. + EncOut(); Local argv[] = { String::NewFromUtf8(env()->isolate(), err_buf).ToLocalChecked(), }; @@ -303,8 +356,9 @@ void DTLSSession::Cycle() { void DTLSSession::ClearOut() { if (destroyed_) return; - // Try to read decrypted application data from OpenSSL. - uint8_t buf[65536]; + // Try to read decrypted application data from OpenSSL. A DTLS record's + // plaintext is at most 2^14 bytes, so one SSL_read yields at most that much. + uint8_t buf[16384]; int read; while ((read = SSL_read(ssl_.get(), buf, sizeof(buf))) > 0) { @@ -316,6 +370,9 @@ void DTLSSession::ClearOut() { .ToLocalChecked(), }; EmitCallback(DTLS_CB_SESSION_MESSAGE, 1, argv); + // The message handler may have destroyed the session synchronously; stop + // reading if so (Cycle()'s strong reference keeps `this` itself alive). + if (destroyed_) return; } int err = SSL_get_error(ssl_.get(), read); @@ -334,8 +391,13 @@ void DTLSSession::ClearOut() { // Send our close_notify back. SSL_shutdown(ssl_.get()); EncOut(); + // Detach from the endpoint's session table before notifying JS so an + // observer of the close sees a consistent session count. Cycle() holds + // a strong reference for the duration of the pump. + if (auto ep = endpoint_.get()) ep->RemoveSession(remote_address_); Local argv[] = {}; EmitCallback(DTLS_CB_SESSION_CLOSE, 0, argv); + Destroy(); } break; @@ -344,6 +406,9 @@ void DTLSSession::ClearOut() { unsigned long ossl_err = ERR_get_error(); // NOLINT(runtime/int) char err_buf[256]; ERR_error_string_n(ossl_err, err_buf, sizeof(err_buf)); + // Flush any fatal alert OpenSSL queued for the peer before emitting the + // error, which tears the session down and detaches the endpoint. + EncOut(); Local argv[] = { String::NewFromUtf8(env()->isolate(), err_buf).ToLocalChecked(), }; @@ -404,6 +469,12 @@ int DTLSSession::Send(const uint8_t* data, size_t len) { void DTLSSession::Close() { if (destroyed_ || closed_) return; + // Emitting the close below can synchronously free this session (a client + // session that owns its endpoint tears the endpoint -- and thus itself -- + // down from the close callback), and we call Destroy() afterwards. Pin a + // strong reference so `this` survives until we return. + BaseObjectPtr strong_ref{this}; + closed_ = true; state_->closing = 1; DTLS_STAT_RECORD_TIMESTAMP(DTLSSessionStats, closing_at); @@ -420,11 +491,21 @@ void DTLSSession::Close() { state_->open = 0; + // Detach from the endpoint's session table before notifying JS, so an + // observer of the close (e.g. one awaiting `closed`) sees a consistent + // session count. We stay alive via strong_ref, and endpoint_ remains valid + // for the callback below; the Destroy() that follows clears it. + if (auto ep = endpoint_.get()) ep->RemoveSession(remote_address_); + // Notify JS. HandleScope handle_scope(env()->isolate()); Context::Scope context_scope(env()->context()); Local argv[] = {}; EmitCallback(DTLS_CB_SESSION_CLOSE, 0, argv); + + // Release the remaining resources. RemoveSession above already detached us, + // so the one inside Destroy() is a no-op. + Destroy(); } void DTLSSession::Destroy() { @@ -659,15 +740,6 @@ void DTLSSession::GetSRTPProfile(const FunctionCallbackInfo& args) { } } -void DTLSSession::SetServername(const FunctionCallbackInfo& args) { - DTLSSession* session; - ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); - - CHECK(args[0]->IsString()); - Utf8Value servername(session->env()->isolate(), args[0]); - SSL_set_tlsext_host_name(session->ssl_.get(), *servername); -} - void DTLSSession::GetServername(const FunctionCallbackInfo& args) { DTLSSession* session; ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); diff --git a/src/dtls/dtls_session.h b/src/dtls/dtls_session.h index d64d0e4d4873..162752b6eb4c 100644 --- a/src/dtls/dtls_session.h +++ b/src/dtls/dtls_session.h @@ -54,11 +54,19 @@ class DTLSSession final : public AsyncWrap { // |ssl_ctx| - the SSL_CTX to create the SSL* from // |remote| - the peer address // |is_server| - true if this is a server-side session + // |servername| - SNI to advertise (client only); nullptr to omit. + // |verify_host| - expected peer identity to verify (client only); + // nullptr disables identity checking. + // |verify_is_ip| - true if |verify_host| is an IP literal (verified + // against iPAddress SANs) rather than a DNS name. static BaseObjectPtr Create(Environment* env, DTLSEndpoint* endpoint, SSL_CTX* ssl_ctx, const SocketAddress& remote, - bool is_server); + bool is_server, + const char* servername = nullptr, + const char* verify_host = nullptr, + bool verify_is_ip = false); // Create a session from an already-initialized SSL object. // Used by the server after DTLSv1_listen() returns 1 — the SSL @@ -119,7 +127,6 @@ class DTLSSession final : public AsyncWrap { static void ExportKeyingMaterial( const v8::FunctionCallbackInfo& args); static void GetSRTPProfile(const v8::FunctionCallbackInfo& args); - static void SetServername(const v8::FunctionCallbackInfo& args); static void GetServername(const v8::FunctionCallbackInfo& args); public: diff --git a/src/ffi/fast.cc b/src/ffi/fast.cc index 8c4420761fec..ea98bf8aa4f1 100644 --- a/src/ffi/fast.cc +++ b/src/ffi/fast.cc @@ -164,7 +164,8 @@ bool SignatureNeedsFastIntegerValidation(const FFIFunction& fn) { for (const std::string& name : fn.arg_type_names) { if (name == "bool" || name == "char" || name == "i8" || name == "int8" || name == "u8" || name == "uint8" || name == "i16" || name == "int16" || - name == "u16" || name == "uint16" || name == "i64" || name == "int64" || + name == "u16" || name == "uint16" || name == "i32" || name == "int32" || + name == "u32" || name == "uint32" || name == "i64" || name == "int64" || name == "u64" || name == "uint64") { return true; } @@ -178,12 +179,32 @@ bool IsPointerTypeName(const std::string& name) { return name == "pointer" || name == "ptr" || name == "function"; } +bool IsBufferTypeName(const std::string& name) { + return name == "buffer" || name == "arraybuffer"; +} + bool SignatureNeedsFastBufferInvoke(const FFIFunction& fn) { // The secondary buffer invoke is only generated for the hot monomorphic case // where a single pointer-like argument can be satisfied by a Buffer or // ArrayBuffer without allocating or caching a BigInt pointer in JS. return fn.arg_type_names.size() == 1 && - IsPointerTypeName(fn.arg_type_names[0]); + (IsPointerTypeName(fn.arg_type_names[0]) || + IsBufferTypeName(fn.arg_type_names[0])); +} + +std::shared_ptr CloneWithRawPointerArgNames( + const std::shared_ptr& fn) { + // The primary Fast API entrypoint receives pointer-compatible values as + // BigInts after the JS wrapper has converted strings, nullish values, and + // memory-backed objects. A secondary entrypoint handles the monomorphic + // memory-backed case without extracting the pointer in JS. + auto clone = std::make_shared(*fn); + for (std::string& name : clone->arg_type_names) { + if (IsBufferTypeName(name)) { + name = "pointer"; + } + } + return clone; } std::shared_ptr CloneWithFastBufferArgNames( diff --git a/src/ffi/fast.h b/src/ffi/fast.h index 539df26da79b..b85191aade13 100644 --- a/src/ffi/fast.h +++ b/src/ffi/fast.h @@ -61,6 +61,8 @@ bool SignatureNeedsRawPointerConversions(const FFIFunction& fn); bool SignatureNeedsFastIntegerValidation(const FFIFunction& fn); bool IsPointerTypeName(const std::string& name); bool SignatureNeedsFastBufferInvoke(const FFIFunction& fn); +std::shared_ptr CloneWithRawPointerArgNames( + const std::shared_ptr& fn); std::shared_ptr CloneWithFastBufferArgNames( const std::shared_ptr& fn); std::unique_ptr CreateFastFFIMetadata(const FFIFunction& fn); diff --git a/src/node_ffi.cc b/src/node_ffi.cc index 23c58e8ea128..b05d09270126 100644 --- a/src/node_ffi.cc +++ b/src/node_ffi.cc @@ -249,7 +249,8 @@ MaybeLocal DynamicLibrary::CreateFunction( // Try the generated Fast API path first. If metadata creation rejects the // signature, fall back to SharedBuffer for supported scalar shapes, then to // the generic libffi invoker. - info->fast_metadata = CreateFastFFIMetadata(*fn); + std::shared_ptr fast_fn = CloneWithRawPointerArgNames(fn); + info->fast_metadata = CreateFastFFIMetadata(*fast_fn); bool use_fast_api = info->fast_metadata != nullptr; bool use_sb = !use_fast_api && IsSBEligibleSignature(*fn); bool has_ptr_args = use_sb && SignatureHasPointerArgs(*fn); diff --git a/src/node_options.cc b/src/node_options.cc index b9d3dd56092e..383522863bb8 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -1029,6 +1029,13 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() { &EnvironmentOptions::coverage_include_pattern, kAllowedInEnvvar, OptionNamespaces::kTestRunnerNamespace); + AddOption("--test-coverage-include-all", + "include source files that were never loaded in the coverage " + "report", + &EnvironmentOptions::coverage_include_all, + kAllowedInEnvvar, + false, + OptionNamespaces::kTestRunnerNamespace); AddOption("--test-coverage-exclude", "exclude files from coverage report that match this glob pattern", &EnvironmentOptions::coverage_exclude_pattern, diff --git a/src/node_options.h b/src/node_options.h index 7d9ff9e5147d..fc758f2444aa 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -221,6 +221,7 @@ class EnvironmentOptions : public Options { std::vector test_skip_pattern; std::vector experimental_test_tag_filter; std::vector coverage_include_pattern; + bool coverage_include_all = false; std::vector coverage_exclude_pattern; bool throw_deprecation = false; bool trace_deprecation = false; diff --git a/src/node_sqlite.cc b/src/node_sqlite.cc index 282184ddcf02..8c3709178af0 100644 --- a/src/node_sqlite.cc +++ b/src/node_sqlite.cc @@ -2396,7 +2396,10 @@ void DatabaseSync::EnableLoadExtension( const FunctionCallbackInfo& args) { DatabaseSync* db; ASSIGN_OR_RETURN_UNWRAP(&db, args.This()); - auto isolate = args.GetIsolate(); + Environment* env = Environment::GetCurrent(args); + THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open"); + + Isolate* isolate = env->isolate(); if (!args[0]->IsBoolean()) { THROW_ERR_INVALID_ARG_TYPE(isolate, "The \"allow\" argument must be a boolean."); @@ -2424,7 +2427,7 @@ void DatabaseSync::EnableDefensive(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open"); - auto isolate = args.GetIsolate(); + Isolate* isolate = env->isolate(); if (!args[0]->IsBoolean()) { THROW_ERR_INVALID_ARG_TYPE(isolate, "The \"active\" argument must be a boolean."); @@ -2475,6 +2478,8 @@ void DatabaseSync::SetAuthorizer(const FunctionCallbackInfo& args) { DatabaseSync* db; ASSIGN_OR_RETURN_UNWRAP(&db, args.This()); Environment* env = Environment::GetCurrent(args); + THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open"); + Isolate* isolate = env->isolate(); if (args[0]->IsNull()) { diff --git a/src/node_wasm_web_api.cc b/src/node_wasm_web_api.cc index a1e52726f3a7..a49cab7fb620 100644 --- a/src/node_wasm_web_api.cc +++ b/src/node_wasm_web_api.cc @@ -44,6 +44,7 @@ Local WasmStreamingObject::Initialize(Environment* env) { void WasmStreamingObject::RegisterExternalReferences( ExternalReferenceRegistry* registry) { registry->Register(New); + registry->Register(SetURL); registry->Register(Push); registry->Register(Finish); registry->Register(Abort); diff --git a/src/quic/session.cc b/src/quic/session.cc index 0f5fb0d95258..8466bed35f78 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -2813,9 +2813,9 @@ bool Session::ReadPacket(const uint8_t* data, Debug(this, "Session successfully received %zu-byte packet", len); if (!is_destroyed()) [[likely]] { STAT_INCREMENT_N(Stats, bytes_received, len); - // Process deferred operations that couldn't run inside callback - // scopes (e.g., HTTP/3 GOAWAY handling that calls into JS). - application().PostReceive(); + // Process deferred application operations after ALPN selection - not + // necessarily resolved yet as ClientHello can span multiple packets. + if (has_application()) application().PostReceive(); // Surface a server session to JS once its ClientHello has been // processed (OnSelectAlpn fired: SNI + ALPN are known and reliable). // Held first-flight events - including 0-RTT request streams - replay diff --git a/test/ffi/ffi-test-common.js b/test/ffi/ffi-test-common.js index 86e56de8ec21..fa54ca2cfd34 100644 --- a/test/ffi/ffi-test-common.js +++ b/test/ffi/ffi-test-common.js @@ -6,18 +6,15 @@ const path = require('node:path'); common.skipIfFFIMissing(); +const { suffix } = require('node:ffi'); + const fixtureBuildDir = path.join( __dirname, 'fixture_library', 'build', common.buildType, ); -const libraryPath = path.join( - fixtureBuildDir, - process.platform === 'win32' ? 'ffi_test_library.dll' : - process.platform === 'darwin' ? 'ffi_test_library.dylib' : - 'ffi_test_library.so', -); +const libraryPath = path.join(fixtureBuildDir, `ffi_test_library.${suffix}`); function ensureFixtureLibrary() { if (!fs.existsSync(libraryPath)) { diff --git a/test/ffi/test-ffi-fast-buffer.js b/test/ffi/test-ffi-fast-buffer.js index 596ae83d1888..97d8a3c9b852 100644 --- a/test/ffi/test-ffi-fast-buffer.js +++ b/test/ffi/test-ffi-fast-buffer.js @@ -95,3 +95,41 @@ test('fast FFI string buffers survive reentrant callbacks', { lib.close(); } }); + +test('optimized buffer signatures preserve pointer-like conversions', () => { + const lib = new ffi.DynamicLibrary(libraryPath); + const asBuffer = lib.getFunction('pointer_to_usize', { + arguments: ['buffer'], + return: 'u64', + }); + const asArrayBuffer = lib.getFunction('pointer_to_usize', { + arguments: ['arraybuffer'], + return: 'u64', + }); + + function callBuffer(value) { + return asBuffer(value); + } + + function callArrayBuffer(value) { + return asArrayBuffer(value); + } + + try { + for (let i = 0; i < 100_000; i++) { + assert.strictEqual(callBuffer(0n), 0n); + assert.strictEqual(callArrayBuffer(0n), 0n); + } + + for (const call of [callBuffer, callArrayBuffer]) { + assert.strictEqual(call(null), 0n); + assert.strictEqual(call(undefined), 0n); + assert.notStrictEqual(call('ffi'), 0n); + + const bytes = Buffer.alloc(1); + assert.strictEqual(call(bytes), ffi.getRawPointer(bytes)); + } + } finally { + lib.close(); + } +}); diff --git a/test/ffi/test-ffi-fast-integer-validation.js b/test/ffi/test-ffi-fast-integer-validation.js index 49fd1364948c..26d51ae4248f 100644 --- a/test/ffi/test-ffi-fast-integer-validation.js +++ b/test/ffi/test-ffi-fast-integer-validation.js @@ -28,6 +28,10 @@ test('fast FFI validates integer argument ranges', () => { function callU16(value) { return functions.add_u16(value, 0); } + function callI32(value) { return functions.add_i32(value, 0); } + + function callU32(value) { return functions.add_u32(value, 0); } + function callI64(value) { return functions.add_i64(value, 0n); } function callU64(value) { return functions.add_u64(value, 0n); } @@ -37,6 +41,8 @@ test('fast FFI validates integer argument ranges', () => { [callU8, 0], [callI16, 0], [callU16, 0], + [callI32, 0], + [callU32, 0], [callI64, 0n], [callU64, 0n], ]) { @@ -48,6 +54,14 @@ test('fast FFI validates integer argument ranges', () => { assert.throws(() => callU8(256), expect); assert.throws(() => callI16(32768), expect); assert.throws(() => callU16(65536), expect); + assert.throws(() => callI32(2147483648), expect); + assert.throws(() => callI32(-2147483649), expect); + assert.throws(() => callI32(1.5), expect); + assert.throws(() => callI32('1'), expect); + assert.throws(() => callU32(4294967296), expect); + assert.throws(() => callU32(-1), expect); + assert.throws(() => callU32(1.5), expect); + assert.throws(() => callU32('1'), expect); assert.throws(() => callI64(2n ** 63n), expect); assert.throws(() => callU64(2n ** 64n), expect); } finally { diff --git a/test/ffi/test-ffi-module.js b/test/ffi/test-ffi-module.js index ecbd6f1c6a98..7fe265972a74 100644 --- a/test/ffi/test-ffi-module.js +++ b/test/ffi/test-ffi-module.js @@ -154,6 +154,14 @@ test('ffi exports expected API surface', () => { assert.strictEqual(typeof ffi.types, 'object'); }); +test('ffi.suffix matches the current platform', () => { + const ffi = require('node:ffi'); + const expected = process.platform === 'win32' ? 'dll' : + process.platform === 'darwin' ? 'dylib' : 'so'; + + assert.strictEqual(ffi.suffix, expected); +}); + test('ffi.types exports canonical type constants', () => { const ffi = require('node:ffi'); const expected = { diff --git a/test/fixtures/test-runner/coverage-include-all/covered.js b/test/fixtures/test-runner/coverage-include-all/covered.js new file mode 100644 index 000000000000..5779d9f9747f --- /dev/null +++ b/test/fixtures/test-runner/coverage-include-all/covered.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = function covered() { + return 'covered'; +}; diff --git a/test/fixtures/test-runner/coverage-include-all/data.json b/test/fixtures/test-runner/coverage-include-all/data.json new file mode 100644 index 000000000000..76af629f8ff5 --- /dev/null +++ b/test/fixtures/test-runner/coverage-include-all/data.json @@ -0,0 +1,3 @@ +{ + "not": "a source file" +} diff --git a/test/fixtures/test-runner/coverage-include-all/index.test.js b/test/fixtures/test-runner/coverage-include-all/index.test.js new file mode 100644 index 000000000000..48d7f040831c --- /dev/null +++ b/test/fixtures/test-runner/coverage-include-all/index.test.js @@ -0,0 +1,9 @@ +'use strict'; + +const assert = require('node:assert'); +const test = require('node:test'); +const covered = require('./covered'); + +test('covered source is executed', () => { + assert.strictEqual(covered(), 'covered'); +}); diff --git a/test/fixtures/test-runner/coverage-include-all/nested/deep.js b/test/fixtures/test-runner/coverage-include-all/nested/deep.js new file mode 100644 index 000000000000..26a37f0c97b2 --- /dev/null +++ b/test/fixtures/test-runner/coverage-include-all/nested/deep.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = function deep() { + return 'deep'; +}; diff --git a/test/fixtures/test-runner/coverage-include-all/node_modules/pkg/index.js b/test/fixtures/test-runner/coverage-include-all/node_modules/pkg/index.js new file mode 100644 index 000000000000..4791b062ab2c --- /dev/null +++ b/test/fixtures/test-runner/coverage-include-all/node_modules/pkg/index.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = function fromNodeModules() { + return 'from node_modules'; +}; diff --git a/test/fixtures/test-runner/coverage-include-all/untested.js b/test/fixtures/test-runner/coverage-include-all/untested.js new file mode 100644 index 000000000000..969d9d1a1dea --- /dev/null +++ b/test/fixtures/test-runner/coverage-include-all/untested.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = function untested() { + return 'untested'; +}; diff --git a/test/fixtures/wpt/url/WEB_FEATURES.yml b/test/fixtures/wpt/url/WEB_FEATURES.yml index b7a784695aa0..a39c0594f47d 100644 --- a/test/fixtures/wpt/url/WEB_FEATURES.yml +++ b/test/fixtures/wpt/url/WEB_FEATURES.yml @@ -1,11 +1,4 @@ -features: -- name: url - files: - - "*" - - "!url-statics-canparse.*" -- name: url-canparse - files: - - url-statics-canparse.* -- name: base - files: - - a-element* +rules: +- url-statics-canparse.*: [url-canparse] +- a-element*: [base, url] +- "*": [url] diff --git a/test/fixtures/wpt/url/resources/setters_tests.json b/test/fixtures/wpt/url/resources/setters_tests.json index 0a151f91862a..221e77a951bb 100644 --- a/test/fixtures/wpt/url/resources/setters_tests.json +++ b/test/fixtures/wpt/url/resources/setters_tests.json @@ -1900,6 +1900,7 @@ "href": "https://domain.com:443", "new_value": "\u00098080", "expected": { + "href": "https://domain.com:8080/", "port": "8080" } }, @@ -1908,6 +1909,7 @@ "href": "wpt++://domain.com:443", "new_value": "\u00098080", "expected": { + "href": "wpt++://domain.com:8080", "port": "8080" } }, @@ -1916,6 +1918,7 @@ "href": "https://www.google.com:4343", "new_value": "4wpt", "expected": { + "href": "https://www.google.com:4/", "port": "4" } }, @@ -1923,6 +1926,7 @@ "href": "https://domain.com:3000", "new_value": "\n\t80\n\t80\n\t", "expected": { + "href": "https://domain.com:8080/", "port": "8080" } }, @@ -1930,6 +1934,7 @@ "href": "https://domain.com:3000", "new_value": "\n\n\t\t", "expected": { + "href": "https://domain.com:3000/", "port": "3000" } } diff --git a/test/fixtures/wpt/url/resources/urltestdata.json b/test/fixtures/wpt/url/resources/urltestdata.json index c8d6ffe22bff..c4807f9e2cfc 100644 --- a/test/fixtures/wpt/url/resources/urltestdata.json +++ b/test/fixtures/wpt/url/resources/urltestdata.json @@ -4373,6 +4373,52 @@ "search": "", "hash": "" }, + "Astral code point followed by a trailing character in the userinfo", + { + "input": "http://😀x@host/", + "base": null, + "href": "http://%F0%9F%98%80x@host/", + "origin": "http://host", + "protocol": "http:", + "username": "%F0%9F%98%80x", + "password": "", + "host": "host", + "hostname": "host", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://a:😀x@host/", + "base": null, + "href": "http://a:%F0%9F%98%80x@host/", + "origin": "http://host", + "protocol": "http:", + "username": "a", + "password": "%F0%9F%98%80x", + "host": "host", + "hostname": "host", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://😀@host/", + "base": null, + "href": "http://%F0%9F%98%80@host/", + "origin": "http://host", + "protocol": "http:", + "username": "%F0%9F%98%80", + "password": "", + "host": "host", + "hostname": "host", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, { "input": "https://localhost?q=🔥", "base": null, diff --git a/test/fixtures/wpt/versions.json b/test/fixtures/wpt/versions.json index ef4ac92ba8b3..32b9537ffca9 100644 --- a/test/fixtures/wpt/versions.json +++ b/test/fixtures/wpt/versions.json @@ -72,7 +72,7 @@ "path": "streams" }, "url": { - "commit": "b63305b743ed9ce2725d4ae09c5c8f0c40d8e6e1", + "commit": "4832db47614f5f48cc57374cbf5c1f70937fad48", "path": "url" }, "urlpattern": { diff --git a/test/parallel/parallel.status b/test/parallel/parallel.status index 695736b237b6..7b15183e2fdb 100644 --- a/test/parallel/parallel.status +++ b/test/parallel/parallel.status @@ -19,18 +19,6 @@ test-fs-read-stream-concurrent-reads: PASS, FLAKY # https://github.com/nodejs/build/issues/3043 test-snapshot-incompatible: SKIP -# There's a bug in CDP where `replMode: true` causes the Inspector -# to collect its `Runtime.evaluate` promise before the evaluation -# is complete. This test intentionally runs multiple REPL instances -# in parallel, which significantly increases the likelihood of a -# garbage collection occurring while an evaluation is still pending. -# In normal usage, users typically don't create multiple REPLs -# simultaneously, so this race is much much less likely to occur. -# https://github.com/nodejs/node/issues/64595 -# https://issues.chromium.org/issues/536271637 -# https://ci.nodejs.org/job/node-stress-single-test/800 (38/1000) -test-repl-user-error-handler: PASS, FLAKY - [$system==win32] # https://github.com/nodejs/node/issues/59090 test-inspector-network-fetch: PASS, FLAKY diff --git a/test/parallel/test-dtls-accessors.mjs b/test/parallel/test-dtls-accessors.mjs new file mode 100644 index 000000000000..bb93eeaebe6e --- /dev/null +++ b/test/parallel/test-dtls-accessors.mjs @@ -0,0 +1,101 @@ +// Flags: --experimental-dtls --no-warnings + +// Test: DTLSEndpoint/DTLSSession state fields and callback accessors reflect +// what is set and the connection lifecycle. + +import { + hasCrypto, skip, mustCall, mustNotCall, mustCallAtLeast, +} from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +if (!hasCrypto) { + skip('missing crypto'); +} + +if (!process.features.dtls) { + skip('DTLS is not enabled'); +} + +const { listen, connect } = await import('node:dtls'); + +const cert = fixtures.readKey('agent1-cert.pem').toString(); +const key = fixtures.readKey('agent1-key.pem').toString(); +const ca = fixtures.readKey('ca1-cert.pem').toString(); + +const gotServerSession = Promise.withResolvers(); + +const server = listen(mustCall((session) => { + gotServerSession.resolve(session); +}), { cert, key, port: 0, host: '127.0.0.1' }); + +// --- Endpoint state after listen(): bound and listening. --- +const es = server.state; +assert.strictEqual(es.bound, true); +assert.strictEqual(es.listening, true); +assert.strictEqual(es.closing, false); +assert.strictEqual(es.destroyed, false); +assert.strictEqual(es.sessionCount, 0); + +// The busy property is settable via the endpoint and reflected in the state view. +assert.strictEqual(server.busy, false); +assert.strictEqual(es.busy, false); +server.busy = true; +assert.strictEqual(server.busy, true); +assert.strictEqual(es.busy, true); +server.busy = false; +assert.strictEqual(es.busy, false); + +// --- Endpoint onerror accessor. --- +assert.strictEqual(server.onerror, undefined); +const onEndpointError = mustNotCall(); +server.onerror = onEndpointError; +assert.strictEqual(server.onerror, onEndpointError); + +const client = connect('127.0.0.1', server.address.port, { + ca: [ca], + rejectUnauthorized: false, +}); + +// --- Session state during the handshake. --- +const cs = client.state; +assert.strictEqual(cs.handshaking, true); +assert.strictEqual(cs.open, false); +assert.strictEqual(cs.closing, false); +assert.strictEqual(cs.destroyed, false); +assert.strictEqual(cs.hasMessageListener, false); + +// --- Session callback accessors: unset, then set. --- +assert.strictEqual(client.onmessage, undefined); +assert.strictEqual(client.onerror, undefined); +assert.strictEqual(client.onhandshake, undefined); +assert.strictEqual(client.onkeylog, undefined); +// A connect() session owns its internal endpoint. +assert.strictEqual(client.ownsEndpoint, true); + +client.onmessage = mustNotCall(); +assert.strictEqual(typeof client.onmessage, 'function'); +// Attaching a message listener flips the shared flag. +assert.strictEqual(cs.hasMessageListener, true); + +client.onerror = mustNotCall(); +assert.strictEqual(typeof client.onerror, 'function'); + +client.onhandshake = mustCall(); +assert.strictEqual(typeof client.onhandshake, 'function'); + +client.onkeylog = mustCallAtLeast(); +assert.strictEqual(typeof client.onkeylog, 'function'); + +await client.opened; + +// --- Session state after the handshake completes. --- +assert.strictEqual(cs.handshaking, false); +assert.strictEqual(cs.open, true); + +const serverSession = await gotServerSession.promise; +await serverSession.opened; +assert.strictEqual(es.sessionCount, 1); + +await client.close(); +await server.close(); diff --git a/test/parallel/test-dtls-alpn.mjs b/test/parallel/test-dtls-alpn.mjs index 5ab6b3b077cb..01054dd6432b 100644 --- a/test/parallel/test-dtls-alpn.mjs +++ b/test/parallel/test-dtls-alpn.mjs @@ -23,7 +23,6 @@ const ca = fixtures.readKey('ca1-cert.pem'); const serverAlpnChecked = Promise.withResolvers(); const endpoint = listen(mustCall(async (session) => { - session.onmessage = () => {}; await session.opened; // Server should see the negotiated ALPN protocol. assert.strictEqual(session.alpnProtocol, 'coap'); @@ -51,3 +50,33 @@ await serverAlpnChecked.promise; await session.close(); await endpoint.close(); + +// ALPN with no protocol in common: the handshake still completes and neither +// peer reports a negotiated protocol. +{ + const gotServerSession = Promise.withResolvers(); + + const server = listen(mustCall((s) => gotServerSession.resolve(s)), { + cert: serverCert.toString(), + key: serverKey.toString(), + port: 0, + host: '127.0.0.1', + alpn: ['bar'], + }); + + const client = connect('127.0.0.1', server.address.port, { + ca: [ca.toString()], + rejectUnauthorized: false, + alpn: ['foo'], + }); + + await client.opened; + const serverSession = await gotServerSession.promise; + await serverSession.opened; + + assert.strictEqual(client.alpnProtocol, undefined); + assert.strictEqual(serverSession.alpnProtocol, undefined); + + await client.close(); + await server.close(); +} diff --git a/test/parallel/test-dtls-ciphers.mjs b/test/parallel/test-dtls-ciphers.mjs new file mode 100644 index 000000000000..074c33f64b9b --- /dev/null +++ b/test/parallel/test-dtls-ciphers.mjs @@ -0,0 +1,76 @@ +// Flags: --experimental-dtls --no-warnings + +// Test: cipher and ECDH-curve selection and validation. + +import { hasCrypto, skip, mustCall, mustNotCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +if (!hasCrypto) { + skip('missing crypto'); +} + +if (!process.features.dtls) { + skip('DTLS is not enabled'); +} + +const { listen, connect } = await import('node:dtls'); + +const cert = fixtures.readKey('agent1-cert.pem').toString(); +const key = fixtures.readKey('agent1-key.pem').toString(); +const ca = fixtures.readKey('ca1-cert.pem').toString(); + +const CIPHER = 'ECDHE-RSA-AES128-GCM-SHA256'; + +// Case 1: a specific cipher is negotiated and reported on both peers. +{ + const gotServerSession = Promise.withResolvers(); + + const server = listen(mustCall((session) => { + gotServerSession.resolve(session); + }), { cert, key, port: 0, host: '127.0.0.1', ciphers: CIPHER }); + + const client = connect('127.0.0.1', server.address.port, { + ca: [ca], + rejectUnauthorized: false, + ciphers: CIPHER, + }); + + await client.opened; + const serverSession = await gotServerSession.promise; + await serverSession.opened; + + assert.strictEqual(client.cipher.name, CIPHER); + assert.strictEqual(serverSession.cipher.name, CIPHER); + + await client.close(); + await server.close(); +} + +// Case 2: an invalid cipher list is rejected. +assert.throws(() => listen(mustNotCall(), { + cert, key, port: 0, host: '127.0.0.1', ciphers: 'THIS-IS-NOT-A-CIPHER', +}), { code: 'ERR_CRYPTO_OPERATION_FAILED' }); + +// Case 3: a valid ECDH curve completes a handshake. +{ + const server = listen(mustCall(), { + cert, key, port: 0, host: '127.0.0.1', ecdhCurve: 'P-256', + }); + + const client = connect('127.0.0.1', server.address.port, { + ca: [ca], + rejectUnauthorized: false, + ecdhCurve: 'P-256', + }); + + await client.opened; + + await client.close(); + await server.close(); +} + +// Case 4: an invalid ECDH curve is rejected. +assert.throws(() => listen(mustNotCall(), { + cert, key, port: 0, host: '127.0.0.1', ecdhCurve: 'not-a-curve', +}), { code: 'ERR_CRYPTO_OPERATION_FAILED' }); diff --git a/test/parallel/test-dtls-client-cert.mjs b/test/parallel/test-dtls-client-cert.mjs new file mode 100644 index 000000000000..271bd7e322ab --- /dev/null +++ b/test/parallel/test-dtls-client-cert.mjs @@ -0,0 +1,70 @@ +// Flags: --experimental-dtls --no-warnings + +// Test: DTLS mutual authentication. A server with requestCert verifies the +// client's certificate; a client that presents no certificate is rejected. + +import { hasCrypto, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +if (!hasCrypto) { + skip('missing crypto'); +} + +if (!process.features.dtls) { + skip('DTLS is not enabled'); +} + +const { listen, connect } = await import('node:dtls'); + +const cert = fixtures.readKey('agent1-cert.pem').toString(); +const key = fixtures.readKey('agent1-key.pem').toString(); +const ca = fixtures.readKey('ca1-cert.pem').toString(); + +// Case 1: the client presents a certificate the server can verify. +{ + const gotServerSession = Promise.withResolvers(); + + const server = listen(mustCall((session) => { + gotServerSession.resolve(session); + }), { + cert, key, ca: [ca], requestCert: true, port: 0, host: '127.0.0.1', + }); + + const client = connect('127.0.0.1', server.address.port, { + cert, key, ca: [ca], rejectUnauthorized: false, + }); + + await client.opened; + const serverSession = await gotServerSession.promise; + await serverSession.opened; + + // The server received and verified the client's certificate. + const clientCert = serverSession.peerCertificate; + assert.ok(clientCert); + assert.ok(clientCert.includes('BEGIN CERTIFICATE')); + + await client.close(); + await server.close(); +} + +// Case 2: the client presents no certificate; the server requires one and +// rejects the handshake. +{ + const server = listen(mustCall(), { + cert, key, ca: [ca], requestCert: true, port: 0, host: '127.0.0.1', + }); + + const client = connect('127.0.0.1', server.address.port, { + ca: [ca], rejectUnauthorized: false, + }); + + // The exact alert text varies, so assert only that the handshake is rejected. + await assert.rejects(client.opened, { + message: /handshake failure/ + }); + + // The failed client tears down its internally-owned endpoint. + await client.endpoint.closed; + await server.close(); +} diff --git a/test/parallel/test-dtls-connect-error-cleanup.mjs b/test/parallel/test-dtls-connect-error-cleanup.mjs new file mode 100644 index 000000000000..13dcf8da26bc --- /dev/null +++ b/test/parallel/test-dtls-connect-error-cleanup.mjs @@ -0,0 +1,43 @@ +// Flags: --experimental-dtls --no-warnings + +// Test: a client connect() whose handshake fails must tear down its internally +// owned endpoint, so the event loop can drain. Regression test for a failed +// connect leaking the endpoint (and hanging the process). + +import { hasCrypto, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +if (!hasCrypto) { + skip('missing crypto'); +} + +if (!process.features.dtls) { + skip('DTLS is not enabled'); +} + +const { listen, connect } = await import('node:dtls'); + +const cert = fixtures.readKey('agent1-cert.pem').toString(); +const key = fixtures.readKey('agent1-key.pem').toString(); +const ca = fixtures.readKey('ca1-cert.pem').toString(); + +// The client rejects the certificate mid-handshake, so this server session +// never opens; its opened rejection is handled internally by the library. +const server = listen(mustCall(), { cert, key, port: 0, host: '127.0.0.1' }); + +// A servername that does not match the certificate, under rejectUnauthorized, +// makes the client's handshake fail during verification. +const session = connect('127.0.0.1', server.address.port, { + ca: [ca], + rejectUnauthorized: true, + servername: 'wrong.example.com', +}); + +await assert.rejects(session.opened, /certificate verify failed/i); + +// The failed connect must have closed its internally-owned endpoint. Without +// that, this await never settles and the test times out. +await session.endpoint.closed; + +await server.close(); diff --git a/test/parallel/test-dtls-destroy-in-callback.mjs b/test/parallel/test-dtls-destroy-in-callback.mjs new file mode 100644 index 000000000000..7915742ca01d --- /dev/null +++ b/test/parallel/test-dtls-destroy-in-callback.mjs @@ -0,0 +1,77 @@ +// Flags: --experimental-dtls --no-warnings + +// Test: destroying a DTLS session synchronously from within a callback that is +// dispatched from the session's own I/O pump must not crash. The endpoint's +// session table holds the only strong reference to the session, so a reentrant +// destroy() removes it mid-pump; the implementation must keep the object alive +// until the pump unwinds (regression test for a use-after-free). + +import { hasCrypto, skip, mustCall } from '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; + +if (!hasCrypto) { + skip('missing crypto'); +} + +if (!process.features.dtls) { + skip('DTLS is not enabled'); +} + +const { listen, connect } = await import('node:dtls'); + +const cert = fixtures.readKey('agent1-cert.pem').toString(); +const key = fixtures.readKey('agent1-key.pem').toString(); +const ca = fixtures.readKey('ca1-cert.pem').toString(); + +// --------------------------------------------------------------------------- +// Case 1: destroy the (server) session from inside onmessage. The datagram +// carrying the message drives receive -> pump -> onmessage -> destroy(), which +// frees the session's map entry while ClearOut() is still looping over ssl_. +{ + const destroyed = Promise.withResolvers(); + + const server = listen(mustCall((session) => { + session.onmessage = mustCall(() => { + session.destroy(); + destroyed.resolve(); + }); + }), { cert, key, port: 0, host: '127.0.0.1' }); + + const client = connect('127.0.0.1', server.address.port, { + ca: [ca], + rejectUnauthorized: false, + }); + + await client.opened; + client.send('destroy me from onmessage'); + + await destroyed.promise; + + await client.close(); + await server.close(); +} + +// --------------------------------------------------------------------------- +// Case 2: destroy the (server) session from inside onhandshake. Handshake +// completion is emitted from the middle of the pump (Cycle), so destroying +// there must not free the session before the pump finishes unwinding. +{ + const destroyed = Promise.withResolvers(); + + const server = listen(mustCall((session) => { + session.onhandshake = mustCall(() => { + session.destroy(); + destroyed.resolve(); + }); + }), { cert, key, port: 0, host: '127.0.0.1' }); + + const client = connect('127.0.0.1', server.address.port, { + ca: [ca], + rejectUnauthorized: false, + }); + + await destroyed.promise; + + await client.close(); + await server.close(); +} diff --git a/test/parallel/test-dtls-errors.mjs b/test/parallel/test-dtls-errors.mjs new file mode 100644 index 000000000000..e65c90abd4e0 --- /dev/null +++ b/test/parallel/test-dtls-errors.mjs @@ -0,0 +1,45 @@ +// Flags: --experimental-dtls --no-warnings + +// Test: DTLS error handling for invalid certificate/key material and endpoint +// state. + +import { hasCrypto, skip, mustNotCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +if (!hasCrypto) { + skip('missing crypto'); +} + +if (!process.features.dtls) { + skip('DTLS is not enabled'); +} + +const { listen, DTLSEndpoint } = await import('node:dtls'); + +const cert = fixtures.readKey('agent1-cert.pem').toString(); +const key = fixtures.readKey('agent1-key.pem').toString(); +const mismatchedKey = fixtures.readKey('agent2-key.pem').toString(); + +// A malformed certificate PEM is rejected. +assert.throws(() => listen(mustNotCall(), { + cert: 'not a certificate', key, port: 0, +}), { code: 'ERR_CRYPTO_OPERATION_FAILED' }); + +// A malformed private key PEM is rejected. +assert.throws(() => listen(mustNotCall(), { + cert, key: 'not a key', port: 0, +}), { code: 'ERR_CRYPTO_OPERATION_FAILED' }); + +// A private key that does not match the certificate is rejected. +assert.throws(() => listen(mustNotCall(), { + cert, key: mismatchedKey, port: 0, +}), { code: 'ERR_CRYPTO_OPERATION_FAILED' }); + +// Binding the same endpoint twice fails. +{ + const endpoint = new DTLSEndpoint(); + endpoint.bind('127.0.0.1', 0); + assert.throws(() => endpoint.bind('127.0.0.1', 0), { code: 'ERR_INVALID_STATE' }); + await endpoint.close(); +} diff --git a/test/parallel/test-dtls-keylog.mjs b/test/parallel/test-dtls-keylog.mjs new file mode 100644 index 000000000000..f32c1142b4c6 --- /dev/null +++ b/test/parallel/test-dtls-keylog.mjs @@ -0,0 +1,46 @@ +// Flags: --experimental-dtls --no-warnings + +// Test: the onkeylog callback delivers NSS-format key material during the +// handshake (useful for decrypting captures in Wireshark). + +import { hasCrypto, skip, mustCall, mustCallAtLeast } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +if (!hasCrypto) { + skip('missing crypto'); +} + +if (!process.features.dtls) { + skip('DTLS is not enabled'); +} + +const { listen, connect } = await import('node:dtls'); + +const cert = fixtures.readKey('agent1-cert.pem').toString(); +const key = fixtures.readKey('agent1-key.pem').toString(); +const ca = fixtures.readKey('ca1-cert.pem').toString(); + +const gotKeylog = Promise.withResolvers(); + +const server = listen(mustCall(), { + cert, key, port: 0, host: '127.0.0.1', +}); + +const client = connect('127.0.0.1', server.address.port, { + ca: [ca], + rejectUnauthorized: false, +}); + +// A keylog line is "