From 9012fc60fd69f961a478ff6b881d3be9e4877f2f Mon Sep 17 00:00:00 2001 From: Junaid Khan Date: Thu, 22 Oct 2026 17:04:32 +0530 Subject: [PATCH 001/228] fs: stop recursive mkdir from retrying ENOENT forever Backport of #66340 to v24.x-staging. Also covers nested procfs paths from #66890. Fixes: https://github.com/nodejs/node/issues/66268 Fixes: https://github.com/nodejs/node/issues/66890 --- src/node_file-inl.h | 11 ++++++++ src/node_file.cc | 8 ++++++ src/node_file.h | 4 +++ .../test-fs-mkdir-recursive-enoent.js | 28 +++++++++++++++++++ .../test-heapdump-fs-mkdir-recursive.js | 23 +++++++++++++++ 5 files changed, 74 insertions(+) create mode 100644 test/parallel/test-fs-mkdir-recursive-enoent.js create mode 100644 test/parallel/test-heapdump-fs-mkdir-recursive.js diff --git a/src/node_file-inl.h b/src/node_file-inl.h index ebd97c1c8c52..803446b1efba 100644 --- a/src/node_file-inl.h +++ b/src/node_file-inl.h @@ -6,6 +6,8 @@ #include "node_file.h" #include "req_wrap-inl.h" +#include + namespace node { namespace fs { @@ -27,6 +29,15 @@ void FSContinuationData::MaybeSetFirstPath(const std::string& path) { } } +bool FSContinuationData::ShouldRetryENOENT(const std::string& path) { + if (std::find(enoent_paths_.begin(), enoent_paths_.end(), path) != + enoent_paths_.end()) { + return false; + } + enoent_paths_.push_back(path); + return true; +} + std::string FSContinuationData::PopPath() { CHECK(!paths_.empty()); std::string path = std::move(paths_.back()); diff --git a/src/node_file.cc b/src/node_file.cc index acb9e9d66983..0b6924e1926c 100644 --- a/src/node_file.cc +++ b/src/node_file.cc @@ -207,6 +207,7 @@ typedef void (*uv_fs_callback_t)(uv_fs_t*); void FSContinuationData::MemoryInfo(MemoryTracker* tracker) const { tracker->TrackField("paths", paths_); + tracker->TrackField("enoent_paths", enoent_paths_); } FileHandleReadWrap::~FileHandleReadWrap() = default; @@ -1948,6 +1949,9 @@ int MKDirpSync(uv_loop_t* loop, std::string dirname = next_path.substr(0, next_path.find_last_of(kPathSeparator)); if (dirname != next_path) { + if (!req_wrap->continuation_data()->ShouldRetryENOENT(next_path)) { + return err; + } req_wrap->continuation_data()->PushPath(std::move(next_path)); req_wrap->continuation_data()->PushPath(std::move(dirname)); } else if (req_wrap->continuation_data()->paths().empty()) { @@ -2029,6 +2033,10 @@ int MKDirpAsync( std::string dirname = path.substr(0, path.find_last_of(kPathSeparator)); if (dirname != path) { + if (!req_wrap->continuation_data()->ShouldRetryENOENT(path)) { + req_wrap->continuation_data()->Done(err); + break; + } req_wrap->continuation_data()->PushPath(path); req_wrap->continuation_data()->PushPath(std::move(dirname)); } else if (req_wrap->continuation_data()->paths().empty()) { diff --git a/src/node_file.h b/src/node_file.h index fab01a4c17b8..1e88f79d6fa8 100644 --- a/src/node_file.h +++ b/src/node_file.h @@ -114,6 +114,9 @@ class FSContinuationData : public MemoryRetainer { inline std::string PopPath(); // Used by mkdirp to track the first path created: inline void MaybeSetFirstPath(const std::string& path); + // Used by mkdirp to retry a path that failed with ENOENT only once: its + // parent has been created or checked by then, so retrying again cannot help. + inline bool ShouldRetryENOENT(const std::string& path); inline void Done(int result); int mode() const { return mode_; } @@ -129,6 +132,7 @@ class FSContinuationData : public MemoryRetainer { uv_fs_t* req_; int mode_; std::vector paths_; + std::vector enoent_paths_; std::string first_path_; }; diff --git a/test/parallel/test-fs-mkdir-recursive-enoent.js b/test/parallel/test-fs-mkdir-recursive-enoent.js new file mode 100644 index 000000000000..2626dca2d124 --- /dev/null +++ b/test/parallel/test-fs-mkdir-recursive-enoent.js @@ -0,0 +1,28 @@ +'use strict'; +// Refs: https://github.com/nodejs/node/issues/66268 +// Refs: https://github.com/nodejs/node/issues/66890 +// On procfs, mkdir fails with ENOENT although the parent exists. A recursive +// mkdir must report the error instead of retrying forever — including when +// the failing path is nested under another missing intermediate component. +const common = require('../common'); +const fs = require('fs'); + +if (!common.isLinux) common.skip('procfs is Linux only'); + +const assert = require('assert'); +const expected = { code: 'ENOENT', syscall: 'mkdir' }; + +for (const dir of [ + `/proc/node-test-${process.pid}`, + `/proc/node-test-${process.pid}/deeper`, +]) { + assert.throws(() => fs.mkdirSync(dir, { recursive: true }), expected); + + fs.mkdir(dir, { recursive: true }, common.mustCall((err) => { + assert.strictEqual(err.code, expected.code); + assert.strictEqual(err.syscall, expected.syscall); + })); + + assert.rejects(fs.promises.mkdir(dir, { recursive: true }), expected) + .then(common.mustCall()); +} diff --git a/test/parallel/test-heapdump-fs-mkdir-recursive.js b/test/parallel/test-heapdump-fs-mkdir-recursive.js new file mode 100644 index 000000000000..4da402079437 --- /dev/null +++ b/test/parallel/test-heapdump-fs-mkdir-recursive.js @@ -0,0 +1,23 @@ +'use strict'; + +// This tests heap snapshot integration of a pending recursive mkdir. + +const common = require('../common'); +const tmpdir = require('../common/tmpdir'); +const { validateByRetainingPath } = require('../common/heap'); +const assert = require('assert'); +const fs = require('fs'); + +tmpdir.refresh(); + +{ + const nodes = validateByRetainingPath('Node / FSContinuationData', []); + assert.strictEqual(nodes.length, 0); +} + +fs.mkdir(tmpdir.resolve('a', 'b'), { recursive: true }, common.mustSucceed()); + +{ + const nodes = validateByRetainingPath('Node / FSContinuationData', []); + assert.strictEqual(nodes.length, 1); +} From bd5809c4eca1b7f5b0a2cac43cad97acf7b563ea Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Wed, 10 Jun 2026 12:56:56 +0200 Subject: [PATCH 002/228] lib: add lint rule to enforce use of `kEmptyObject` Signed-off-by: Antoine du Hamel PR-URL: https://github.com/nodejs/node/pull/63790 Reviewed-By: Filip Skokan Reviewed-By: Chemi Atlow Reviewed-By: Jake Yuesong Li Reviewed-By: Marco Ippolito Reviewed-By: Luigi Pinca Reviewed-By: Trivikram Kamat Reviewed-By: Matteo Collina --- lib/diagnostics_channel.js | 8 +-- lib/fs.js | 16 ++--- .../async_context_frame.js | 3 +- .../async_local_storage/async_hooks.js | 3 +- lib/internal/blob.js | 2 +- lib/internal/fs/promises.js | 8 +-- lib/internal/http2/core.js | 2 +- lib/internal/inspector/network_http.js | 3 +- lib/internal/inspector/network_http2.js | 3 +- lib/internal/modules/esm/hooks.js | 2 +- lib/internal/modules/esm/resolve.js | 4 +- lib/internal/perf/observe.js | 4 +- lib/internal/readline/emitKeypressEvents.js | 2 +- lib/internal/streams/fast-utf8-stream.js | 3 +- lib/repl.js | 2 +- lib/util.js | 3 +- lib/vm.js | 3 +- lib/wasi.js | 2 +- .../test-eslint-avoid-prototype-pollution.js | 62 ++++++++++++++++++- .../eslint-rules/avoid-prototype-pollution.js | 22 +++++++ 20 files changed, 123 insertions(+), 34 deletions(-) diff --git a/lib/diagnostics_channel.js b/lib/diagnostics_channel.js index 5425fb09b8bc..70f101e37c99 100644 --- a/lib/diagnostics_channel.js +++ b/lib/diagnostics_channel.js @@ -35,7 +35,7 @@ const { triggerUncaughtException } = internalBinding('errors'); // must always be accessed through the binding instead of cached. const dc_binding = internalBinding('diagnostics_channel'); -const { WeakReference } = require('internal/util'); +const { WeakReference, kEmptyObject } = require('internal/util'); // Can't delete when weakref count reaches 0 as it could increment again. // Only GC can be used as a valid time to clean up the channels map. @@ -343,7 +343,7 @@ class TracingChannel { return done; } - traceSync(fn, context = {}, thisArg, ...args) { + traceSync(fn, context = { __proto__: null }, thisArg, ...args) { if (!this.hasSubscribers) { return ReflectApply(fn, thisArg, args); } @@ -365,7 +365,7 @@ class TracingChannel { }); } - tracePromise(fn, context = {}, thisArg, ...args) { + tracePromise(fn, context = { __proto__: null }, thisArg, ...args) { if (!this.hasSubscribers) { return ReflectApply(fn, thisArg, args); } @@ -407,7 +407,7 @@ class TracingChannel { }); } - traceCallback(fn, position = -1, context = {}, thisArg, ...args) { + traceCallback(fn, position = -1, context = kEmptyObject, thisArg, ...args) { if (!this.hasSubscribers) { return ReflectApply(fn, thisArg, args); } diff --git a/lib/fs.js b/lib/fs.js index 3801843e59a2..14e0be975e79 100644 --- a/lib/fs.js +++ b/lib/fs.js @@ -1647,7 +1647,7 @@ function readdirSync(path, options) { * ) => any} [callback] * @returns {void} */ -function fstat(fd, options = { bigint: false }, callback) { +function fstat(fd, options = { __proto__: null, bigint: false }, callback) { if (typeof options === 'function') { callback = options; options = kEmptyObject; @@ -1670,7 +1670,7 @@ function fstat(fd, options = { bigint: false }, callback) { * ) => any} callback * @returns {void} */ -function lstat(path, options = { bigint: false }, callback) { +function lstat(path, options = { __proto__: null, bigint: false }, callback) { if (typeof options === 'function') { callback = options; options = kEmptyObject; @@ -1698,7 +1698,7 @@ function lstat(path, options = { bigint: false }, callback) { * ) => any} callback * @returns {void} */ -function stat(path, options = { bigint: false, throwIfNoEntry: true }, callback) { +function stat(path, options = { __proto__: null, bigint: false, throwIfNoEntry: true }, callback) { if (typeof options === 'function') { callback = options; options = kEmptyObject; @@ -1718,7 +1718,7 @@ function stat(path, options = { bigint: false, throwIfNoEntry: true }, callback) binding.stat(getValidatedPath(path), options.bigint, req, options.throwIfNoEntry); } -function statfs(path, options = { bigint: false }, callback) { +function statfs(path, options = { __proto__: null, bigint: false }, callback) { if (typeof options === 'function') { callback = options; options = kEmptyObject; @@ -1743,7 +1743,7 @@ function statfs(path, options = { bigint: false }, callback) { * @param {{ bigint?: boolean; }} [options] * @returns {Stats | undefined} */ -function fstatSync(fd, options = { bigint: false }) { +function fstatSync(fd, options = { __proto__: null, bigint: false }) { const stats = binding.fstat(fd, options.bigint, undefined, false); if (stats === undefined) { return; @@ -1761,7 +1761,7 @@ function fstatSync(fd, options = { bigint: false }) { * }} [options] * @returns {Stats | undefined} */ -function lstatSync(path, options = { bigint: false, throwIfNoEntry: true }) { +function lstatSync(path, options = { __proto__: null, bigint: false, throwIfNoEntry: true }) { path = getValidatedPath(path); if (permission.isEnabled() && !permission.has('fs.read', path)) { const resource = BufferIsBuffer(path) ? BufferToString(path) : path; @@ -1790,7 +1790,7 @@ function lstatSync(path, options = { bigint: false, throwIfNoEntry: true }) { * }} [options] * @returns {Stats} */ -function statSync(path, options = { bigint: false, throwIfNoEntry: true }) { +function statSync(path, options = { __proto__: null, bigint: false, throwIfNoEntry: true }) { const stats = binding.stat( getValidatedPath(path), options.bigint, @@ -1803,7 +1803,7 @@ function statSync(path, options = { bigint: false, throwIfNoEntry: true }) { return getStatsFromBinding(stats); } -function statfsSync(path, options = { bigint: false }) { +function statfsSync(path, options = { __proto__: null, bigint: false }) { const stats = binding.statfs(getValidatedPath(path), options.bigint); return getStatFsFromBinding(stats); } diff --git a/lib/internal/async_local_storage/async_context_frame.js b/lib/internal/async_local_storage/async_context_frame.js index 8390ba92cbe8..0fd20b79535d 100644 --- a/lib/internal/async_local_storage/async_context_frame.js +++ b/lib/internal/async_local_storage/async_context_frame.js @@ -13,6 +13,7 @@ const AsyncContextFrame = require('internal/async_context_frame'); const { AsyncResource } = require('async_hooks'); const RunScope = require('internal/async_local_storage/run_scope'); +const { kEmptyObject } = require('internal/util'); class AsyncLocalStorage { #defaultValue = undefined; @@ -26,7 +27,7 @@ class AsyncLocalStorage { /** * @param {AsyncLocalStorageOptions} [options] */ - constructor(options = {}) { + constructor(options = kEmptyObject) { validateObject(options, 'options'); this.#defaultValue = options.defaultValue; diff --git a/lib/internal/async_local_storage/async_hooks.js b/lib/internal/async_local_storage/async_hooks.js index 4bd4d0392b5d..2d39e1f18322 100644 --- a/lib/internal/async_local_storage/async_hooks.js +++ b/lib/internal/async_local_storage/async_hooks.js @@ -25,6 +25,7 @@ const { } = require('async_hooks'); const RunScope = require('internal/async_local_storage/run_scope'); +const { kEmptyObject } = require('internal/util'); const storageList = []; @@ -54,7 +55,7 @@ class AsyncLocalStorage { /** * @param {AsyncLocalStorageOptions} [options] */ - constructor(options = {}) { + constructor(options = kEmptyObject) { this.kResourceStore = Symbol('kResourceStore'); this.enabled = false; validateObject(options, 'options'); diff --git a/lib/internal/blob.js b/lib/internal/blob.js index 6d0ec01f2ae5..a6b6ac395820 100644 --- a/lib/internal/blob.js +++ b/lib/internal/blob.js @@ -572,7 +572,7 @@ function createBlobReaderStream(reader) { // unbounded memory growth when the DataQueue has a large burst of data. const kMaxBatchChunks = 16; -async function* createBlobReaderIterable(reader, options = {}) { +async function* createBlobReaderIterable(reader, options = kEmptyObject) { const { getReadError } = options; let wakeup = PromiseWithResolvers(); let immediate; diff --git a/lib/internal/fs/promises.js b/lib/internal/fs/promises.js index 561b7ae5e8b4..bc7985303ba6 100644 --- a/lib/internal/fs/promises.js +++ b/lib/internal/fs/promises.js @@ -1723,7 +1723,7 @@ async function symlink(target, path, type) { ); } -async function fstat(handle, options = { bigint: false }) { +async function fstat(handle, options = { __proto__: null, bigint: false }) { const result = await PromisePrototypeThen( binding.fstat(handle.fd, options.bigint, kUsePromises), undefined, @@ -1732,7 +1732,7 @@ async function fstat(handle, options = { bigint: false }) { return getStatsFromBinding(result); } -async function lstat(path, options = { bigint: false }) { +async function lstat(path, options = { __proto__: null, bigint: false }) { path = getValidatedPath(path); if (permission.isEnabled() && !permission.has('fs.read', path)) { const resource = pathModule.toNamespacedPath(BufferIsBuffer(path) ? BufferToString(path) : path); @@ -1746,7 +1746,7 @@ async function lstat(path, options = { bigint: false }) { return getStatsFromBinding(result); } -async function stat(path, options = { bigint: false, throwIfNoEntry: true }) { +async function stat(path, options = { __proto__: null, bigint: false, throwIfNoEntry: true }) { const result = await PromisePrototypeThen( binding.stat(getValidatedPath(path), options.bigint, kUsePromises, options.throwIfNoEntry), undefined, @@ -1759,7 +1759,7 @@ async function stat(path, options = { bigint: false, throwIfNoEntry: true }) { return getStatsFromBinding(result); } -async function statfs(path, options = { bigint: false }) { +async function statfs(path, options = { __proto__: null, bigint: false }) { const result = await PromisePrototypeThen( binding.statfs(getValidatedPath(path), options.bigint, kUsePromises), undefined, diff --git a/lib/internal/http2/core.js b/lib/internal/http2/core.js index ac4a72feef40..a6077abb063e 100644 --- a/lib/internal/http2/core.js +++ b/lib/internal/http2/core.js @@ -3816,7 +3816,7 @@ function getUnpackedSettings(buf, options = kEmptyObject) { return settings; } -function performServerHandshake(socket, options = {}) { +function performServerHandshake(socket, options = kEmptyObject) { options = initializeOptions(options); return new ServerHttp2Session(options, socket, undefined); } diff --git a/lib/internal/inspector/network_http.js b/lib/internal/inspector/network_http.js index 46bdd827c094..1bb08762bdb4 100644 --- a/lib/internal/inspector/network_http.js +++ b/lib/internal/inspector/network_http.js @@ -19,6 +19,7 @@ const { } = require('internal/inspector/network'); const { Network } = require('inspector'); const EventEmitter = require('events'); +const { kEmptyObject } = require('internal/util'); const kRequestUrl = Symbol('kRequestUrl'); @@ -36,7 +37,7 @@ function getRequestURL(request, host) { } // Convert a Headers object (Map) to a plain object (Map) -const convertHeaderObject = (headers = {}) => { +const convertHeaderObject = (headers = kEmptyObject) => { // The 'host' header that contains the host and port of the URL. let host; let charset; diff --git a/lib/internal/inspector/network_http2.js b/lib/internal/inspector/network_http2.js index 0f0751c44dd1..0f4aabf59cfd 100644 --- a/lib/internal/inspector/network_http2.js +++ b/lib/internal/inspector/network_http2.js @@ -30,11 +30,12 @@ const { } = internalBinding('http2').constants; const EventEmitter = require('events'); const { Buffer } = require('buffer'); +const { kEmptyObject } = require('internal/util'); const kRequestUrl = Symbol('kRequestUrl'); // Convert a Headers object (Map) to a plain object (Map) -function convertHeaderObject(headers = {}) { +function convertHeaderObject(headers = kEmptyObject) { let scheme; let authority; let path; diff --git a/lib/internal/modules/esm/hooks.js b/lib/internal/modules/esm/hooks.js index 3a2556696fbd..4fcce927495b 100644 --- a/lib/internal/modules/esm/hooks.js +++ b/lib/internal/modules/esm/hooks.js @@ -358,7 +358,7 @@ class AsyncLoaderHooksOnLoaderHookWorker { * @param {object} context Metadata about the module * @returns {Promise<{ format: ModuleFormat, source: ModuleSource }>} */ - async load(url, context = {}) { + async load(url, context = kEmptyObject) { const chain = this.#chains.load; const meta = { chainFinished: null, diff --git a/lib/internal/modules/esm/resolve.js b/lib/internal/modules/esm/resolve.js index f6c807822841..3916e79328ed 100644 --- a/lib/internal/modules/esm/resolve.js +++ b/lib/internal/modules/esm/resolve.js @@ -30,7 +30,7 @@ const { getOptionValue } = require('internal/options'); // Do not eagerly grab .manifest, it may be in TDZ const { sep, posix: { relative: relativePosixPath }, resolve, join } = require('path'); const { URL, pathToFileURL, fileURLToPath, isURL, URLParse } = require('internal/url'); -const { getCWDURL, setOwnProperty } = require('internal/util'); +const { getCWDURL, setOwnProperty, kEmptyObject } = require('internal/util'); const { canParse: URLCanParse } = internalBinding('url'); const { legacyMainResolve: FSLegacyMainResolve } = internalBinding('fs'); const { @@ -958,7 +958,7 @@ function throwIfInvalidParentURL(parentURL) { * @param {string[]} [context.conditions] - The conditions for resolving the specifier. * @returns {{url: string, format?: string}} */ -function defaultResolve(specifier, context = {}) { +function defaultResolve(specifier, context = kEmptyObject) { let { parentURL, conditions } = context; throwIfInvalidParentURL(parentURL); diff --git a/lib/internal/perf/observe.js b/lib/internal/perf/observe.js index f35544328d43..a9f3f267bc18 100644 --- a/lib/internal/perf/observe.js +++ b/lib/internal/perf/observe.js @@ -576,14 +576,14 @@ function hasObserver(type) { } -function startPerf(target, key, context = {}) { +function startPerf(target, key, context = kEmptyObject) { target[key] = { ...context, startTime: now(), }; } -function stopPerf(target, key, context = {}) { +function stopPerf(target, key, context = kEmptyObject) { const ctx = target[key]; if (!ctx) { return; diff --git a/lib/internal/readline/emitKeypressEvents.js b/lib/internal/readline/emitKeypressEvents.js index 1ac1091d70ca..0878bd8154e3 100644 --- a/lib/internal/readline/emitKeypressEvents.js +++ b/lib/internal/readline/emitKeypressEvents.js @@ -31,7 +31,7 @@ const ESCAPE_CODE_TIMEOUT = 500; * accepts a readable Stream instance and makes it emit "keypress" events */ -function emitKeypressEvents(stream, iface = {}) { +function emitKeypressEvents(stream, iface = { __proto__: null }) { if (stream[KEYPRESS_DECODER]) return; stream[KEYPRESS_DECODER] = new StringDecoder('utf8'); diff --git a/lib/internal/streams/fast-utf8-stream.js b/lib/internal/streams/fast-utf8-stream.js index 6ca12878fd40..8a1b6185e828 100644 --- a/lib/internal/streams/fast-utf8-stream.js +++ b/lib/internal/streams/fast-utf8-stream.js @@ -12,6 +12,7 @@ const { const { sleep, + kEmptyObject, } = require('internal/util'); const { @@ -131,7 +132,7 @@ class Utf8Stream extends EventEmitter { contentMode = kContentModeUtf8, mode, // Provides for a custom fs implementation. Mostly useful for testing. - fs: overrideFs = {}, + fs: overrideFs = kEmptyObject, } = options; super(); diff --git a/lib/repl.js b/lib/repl.js index 77d62495661c..4c935caed7e0 100644 --- a/lib/repl.js +++ b/lib/repl.js @@ -1049,7 +1049,7 @@ function start(prompt, source, eval_, useGlobal, ignoreUndefined, replMode) { prompt, source, eval_, useGlobal, ignoreUndefined, replMode); } -REPLServer.prototype.setupHistory = function setupHistory(historyConfig = {}, cb) { +REPLServer.prototype.setupHistory = function setupHistory(historyConfig = { __proto__: null }, cb) { // TODO(puskin94): necessary because historyConfig can be a string for backwards compatibility const options = typeof historyConfig === 'string' ? { filePath: historyConfig } : diff --git a/lib/util.js b/lib/util.js index 622a3f239f3a..ed54f718d1c4 100644 --- a/lib/util.js +++ b/lib/util.js @@ -96,6 +96,7 @@ const { getSystemErrorMessage: internalErrorMessage, promisify, defineLazyProperties, + kEmptyObject, } = require('internal/util'); let abortController; @@ -583,7 +584,7 @@ function getCallSites(frameCount = 10, options) { }; // Public util.deprecate API -function deprecate(fn, msg, code, { modifyPrototype } = {}) { +function deprecate(fn, msg, code, { modifyPrototype } = kEmptyObject) { return internalDeprecate(fn, msg, code, undefined, modifyPrototype); } diff --git a/lib/vm.js b/lib/vm.js index ae7108062018..2c7446af6076 100644 --- a/lib/vm.js +++ b/lib/vm.js @@ -24,6 +24,7 @@ const { ArrayPrototypeForEach, ObjectFreeze, + ObjectPrototype, PromiseReject, ReflectApply, Symbol, @@ -222,7 +223,7 @@ function getContextOptions(options) { } let defaultContextNameIndex = 1; -function createContext(contextObject = {}, options = kEmptyObject) { +function createContext(contextObject = { __proto__: ObjectPrototype }, options = kEmptyObject) { if (contextObject !== vm_context_no_contextify && isContext(contextObject)) { return contextObject; } diff --git a/lib/wasi.js b/lib/wasi.js index 9971b533e723..b81835dfede5 100644 --- a/lib/wasi.js +++ b/lib/wasi.js @@ -111,7 +111,7 @@ class WASI { finalizeBindings(instance, { memory = instance?.exports?.memory, - } = {}) { + } = kEmptyObject) { if (this[kStarted]) { throw new ERR_WASI_ALREADY_STARTED(); } diff --git a/test/parallel/test-eslint-avoid-prototype-pollution.js b/test/parallel/test-eslint-avoid-prototype-pollution.js index c6b0fe638655..98dc951a9064 100644 --- a/test/parallel/test-eslint-avoid-prototype-pollution.js +++ b/test/parallel/test-eslint-avoid-prototype-pollution.js @@ -11,7 +11,7 @@ const RuleTester = require('../../tools/eslint/node_modules/eslint').RuleTester; const rule = require('../../tools/eslint-rules/avoid-prototype-pollution'); new RuleTester() - .run('property-descriptor-no-prototype-pollution', rule, { + .run('avoid-prototype-pollution', rule, { valid: [ 'ObjectDefineProperties({}, {})', 'ObjectCreate(null, {})', @@ -63,6 +63,18 @@ new RuleTester() 'new Proxy({}, { __proto__: null, ...{} })', 'async function name(){return await SafePromiseAll([])}', 'async function name(){const val = await SafePromiseAll([])}', + 'async function name(options = kEmptyObject){}', + 'function name(options = kEmptyObject){}', + 'function name(options = { __proto__: ObjectPrototype }){}', + 'function name(options = { __proto__: null }){}', + 'new class { name(options = kEmptyObject){} }', + 'const name = (options = kEmptyObject) => {}', + 'async function name(options = { __proto__: null, key: 1 }){}', + 'const name = ({ destr } = { destr: 1 }) => {}', + 'function name({ destr } = kEmptyObject){}', + 'function name({ [Symbol.match]: m } = kEmptyObject){}', + 'function name({ [Symbol.match]: m } = { __proto__: null, [Symbol.match]: 1 }){}', + 'function name({ [Symbol.match]: m, c } = { __proto__: null, c: 1 }){}', ], invalid: [ { @@ -330,5 +342,53 @@ new RuleTester() code: 'ArrayPrototypeConcat([])', errors: [{ message: /\bisConcatSpreadable\b/ }] }, + { + code: 'function name(options = {}) {}', + errors: [{ message: /\bkEmptyObject\b/ }] + }, + { + code: 'async function name(options = { key: 1 }) {}', + errors: [{ message: /\b__proto__: null\b/ }] + }, + { + code: 'new class { name(options = {}) {} }', + errors: [{ message: /\bkEmptyObject\b/ }] + }, + { + code: 'new class { async name(options = {}) {} }', + errors: [{ message: /\bkEmptyObject\b/ }] + }, + { + code: 'const name = (options = {}) => {}', + errors: [{ message: /\bkEmptyObject\b/ }] + }, + { + code: 'const name = async (options = {}) => {}', + errors: [{ message: /\bkEmptyObject\b/ }] + }, + { + code: 'function name({ destr } = {}) {}', + errors: [{ message: /\bkEmptyObject\b/ }] + }, + { + code: 'async function name({ destr } = {}) {}', + errors: [{ message: /\bkEmptyObject\b/ }] + }, + { + code: 'new class { name({ destr } = {}) {} }', + errors: [{ message: /\bkEmptyObject\b/ }] + }, + { + code: 'new class { async name({ destr } = {}) {} }', + errors: [{ message: /\bkEmptyObject\b/ }] + }, + { + code: 'const name = ({ destr } = {}) => {}', + errors: [{ message: /\bkEmptyObject\b/ }] + }, + { + code: 'const name = async ({ destr } = {}) => {}', + errors: [{ message: /\bkEmptyObject\b/ }] + }, ] }); diff --git a/tools/eslint-rules/avoid-prototype-pollution.js b/tools/eslint-rules/avoid-prototype-pollution.js index da13653e3e36..79468a000274 100644 --- a/tools/eslint-rules/avoid-prototype-pollution.js +++ b/tools/eslint-rules/avoid-prototype-pollution.js @@ -232,6 +232,28 @@ module.exports = { 'which can be subject to prototype pollution', }); }, + + 'AssignmentPattern[right.type="ObjectExpression"]'(node) { + if (!node.right.properties.length) { + context.report({ + node: node.right, + message: 'Use kEmptyObject instead of declaring a new empty object, or define a __proto__ property', + }); + return; + } + const propertyIsIdentifier = (p) => p.key.type === 'Identifier'; + if (node.left.type === 'ObjectPattern' && + node.left.properties.every(propertyIsIdentifier) && + node.right.properties.every(propertyIsIdentifier)) { + const rightNames = node.right.properties.map((p) => p.key.name); + if (node.left.properties.every((p) => rightNames.includes(p.key.name))) return; + } + if (node.right.properties.some((p) => p.key.name === '__proto__')) return; + context.report({ + node: node.right, + message: `Add '__proto__: null' to avoid inheriting from Object.prototype, or '__proto__: ObjectPrototype' if inheritance is desirable`, + }); + }, }; }, }; From 59cb0022bfcd04278d136e4d41d11223dc7e66b1 Mon Sep 17 00:00:00 2001 From: avivkeller Date: Wed, 29 Jul 2026 14:17:04 -0700 Subject: [PATCH 003/228] test_runner: add support for --test-coverage-include-all Signed-off-by: avivkeller PR-URL: https://github.com/nodejs/node/pull/64830 Fixes: https://github.com/nodejs/node/issues/58887 Reviewed-By: Moshe Atlow Reviewed-By: Chemi Atlow --- doc/api/cli.md | 16 +++++ doc/api/test.md | 7 ++ doc/node.1 | 14 ++++ lib/internal/test_runner/coverage.js | 50 ++++++++++++- lib/internal/test_runner/runner.js | 3 + lib/internal/test_runner/utils.js | 2 + src/node_options.cc | 7 ++ src/node_options.h | 1 + .../coverage-include-all/covered.js | 5 ++ .../coverage-include-all/data.json | 3 + .../coverage-include-all/index.test.js | 9 +++ .../coverage-include-all/nested/deep.js | 5 ++ .../node_modules/pkg/index.js | 5 ++ .../coverage-include-all/untested.js | 5 ++ test/parallel/test-runner-coverage.js | 70 +++++++++++++++++++ 15 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/test-runner/coverage-include-all/covered.js create mode 100644 test/fixtures/test-runner/coverage-include-all/data.json create mode 100644 test/fixtures/test-runner/coverage-include-all/index.test.js create mode 100644 test/fixtures/test-runner/coverage-include-all/nested/deep.js create mode 100644 test/fixtures/test-runner/coverage-include-all/node_modules/pkg/index.js create mode 100644 test/fixtures/test-runner/coverage-include-all/untested.js diff --git a/doc/api/cli.md b/doc/api/cli.md index b4f389241f66..2d794f0d2e3c 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -2725,6 +2725,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` -* Returns: {Object} +* Type: {Object} * `loopCount` {number} Number of event loop iterations. * `events` {number} Number of events that have been processed by the event handler. * `eventsWaiting` {number} Number of events that were waiting to be processed when the event provider was called. From b9e8ef1fe8caa60f4c0a31935f16b9bfe667e642 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Thu, 17 Sep 2026 20:46:06 +0000 Subject: [PATCH 017/228] perf_hooks: add performanceNodeTiming.uvMetricsInfoBigInt `performance.nodeTiming.uvMetricsInfo` returns the libuv event loop metrics as numbers, which are only exact up to `Number.MAX_SAFE_INTEGER`. Add `uvMetricsInfoBigInt`, which returns the same metrics as bigints backed by `uint64_t` storage, carrying the full 64-bit range reported by libuv. A single native call fills both a `Float64Array` and a `BigUint64Array`, so `uvMetricsInfo` does not pay for bigint allocation and conversion. The new property is omitted from `toJSON()`, as `JSON.stringify()` cannot serialize bigints. Assisted-by: OpenCode Signed-off-by: James M Snell PR-URL: https://github.com/nodejs/node/pull/66094 Reviewed-By: Rafael Gonzaga Reviewed-By: Chengzhong Wu --- .../perf_hooks/nodetiming-uvmetricsinfo.js | 19 ++++++--- doc/api/perf_hooks.md | 41 +++++++++++++++++++ lib/internal/perf/nodetiming.js | 20 +++++++++ src/aliased_buffer.h | 3 +- src/node_perf.cc | 36 ++++++++++++---- src/node_perf_common.h | 5 ++- src/node_snapshotable.cc | 3 ++ .../fixtures/test-nodetiming-uvmetricsinfo.js | 28 ++++++++++++- ...ormance-nodetiming-uvmetricsinfo-buffer.js | 14 +++++-- ...ormance-nodetiming-uvmetricsinfo-worker.js | 26 ++++++++++++ test/parallel/test-performance-nodetiming.js | 20 +++++++++ typings/internalBinding/performance.d.ts | 1 + 12 files changed, 197 insertions(+), 19 deletions(-) create mode 100644 test/parallel/test-performance-nodetiming-uvmetricsinfo-worker.js diff --git a/benchmark/perf_hooks/nodetiming-uvmetricsinfo.js b/benchmark/perf_hooks/nodetiming-uvmetricsinfo.js index 1d8d174de14f..d646631f4e9c 100644 --- a/benchmark/perf_hooks/nodetiming-uvmetricsinfo.js +++ b/benchmark/perf_hooks/nodetiming-uvmetricsinfo.js @@ -11,6 +11,7 @@ const { const bench = common.createBenchmark(main, { n: [1e6], events: [1, 1000, 10000], + api: ['number', 'bigint'], }); async function runEvents(events) { @@ -19,11 +20,19 @@ async function runEvents(events) { } } -async function main({ n, events }) { +async function main({ n, events, api }) { await runEvents(events); - bench.start(); - for (let i = 0; i < n; i++) { - assert.ok(performance.nodeTiming.uvMetricsInfo); + if (api === 'bigint') { + bench.start(); + for (let i = 0; i < n; i++) { + assert.ok(performance.nodeTiming.uvMetricsInfoBigInt); + } + bench.end(n); + } else { + bench.start(); + for (let i = 0; i < n; i++) { + assert.ok(performance.nodeTiming.uvMetricsInfo); + } + bench.end(n); } - bench.end(n); } diff --git a/doc/api/perf_hooks.md b/doc/api/perf_hooks.md index 4d72e35cc90c..ca25c38ffdf2 100644 --- a/doc/api/perf_hooks.md +++ b/doc/api/perf_hooks.md @@ -858,6 +858,10 @@ added: This is a wrapper to the `uv_metrics_info` function. It returns the current set of event loop metrics. +The values are exact up to `Number.MAX_SAFE_INTEGER`. Use +[`performanceNodeTiming.uvMetricsInfoBigInt`][] to obtain the full 64-bit +values reported by libuv. + It is recommended to use this property inside a function whose execution was scheduled using `setImmediate` to avoid collecting metrics before finishing all operations scheduled during the current loop iteration. @@ -878,6 +882,41 @@ setImmediate(() => { }); ``` +### `performanceNodeTiming.uvMetricsInfoBigInt` + + + +* Type: {Object} + * `loopCount` {bigint} Number of event loop iterations. + * `events` {bigint} Number of events that have been processed by the event handler. + * `eventsWaiting` {bigint} Number of events that were waiting to be processed when the event provider was called. + +The same as [`performanceNodeTiming.uvMetricsInfo`][], except that the values +are {bigint}s carrying the full 64-bit range reported by libuv. + +Because `JSON.stringify()` cannot serialize {bigint} values, this property is +not enumerable and is not included in the output of +`performanceNodeTiming.toJSON()`. Copies of `performance.nodeTiming` made by +spreading its enumerable properties, for example, remain serializable. + +```cjs +const { performance } = require('node:perf_hooks'); + +setImmediate(() => { + console.log(performance.nodeTiming.uvMetricsInfoBigInt); +}); +``` + +```mjs +import { performance } from 'node:perf_hooks'; + +setImmediate(() => { + console.log(performance.nodeTiming.uvMetricsInfoBigInt); +}); +``` + ### `performanceNodeTiming.v8Start` -* `signals` {AbortSignal\[]} The `AbortSignal`s of which to compose a new `AbortSignal`. +* `signals` {Iterable} An iterable of {AbortSignal}s from which to compose a new + {AbortSignal}. Returns a new `AbortSignal` which will be aborted if any of the provided signals are aborted. Its [`abortSignal.reason`][] will be set to whichever From f580a844a7137af32275e9a3dad1fef901895010 Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Wed, 23 Sep 2026 12:24:53 +0200 Subject: [PATCH 045/228] src: add a flag to keep the embedder's wasm streaming callback `SetIsolateMiscHandlers()` always installs Node.js's `WebAssembly.compileStreaming()` implementation, which is backed by the Environment's fetch-based handler. An embedder that provides its own streaming callback (for example one wired to its own network stack) has to re-install it after every `SetIsolateUpForNode()` or `NewIsolate()` call. Add `SHOULD_NOT_SET_WASM_STREAMING_CALLBACK` next to the existing `SHOULD_NOT_SET_PROMISE_REJECTION_CALLBACK` and `SHOULD_NOT_SET_PREPARE_STACK_TRACE_CALLBACK` flags so it can opt out the same way. Refs: https://github.com/nodejs/node/pull/36447 Signed-off-by: Shelley Vohr PR-URL: https://github.com/nodejs/node/pull/65690 Reviewed-By: Chengzhong Wu --- src/api/environment.cc | 4 +++- src/node.h | 1 + test/cctest/test_environment.cc | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/api/environment.cc b/src/api/environment.cc index 4debed160e1a..94cbef28b9c4 100644 --- a/src/api/environment.cc +++ b/src/api/environment.cc @@ -271,7 +271,9 @@ void SetIsolateMiscHandlers(v8::Isolate* isolate, const IsolateSettings& s) { isolate->SetModifyCodeGenerationFromStringsCallback( modify_code_generation_from_strings_callback); - isolate->SetWasmStreamingCallback(wasm_web_api::StartStreamingCompilation); + if ((s.flags & SHOULD_NOT_SET_WASM_STREAMING_CALLBACK) == 0) { + isolate->SetWasmStreamingCallback(wasm_web_api::StartStreamingCompilation); + } Mutex::ScopedLock lock(node::per_process::cli_options_mutex); if (per_process::cli_options->get_per_isolate_options() diff --git a/src/node.h b/src/node.h index 9f9ba48eb8e2..04514b4b128c 100644 --- a/src/node.h +++ b/src/node.h @@ -466,6 +466,7 @@ enum IsolateSettingsFlags { DETAILED_SOURCE_POSITIONS_FOR_PROFILING = 1 << 1, SHOULD_NOT_SET_PROMISE_REJECTION_CALLBACK = 1 << 2, SHOULD_NOT_SET_PREPARE_STACK_TRACE_CALLBACK = 1 << 3, + SHOULD_NOT_SET_WASM_STREAMING_CALLBACK = 1 << 4, ALLOW_MODIFY_CODE_GENERATION_FROM_STRINGS_CALLBACK = 0, /* legacy no-op */ }; diff --git a/test/cctest/test_environment.cc b/test/cctest/test_environment.cc index 6a48db67baad..b328d3865f9e 100644 --- a/test/cctest/test_environment.cc +++ b/test/cctest/test_environment.cc @@ -65,6 +65,39 @@ TEST_F(EnvironmentTest, ManagedBufferCache) { (*env)->release_managed_buffer(buffer); } +static int embedder_wasm_streaming_calls = 0; +static void EmbedderWasmStreaming(const v8::FunctionCallbackInfo&) { + embedder_wasm_streaming_calls++; +} + +TEST_F(EnvironmentTest, KeepsEmbedderWasmStreamingCallbackWhenAsked) { + const v8::HandleScope handle_scope(isolate_); + const Argv argv; + Env env{handle_scope, argv}; + + auto compile_streaming = [&]() { + v8::Local context = isolate_->GetCurrentContext(); + v8::Script::Compile(context, + v8::String::NewFromUtf8Literal( + isolate_, "WebAssembly.compileStreaming(0); 0")) + .ToLocalChecked() + ->Run(context) + .ToLocalChecked(); + isolate_->PerformMicrotaskCheckpoint(); + }; + + node::IsolateSettings settings; + settings.flags |= node::SHOULD_NOT_SET_WASM_STREAMING_CALLBACK; + isolate_->SetWasmStreamingCallback(EmbedderWasmStreaming); + node::SetIsolateUpForNode(isolate_, settings); + compile_streaming(); + EXPECT_EQ(embedder_wasm_streaming_calls, 1); + + node::SetIsolateUpForNode(isolate_); + compile_streaming(); + EXPECT_EQ(embedder_wasm_streaming_calls, 1); +} + TEST_F(EnvironmentTest, EnvironmentWithoutBrowserGlobals) { const v8::HandleScope handle_scope(isolate_); Argv argv; From ef584facbc8a9591f28debbe7e82bd2586bf924b Mon Sep 17 00:00:00 2001 From: yunshingng Date: Tue, 22 Sep 2026 04:53:24 -0400 Subject: [PATCH 046/228] doc: clarify Worker execArgv vs Permission Model grants Omit execArgv: the worker keeps the parent's CLI flags, including --permission and --allow-*. Explicit execArgv (including []) replaces inheritance and can drop those grants. That is intended. Documented after nodejs/node#65359. No runtime change. Refs: https://github.com/nodejs/node/pull/65359 Signed-off-by: yunshingng PR-URL: https://github.com/nodejs/node/pull/66124 Reviewed-By: James M Snell Reviewed-By: Rafael Gonzaga --- doc/api/permissions.md | 8 +++++++- doc/api/worker_threads.md | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/doc/api/permissions.md b/doc/api/permissions.md index 2887d9624f61..196e0089de94 100644 --- a/doc/api/permissions.md +++ b/doc/api/permissions.md @@ -327,7 +327,13 @@ easy to configure permissions as needed when using `npx`. There are constraints you need to know before using this system: -* The model does not inherit to a worker thread. +* The model does not inherit to a worker thread. A default + `worker_threads.Worker` (no `execArgv` option) still receives the parent + process CLI flags, including `--permission` and `--allow-*` if those were + passed to the parent. Setting `execArgv` explicitly, including + `execArgv: []`, replaces the inherited flags. The worker then does not keep + the parent's Permission Model grants unless those flags are listed again in + `execArgv`. That difference is intended, not a bypass. * When using the Permission Model the following features will be restricted: * Native modules * Child process diff --git a/doc/api/worker_threads.md b/doc/api/worker_threads.md index b276975239c4..dc1e461272bd 100644 --- a/doc/api/worker_threads.md +++ b/doc/api/worker_threads.md @@ -1629,6 +1629,12 @@ changes: process (such as `--title`) are not supported. If set, this is provided as [`process.execArgv`][] inside the worker. By default, options are inherited from the parent thread. + Passing an explicit `execArgv` (including an empty array) replaces that + inheritance: the worker receives only the listed flags. Under the + [Permission Model](permissions.md#permission-model), that means an explicit + `execArgv` can drop the parent's `--permission` / `--allow-*` grants. + Omit `execArgv` to keep the parent's CLI flags. This is intended. See + [Permission Model limitations](permissions.md#limitations-and-known-issues). * `stdin` {boolean} If this is set to `true`, then `worker.stdin` provides a writable stream whose contents appear as `process.stdin` inside the Worker. By default, no data is provided. From e0fcdc62aa825c65b47172c66dffe85de04d7849 Mon Sep 17 00:00:00 2001 From: yuki motokane <216834584+motokaneyuki@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:24:16 +0900 Subject: [PATCH 047/228] doc: add return type to Blocklist.isBlocklist Signed-off-by: Yuki Motokane <216834584+motokaneyuki@users.noreply.github.com> PR-URL: https://github.com/nodejs/node/pull/66181 Refs: https://github.com/nodejs/doc-kit/issues/953 Reviewed-By: Luigi Pinca Reviewed-By: James M Snell Reviewed-By: Tim Perry Reviewed-By: Xuguang Mei --- doc/api/net.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/api/net.md b/doc/api/net.md index e43d0036662b..ac3562f2430e 100644 --- a/doc/api/net.md +++ b/doc/api/net.md @@ -238,7 +238,7 @@ added: --> * `value` {any} Any JS value -* Returns `true` if the `value` is a `net.BlockList`. +* Returns {boolean} `true` if the `value` is a `net.BlockList`. ### `BlockList.PRIVATE_RANGES` From d09171c4a83732ef28c8bd231a024c3ccf04fa86 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Wed, 23 Sep 2026 18:01:27 +0200 Subject: [PATCH 048/228] test: deflake watch mode tests Use isolated directories for the NODE_OPTIONS watch paths to avoid recursively watching the repository's source and test trees. Allow a platform-scaled 10 seconds for startup and restarts. Remove the flaky markers for test-watch-mode and test-watch-mode-inspect. The inspector restart race was already fixed in f2aaf638465. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66232 Reviewed-By: Antoine du Hamel Reviewed-By: Moshe Atlow Reviewed-By: Matteo Collina --- test/sequential/sequential.status | 3 --- test/sequential/test-watch-mode.mjs | 10 ++++++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/test/sequential/sequential.status b/test/sequential/sequential.status index 6b5714500b23..4111eb2b8ecf 100644 --- a/test/sequential/sequential.status +++ b/test/sequential/sequential.status @@ -7,9 +7,6 @@ prefix sequential [true] # This section applies to all platforms # https://github.com/nodejs/node/issues/27611#issuecomment-613100468 test-cpu-prof-dir-worker: PASS, FLAKY -# https://github.com/nodejs/node/issues/44898 -test-watch-mode: PASS, FLAKY -test-watch-mode-inspect: PASS, FLAKY # https://github.com/nodejs/node/issues/47409 test-http2-large-file: PASS, FLAKY diff --git a/test/sequential/test-watch-mode.mjs b/test/sequential/test-watch-mode.mjs index d14493192a02..0c28adec0171 100644 --- a/test/sequential/test-watch-mode.mjs +++ b/test/sequential/test-watch-mode.mjs @@ -87,7 +87,7 @@ function runInBackground({ args = [], options = {}, completed = 'Completed runni future.resolve(); return { stdout, stderr }; }, - restart(timeout = 1000) { + restart(timeout = common.platformTimeout(10_000)) { if (!child) { run(); } @@ -996,6 +996,10 @@ process.on('message', (message) => { }); it('should strip all watch flags from NODE_OPTIONS in child process', async () => { + // Avoid recursively watching the repository's source and test trees. + const cwd = tmpdir.resolve('node-options'); + mkdirSync(path.join(cwd, 'src'), { recursive: true }); + mkdirSync(path.join(cwd, 'test')); const file = createTmpFile('console.log(process.env.NODE_OPTIONS);'); const nodeOptions = [ '--watch', @@ -1012,6 +1016,7 @@ process.on('message', (message) => { const { done, restart } = runInBackground({ args: ['--watch', file], options: { + cwd, env: { ...process.env, NODE_OPTIONS: nodeOptions }, }, }); @@ -1040,7 +1045,8 @@ process.on('message', (message) => { writeFileSync(reqFile, 'globalThis.requiredOk = true;'); const file = createTmpFile('console.log("required:" + !!globalThis.requiredOk);'); - const nodeOptions = `--watch --require "${reqFile}"`; + // Backslashes inside quoted NODE_OPTIONS values are escape characters. + const nodeOptions = `--watch --require "${reqFile.replaceAll(path.sep, '/')}"`; const { done, restart } = runInBackground({ args: ['--watch', file], options: { From 9b08726558e833f61ece638351ad9735033578e6 Mon Sep 17 00:00:00 2001 From: "Node.js GitHub Bot" Date: Wed, 23 Sep 2026 12:25:12 -0400 Subject: [PATCH 049/228] test: update WPT for url to c48d58747e PR-URL: https://github.com/nodejs/node/pull/66141 Reviewed-By: Colin Ihrig Reviewed-By: Aviv Keller Reviewed-By: Luigi Pinca Reviewed-By: Filip Skokan --- .../wpt/url/resources/urltestdata.json | 45 +++++++++++++++++++ test/fixtures/wpt/versions.json | 2 +- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/test/fixtures/wpt/url/resources/urltestdata.json b/test/fixtures/wpt/url/resources/urltestdata.json index aae7b1f65683..22900fb0ecce 100644 --- a/test/fixtures/wpt/url/resources/urltestdata.json +++ b/test/fixtures/wpt/url/resources/urltestdata.json @@ -9638,6 +9638,51 @@ "hash": "" }, "Non-special schemes that some implementations might incorrectly treat as special", + { + "input": "constructor:example", + "base": null, + "href": "constructor:example", + "origin": "null", + "protocol": "constructor:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "example", + "search": "", + "hash": "" + }, + { + "input": "constructor:/example", + "base": null, + "href": "constructor:/example", + "origin": "null", + "protocol": "constructor:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/example", + "search": "", + "hash": "" + }, + { + "input": "constructor://HOST:80", + "base": null, + "href": "constructor://HOST:80", + "origin": "null", + "protocol": "constructor:", + "username": "", + "password": "", + "host": "HOST:80", + "hostname": "HOST", + "port": "80", + "pathname": "", + "search": "", + "hash": "" + }, { "input": "data://example.com:8080/pathname?search#hash", "base": null, diff --git a/test/fixtures/wpt/versions.json b/test/fixtures/wpt/versions.json index bc8d4ef797d2..c65dea83c044 100644 --- a/test/fixtures/wpt/versions.json +++ b/test/fixtures/wpt/versions.json @@ -72,7 +72,7 @@ "path": "streams" }, "url": { - "commit": "c23755a1449cc9c5a9131378c13ecf073c0885c6", + "commit": "c48d58747e1f211527fb695fd60548a997fae617", "path": "url" }, "urlpattern": { From d4b2ba6776aa691ff597cdb04f2dd076ac31dc90 Mon Sep 17 00:00:00 2001 From: Mert Can Altin Date: Thu, 24 Sep 2026 00:41:49 +0300 Subject: [PATCH 050/228] test: use heapdump test for SecureContext memory tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mert Can Altin PR-URL: https://github.com/nodejs/node/pull/64344 Refs: https://github.com/nodejs/node/pull/59051 Reviewed-By: James M Snell Reviewed-By: Joyee Cheung Reviewed-By: Gürgün Dayıoğlu --- test/cctest/test_node_crypto.cc | 27 ------------------- test/pummel/test-heapdump-secure-context.js | 29 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 27 deletions(-) create mode 100644 test/pummel/test-heapdump-secure-context.js diff --git a/test/cctest/test_node_crypto.cc b/test/cctest/test_node_crypto.cc index 59af020d8347..df387ab00f9e 100644 --- a/test/cctest/test_node_crypto.cc +++ b/test/cctest/test_node_crypto.cc @@ -87,33 +87,6 @@ TEST(NodeCrypto, NewRootCertStore) { X509_STORE_free(store); } -/* - * This test verifies that OpenSSL memory tracking constants are properly - * defined. - */ -TEST(NodeCrypto, MemoryTrackingConstants) { - // Verify that our memory tracking constants are defined and reasonable - EXPECT_GT(node::crypto::kSizeOf_SSL_CTX, static_cast(0)) - << "SSL_CTX size constant should be positive"; - EXPECT_GT(node::crypto::kSizeOf_X509, static_cast(0)) - << "X509 size constant should be positive"; - EXPECT_GT(node::crypto::kSizeOf_EVP_MD_CTX, static_cast(0)) - << "EVP_MD_CTX size constant should be positive"; - - // Verify reasonable size ranges (basic sanity check) - EXPECT_LT(node::crypto::kSizeOf_SSL_CTX, static_cast(10000)) - << "SSL_CTX size should be reasonable"; - EXPECT_LT(node::crypto::kSizeOf_X509, static_cast(10000)) - << "X509 size should be reasonable"; - EXPECT_LT(node::crypto::kSizeOf_EVP_MD_CTX, static_cast(1000)) - << "EVP_MD_CTX size should be reasonable"; - - // Specific values we expect based on our implementation - EXPECT_EQ(node::crypto::kSizeOf_SSL_CTX, static_cast(240)); - EXPECT_EQ(node::crypto::kSizeOf_X509, static_cast(128)); - EXPECT_EQ(node::crypto::kSizeOf_EVP_MD_CTX, static_cast(48)); -} - TEST(NodeCrypto, TryGetIntCipherOutputLength) { int output_len = 0; diff --git a/test/pummel/test-heapdump-secure-context.js b/test/pummel/test-heapdump-secure-context.js new file mode 100644 index 000000000000..30fb7d1a157a --- /dev/null +++ b/test/pummel/test-heapdump-secure-context.js @@ -0,0 +1,29 @@ +'use strict'; +// This tests heap snapshot integration of SecureContext. + +const common = require('../common'); + +if (!common.hasCrypto) common.skip('missing crypto'); + +const fixtures = require('../common/fixtures'); +const { + createJSHeapSnapshot, + validateByRetainingPathFromNodes, +} = require('../common/heap'); +const tls = require('tls'); + +// eslint-disable-next-line no-unused-vars +const ctx = tls.createSecureContext({ + cert: fixtures.readKey('agent1-cert.pem'), + key: fixtures.readKey('agent1-key.pem'), +}); + +{ + const nodes = createJSHeapSnapshot(); + validateByRetainingPathFromNodes(nodes, 'Node / SecureContext', [ + { edge_name: 'ctx', node_name: 'Node / ctx' }, + ]); + validateByRetainingPathFromNodes(nodes, 'Node / SecureContext', [ + { edge_name: 'cert', node_name: 'Node / cert' }, + ]); +} From 0ab0a61376e1e95e0276b7168c667eac28f17111 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Thu, 24 Sep 2026 16:35:23 +0200 Subject: [PATCH 051/228] tools: add resume-ci label handling Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/65945 Reviewed-By: Matteo Collina Reviewed-By: James M Snell --- .github/workflows/auto-start-ci.yml | 14 ++++----- doc/contributing/collaborator-guide.md | 17 +++++++++++ tools/actions/start-ci.sh | 39 ++++++++++++++++++++------ 3 files changed, 54 insertions(+), 16 deletions(-) diff --git a/.github/workflows/auto-start-ci.yml b/.github/workflows/auto-start-ci.yml index dc827d62ba71..c8b090fdc044 100644 --- a/.github/workflows/auto-start-ci.yml +++ b/.github/workflows/auto-start-ci.yml @@ -1,6 +1,6 @@ # This action uses the following secrets: # JENKINS_USER: GitHub user whose Jenkins token is defined below -# JENKINS_TOKEN: Jenkins token, to be used to start CI +# JENKINS_TOKEN: Jenkins token, to be used to start or resume CI name: Auto Start CI on: @@ -30,14 +30,14 @@ jobs: steps: - name: Get Pull Requests id: get_prs_for_ci - run: > - echo "numbers=$(gh pr list \ + run: | + numbers=$(gh pr list \ --repo "$GITHUB_REPOSITORY" \ - --label 'request-ci' \ --json 'number' \ - --search 'review:approved' \ + --search 'review:approved label:request-ci,resume-ci' \ -t '{{ range . }}{{ .number }} {{ end }}' \ - --limit 5)" >> "$GITHUB_OUTPUT" + --limit 5) + echo "numbers=$numbers" >> "$GITHUB_OUTPUT" env: GH_TOKEN: ${{ github.token }} start-ci: @@ -70,7 +70,7 @@ jobs: GH_TOKEN: ${{ github.token }} JENKINS_TOKEN: ${{ secrets.JENKINS_TOKEN }} - - name: Start the CI + - name: Start or resume the CI run: | read -r -a numbers <<< "$PULL_REQUESTS" curl -fsSL "https://github.com/${GITHUB_REPOSITORY}/raw/${GITHUB_SHA}/tools/actions/start-ci.sh" \ diff --git a/doc/contributing/collaborator-guide.md b/doc/contributing/collaborator-guide.md index 49ece70c2bb0..96425ebc0880 100644 --- a/doc/contributing/collaborator-guide.md +++ b/doc/contributing/collaborator-guide.md @@ -391,6 +391,21 @@ Once this label is added, `github-actions bot` will start the `node-test-pull-request` automatically. If the `github-actions bot` is unable to start the job, it will update the label with `request-ci-failed`. +To resume an existing CI run, add the `resume-ci` label to the pull request. +As with `request-ci`, the pull request must have an approving review. The bot +removes `resume-ci` when processing the request. If it cannot resume the job, it +adds `resume-ci-failed` and posts the command output with a link to the workflow +run. + +Do not combine `request-ci` and `resume-ci`. If both labels are present, the bot +removes both, adds `request-ci-failed` and `resume-ci-failed`, and reports the +conflict once without starting or resuming CI. + +The job must be failed or aborted and resumable, and its CI-approved commit must +still match the pull request's HEAD. Resuming is refused when available failure +diagnostics reference files changed by the pull request. Use `request-ci` when a +fresh CI run is needed instead. + ### Internal vs. public API All functionality in the official Node.js documentation is part of the public @@ -1011,6 +1026,8 @@ If you cannot find who to cc for a file, `git shortlog -n -s ` can help. * `never-stale`: Issues and pull requests exempt from automatic stale handling * `request-ci`: When this label is added to a PR, CI will be started automatically. See [Starting a Jenkins CI job](#starting-a-jenkins-ci-job) +* `resume-ci`: When this label is added to a PR, the latest linked CI run will be + resumed if eligible. See [Starting a Jenkins CI job](#starting-a-jenkins-ci-job) * `stale`: Issues and pull requests with no activity for 90 days. See [Stale issues and pull requests](#stale-issues-and-pull-requests) * `tsc-agenda`: Open issues and pull requests with this label will be added to diff --git a/tools/actions/start-ci.sh b/tools/actions/start-ci.sh index a9ae07a365eb..4573b17761c9 100755 --- a/tools/actions/start-ci.sh +++ b/tools/actions/start-ci.sh @@ -2,8 +2,6 @@ set -xe -REQUEST_CI_LABEL="request-ci" -REQUEST_CI_FAILED_LABEL="request-ci-failed" cqurl="${GITHUB_SERVER_URL:?}/${GITHUB_REPOSITORY:?}/actions/runs/${GITHUB_RUN_ID:?}" escape_code_block_or_line() { @@ -21,16 +19,39 @@ escape_code_block_or_line() { } for pr in "$@"; do - gh -R "$GITHUB_REPOSITORY" pr edit "$pr" --remove-label "$REQUEST_CI_LABEL" + request_labels=$(gh -R "$GITHUB_REPOSITORY" pr view "$pr" --json labels \ + --jq '[.labels[].name | select(. == "request-ci" or . == "resume-ci")] | sort | join(",")') + case "$request_labels" in + request-ci) + action=start + failed_labels=request-ci-failed + ;; + resume-ci) + action=resume + failed_labels=resume-ci-failed + ;; + request-ci,resume-ci) + action='start or resume' + failed_labels=request-ci-failed,resume-ci-failed + ;; + *) continue ;; + esac + gh -R "$GITHUB_REPOSITORY" pr edit "$pr" --remove-label "$request_labels" - ci_started=yes + ci_succeeded=yes rm -f output; - ncu-ci run --check-for-duplicates "$pr" >output 2>&1 || ci_started=no + if [ "$request_labels" = "request-ci,resume-ci" ]; then + echo 'Refusing to start or resume CI while both request-ci and resume-ci labels are present' >output + ci_succeeded=no + elif [ "$action" = "resume" ]; then + ncu-ci resume "$pr" >output 2>&1 || ci_succeeded=no + else + ncu-ci run --check-for-duplicates "$pr" >output 2>&1 || ci_succeeded=no + fi cat output - if [ "$ci_started" = "no" ]; then - # Do we need to reset? - gh -R "$GITHUB_REPOSITORY" pr edit "$pr" --add-label "$REQUEST_CI_FAILED_LABEL" + if [ "$ci_succeeded" = "no" ]; then + gh -R "$GITHUB_REPOSITORY" pr edit "$pr" --add-label "$failed_labels" reported_failure=$(grep -e '✘' -e '✖' -e '⚠' -e 'ℹ' output | tail -n 10) if [ -z "$reported_failure" ]; then @@ -42,7 +63,7 @@ for pr in "$@"; do failure_body=$(escape_code_block_or_line "$reported_failure") raw_output=$(cat output) - body="### Failed to start CI + body="### Failed to $action CI $failure_body From a78c86cb07bcc5a00d6d4c0f2d8fcd3ca706c274 Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Thu, 24 Sep 2026 17:36:00 +0200 Subject: [PATCH 052/228] test: accept ENOTEMPTY from libc++ in test-fs-rm fs.rmSync() is implemented with std::filesystem::remove_all(). When a read-only directory has a child, libc++ before LLVM 23 drops the child's EACCES and reports the ENOTEMPTY it then gets for the parent instead. The test pinned that behavior to macOS, but it depends on the C++ standard library rather than on the OS: a Linux build against libc++ fails the test when run as non-root, and macOS will start reporting EACCES once it ships the fixed libc++. Accept either code for that case on all POSIX platforms. Refs: https://github.com/llvm/llvm-project/pull/197104 Refs: https://github.com/nodejs/node/pull/57103 Signed-off-by: Shelley Vohr PR-URL: https://github.com/nodejs/node/pull/66065 Reviewed-By: Xuguang Mei --- test/parallel/test-fs-rm.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/test/parallel/test-fs-rm.js b/test/parallel/test-fs-rm.js index 1d0578e3004d..80600d114b9a 100644 --- a/test/parallel/test-fs-rm.js +++ b/test/parallel/test-fs-rm.js @@ -490,10 +490,9 @@ if (isGitPresent) { // This test should not be run as `root` if (!common.isIBMi && (common.isWindows || process.getuid() !== 0)) { function makeDirectoryReadOnly(dir, allowExecute) { - let accessErrorCode = 'EACCES'; - if (common.isMacOS && allowExecute) { - accessErrorCode = 'ENOTEMPTY'; - } + // With libc++ before LLVM 23 remove_all() reports the parent's ENOTEMPTY + // over the child's EACCES: https://github.com/llvm/llvm-project/pull/197104 + let accessErrorCode = allowExecute ? /^(EACCES|ENOTEMPTY)$/ : 'EACCES'; if (common.isWindows) { accessErrorCode = 'EPERM'; const permissions = ['DE', 'DC']; From f9c702939c5dbc1b6c62e784c5ec7627bddc462c Mon Sep 17 00:00:00 2001 From: Rafael Gonzaga Date: Thu, 24 Sep 2026 13:38:24 -0300 Subject: [PATCH 053/228] meta: label permission model changes correctly Signed-off-by: RafaelGSS PR-URL: https://github.com/nodejs/node/pull/66208 Reviewed-By: Santiago Gimeno Reviewed-By: Luigi Pinca Reviewed-By: James M Snell --- .github/label-pr-config.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/label-pr-config.yml b/.github/label-pr-config.yml index 8b36066473f4..f30b6b8a5e3e 100644 --- a/.github/label-pr-config.yml +++ b/.github/label-pr-config.yml @@ -15,6 +15,7 @@ subSystemLabels: /^src\/uv\./: c++, libuv /^src\/(?:connect(?:ion)?|pipe|tcp)_/: c++, net /^src\/node_os/: c++, os + /^src\/permission\//: c++, permission /^src\/(?:node_main|signal_)/: c++, process /^src\/timer[_s]/: c++, timers /^src\/node_root_certs/: c++, tls @@ -102,6 +103,7 @@ subSystemLabels: /^lib\/test.js$/: test_runner /^lib\/v8.js$/: v8 module /^lib\/internal\/url\.js$/: whatwg-url + /^lib\/internal\/process\/permission\.js$/: permission /^lib\/internal\/modules\/esm/: esm /^lib\/internal\/modules/: module /^lib\/internal\/source_map/: source maps @@ -116,6 +118,13 @@ subSystemLabels: exlusiveLabels: # More specific tests + /^test\/(?:parallel|sequential)\/test-(?:[^/]+-)?permission(?:-|\.)/: test, permission + /^test\/ffi\/test-ffi-permissions?(?:-|\.)/: test, permission + /^test\/addons\/no-addons\/permission\.js$/: test, addons, permission + /^test\/es-module\/test-[^/]*-permission(?:-|\.)/: test, esm, permission + /^test\/module-hooks\/test-[^/]*-permission(?:-|\.)/: test, module, loaders, permission + /^test\/fixtures\/permission\//: test, permission + /^test\/fixtures\/rc\/permission-namespace\.json$/: test, permission /^test\/addons\//: test, addons /^test\/debugger/: test, debugger /^test\/doctool\//: test, doc, tools @@ -150,6 +159,7 @@ exlusiveLabels: # test_runner documentation /^doc\/api\/test.md$/: doc, test_runner # Automatically tag JS subsystem-specific API doc changes + /^doc\/api\/permissions\.md$/: doc, permission /^doc\/api\/(\w+)\.md$/: doc, $1 # Add deprecations label to PRs that affect doc/api/deprecations.md /^doc\/api\/deprecations.md$/: doc, deprecations @@ -162,6 +172,7 @@ exlusiveLabels: /^doc\//: doc # More specific benchmarks + /^benchmark\/fs\/readfile-permission-enabled\.js$/: benchmark, fs, permission /^benchmark\/buffers\//: benchmark, buffer /^benchmark\/es\//: benchmark, v8 engine /^benchmark\/_http/: benchmark, http @@ -198,6 +209,7 @@ allJsSubSystems: - os - path - perf_hooks + - permission - process - punycode - querystring From e13ba4f7d5c35681e34f45ec8fd5419ddcf18618 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Thu, 24 Sep 2026 15:34:47 -0700 Subject: [PATCH 054/228] process: graduate process.ref/unref from experimental MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These have been shipped and stable since v22/v23 Signed-off-by: James M Snell PR-URL: https://github.com/nodejs/node/pull/66213 Reviewed-By: Gürgün Dayıoğlu Reviewed-By: Matteo Collina --- doc/api/process.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/doc/api/process.md b/doc/api/process.md index e58695636215..26b0f1e64193 100644 --- a/doc/api/process.md +++ b/doc/api/process.md @@ -3349,10 +3349,12 @@ console.log(`The parent process is pid ${ppid}`); added: - v23.6.0 - v22.14.0 +changes: + - version: REPLACEME + pr-url: https://github.com/nodejs/node/pull/66213 + description: This API is no longer experimental. --> -> Stability: 1 - Experimental - * `maybeRefable` {any} An object that may be "refable". An object is "refable" if it implements the Node.js "Refable protocol". @@ -4445,10 +4447,12 @@ In [`Worker`][] threads, `process.umask(mask)` will throw an exception. added: - v23.6.0 - v22.14.0 +changes: + - version: REPLACEME + pr-url: https://github.com/nodejs/node/pull/66213 + description: This API is no longer experimental. --> -> Stability: 1 - Experimental - * `maybeRefable` {any} An object that may be "unref'd". An object is "unrefable" if it implements the Node.js "Refable protocol". From e0887ddffbf96aefe598b448906be8c7d21df407 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Fri, 18 Sep 2026 00:05:43 +0000 Subject: [PATCH 055/228] perf_hooks: harden histogram CBOR import validation `importHistogram()` did not check the CBOR major type of keys and integer values, so other data items were decoded as integers. It also accepted duplicate keys, silently truncated integers cast to narrower types, allowed bucket counts that did not fit into an `int64_t`, and trusted the total count, min, and max, leaving imported histograms in an inconsistent state when those were absent or did not match the counts. Validate major types and value ranges, reject duplicate keys and non-increasing sparse count indexes, and derive the total count, min, and max from the counts when they are absent. A total count that is present must match the counts. Data produced by `histogram.export()` is unaffected. Assisted-by: OpenCode Signed-off-by: James M Snell PR-URL: https://github.com/nodejs/node/pull/66098 Reviewed-By: Xuguang Mei --- src/histogram.cc | 181 +++++++++++------- .../test-perf-hooks-histogram-import.js | 141 ++++++++++++++ 2 files changed, 249 insertions(+), 73 deletions(-) create mode 100644 test/parallel/test-perf-hooks-histogram-import.js diff --git a/src/histogram.cc b/src/histogram.cc index 30a095b3c43c..0f4c8f10204a 100644 --- a/src/histogram.cc +++ b/src/histogram.cc @@ -1066,12 +1066,45 @@ static bool CborReadFloat64(const uint8_t*& p, return true; } +// Read the argument of a data item that must have the given major type +// (kCborUint, kCborArray, or kCborMap). CborReadUint() alone decodes the +// argument of any major type. +static bool CborReadArgument(const uint8_t*& p, + const uint8_t* end, + uint8_t major, + uint64_t* val) { + if (p >= end || (*p & 0xe0) != major) return false; + return CborReadUint(p, end, val); +} + +// Read an unsigned integer that must fit into a non-negative int64_t. +static bool CborReadInt64(const uint8_t*& p, const uint8_t* end, int64_t* val) { + uint64_t v; + if (!CborReadArgument(p, end, kCborUint, &v) || + v > static_cast(std::numeric_limits::max())) { + return false; + } + *val = static_cast(v); + return true; +} + +// Read an unsigned integer that must fit into a non-negative int32_t. +static bool CborReadInt32(const uint8_t*& p, const uint8_t* end, int32_t* val) { + uint64_t v; + if (!CborReadArgument(p, end, kCborUint, &v) || + v > static_cast(std::numeric_limits::max())) { + return false; + } + *val = static_cast(v); + return true; +} + // Read a value that may be either a uint or float64. static bool CborReadNumber(const uint8_t*& p, const uint8_t* end, double* val) { if (p >= end) return false; if (*p == kCborFloat64) return CborReadFloat64(p, end, val); uint64_t u; - if (!CborReadUint(p, end, &u)) return false; + if (!CborReadArgument(p, end, kCborUint, &u)) return false; *val = static_cast(u); return true; } @@ -1502,13 +1535,12 @@ std::shared_ptr Histogram::Import(const uint8_t* data, size_t len) { const uint8_t* end = data + len; // Read top-level map header. - if (p >= end || (*p >> 5) != 5) return nullptr; // Must be a map. uint64_t map_size; - if (!CborReadUint(p, end, &map_size)) return nullptr; + if (!CborReadArgument(p, end, kCborMap, &map_size)) return nullptr; int64_t lowest = 1; int64_t highest = std::numeric_limits::max(); - int figures = 3; + int32_t figures = 3; int64_t total_count = 0; int64_t min_value = std::numeric_limits::max(); int64_t max_value = 0; @@ -1517,6 +1549,20 @@ std::shared_ptr Histogram::Import(const uint8_t* data, size_t len) { int32_t counts_len = 0; uint64_t version = 0; + // Bitsets of the keys read so far, used to reject duplicate keys and to + // tell whether a field was present. All known keys are less than 64. + uint64_t seen_keys = 0; + uint64_t seen_ewma_keys = 0; + auto mark_seen = [](uint64_t* seen, uint64_t key) { + const uint64_t bit = uint64_t{1} << key; + if (*seen & bit) return false; + *seen |= bit; + return true; + }; + auto has_key = [&seen_keys](uint64_t key) { + return (seen_keys & (uint64_t{1} << key)) != 0; + }; + // Sparse counts storage. std::vector> sparse_counts; @@ -1530,74 +1576,49 @@ std::shared_ptr Histogram::Import(const uint8_t* data, size_t len) { for (uint64_t i = 0; i < map_size; i++) { // Read key (unsigned int). uint64_t key; - if (!CborReadUint(p, end, &key)) return nullptr; + if (!CborReadArgument(p, end, kCborUint, &key)) return nullptr; + if (key > kKeyEwma) return nullptr; // Unknown key. + if (!mark_seen(&seen_keys, key)) return nullptr; // Duplicate key. switch (key) { case kKeyVersion: - if (!CborReadUint(p, end, &version)) return nullptr; + if (!CborReadArgument(p, end, kCborUint, &version)) return nullptr; if (version != kExportVersion) return nullptr; break; - case kKeyLowest: { - uint64_t v; - if (!CborReadUint(p, end, &v)) return nullptr; - lowest = static_cast(v); + case kKeyLowest: + if (!CborReadInt64(p, end, &lowest)) return nullptr; break; - } - case kKeyHighest: { - uint64_t v; - if (!CborReadUint(p, end, &v)) return nullptr; - highest = static_cast(v); + case kKeyHighest: + if (!CborReadInt64(p, end, &highest)) return nullptr; break; - } - case kKeyFigures: { - uint64_t v; - if (!CborReadUint(p, end, &v)) return nullptr; - figures = static_cast(v); + case kKeyFigures: + if (!CborReadInt32(p, end, &figures)) return nullptr; break; - } - case kKeyTotalCount: { - uint64_t v; - if (!CborReadUint(p, end, &v)) return nullptr; - total_count = static_cast(v); + case kKeyTotalCount: + if (!CborReadInt64(p, end, &total_count)) return nullptr; break; - } - case kKeyMin: { - uint64_t v; - if (!CborReadUint(p, end, &v)) return nullptr; - min_value = static_cast(v); + case kKeyMin: + if (!CborReadInt64(p, end, &min_value)) return nullptr; break; - } - case kKeyMax: { - uint64_t v; - if (!CborReadUint(p, end, &v)) return nullptr; - max_value = static_cast(v); + case kKeyMax: + if (!CborReadInt64(p, end, &max_value)) return nullptr; break; - } - case kKeyNormOffset: { - uint64_t v; - if (!CborReadUint(p, end, &v)) return nullptr; - // Reject values that cannot be represented as int32_t; the - // static_cast below would wrap and produce an arbitrary offset. - if (v > static_cast(std::numeric_limits::max())) - return nullptr; - norm_offset = static_cast(v); + case kKeyNormOffset: + // Reject values that cannot be represented as int32_t; casting them + // would wrap and produce an arbitrary offset. + if (!CborReadInt32(p, end, &norm_offset)) return nullptr; break; - } case kKeyConvRatio: if (!CborReadNumber(p, end, &conv_ratio)) return nullptr; break; - case kKeyCountsLen: { - uint64_t v; - if (!CborReadUint(p, end, &v)) return nullptr; - counts_len = static_cast(v); + case kKeyCountsLen: + if (!CborReadInt32(p, end, &counts_len)) return nullptr; break; - } case kKeyCounts: { // Array of flat [delta, count, ...] pairs. Indices are // delta-encoded: accumulate to recover absolute indices. - if (p >= end || (*p >> 5) != 4) return nullptr; uint64_t arr_len; - if (!CborReadUint(p, end, &arr_len)) return nullptr; + if (!CborReadArgument(p, end, kCborArray, &arr_len)) return nullptr; if (arr_len % 2 != 0) return nullptr; // Each element needs at least 1 byte of CBOR encoding, so // arr_len can't exceed the remaining buffer. Without this @@ -1605,24 +1626,30 @@ std::shared_ptr Histogram::Import(const uint8_t* data, size_t len) { // reserve() to OOM-crash before the loop catches the error. if (arr_len > static_cast(end - p)) return nullptr; sparse_counts.reserve(static_cast(arr_len / 2)); - int32_t acc_idx = 0; + int64_t acc_idx = 0; for (uint64_t j = 0; j < arr_len; j += 2) { - uint64_t delta, cnt; - if (!CborReadUint(p, end, &delta)) return nullptr; - if (!CborReadUint(p, end, &cnt)) return nullptr; - acc_idx += static_cast(delta); - sparse_counts.emplace_back(acc_idx, static_cast(cnt)); + int32_t delta; + int64_t cnt; + if (!CborReadInt32(p, end, &delta)) return nullptr; + if (!CborReadInt64(p, end, &cnt)) return nullptr; + // Indices are strictly increasing, so only the first delta (the + // absolute index of the first non-empty bucket) may be zero. + if (j > 0 && delta == 0) return nullptr; + acc_idx += delta; + if (acc_idx > std::numeric_limits::max()) return nullptr; + sparse_counts.emplace_back(static_cast(acc_idx), cnt); } break; } case kKeyEwma: { // Sub-map for EWMA state. - if (p >= end || (*p >> 5) != 5) return nullptr; uint64_t sub_size; - if (!CborReadUint(p, end, &sub_size)) return nullptr; + if (!CborReadArgument(p, end, kCborMap, &sub_size)) return nullptr; for (uint64_t j = 0; j < sub_size; j++) { uint64_t sub_key; - if (!CborReadUint(p, end, &sub_key)) return nullptr; + if (!CborReadArgument(p, end, kCborUint, &sub_key)) return nullptr; + if (sub_key > kEwmaThreshold) return nullptr; // Unknown EWMA key. + if (!mark_seen(&seen_ewma_keys, sub_key)) return nullptr; switch (sub_key) { case kEwmaAlpha: if (!CborReadNumber(p, end, &ewma_alpha)) return nullptr; @@ -1636,20 +1663,13 @@ std::shared_ptr Histogram::Import(const uint8_t* data, size_t len) { case kEwmaErrorRate: if (!CborReadNumber(p, end, &ewma_error_rate)) return nullptr; break; - case kEwmaThreshold: { - uint64_t v; - if (!CborReadUint(p, end, &v)) return nullptr; - threshold = static_cast(v); + case kEwmaThreshold: + if (!CborReadInt64(p, end, &threshold)) return nullptr; break; - } - default: - return nullptr; // Unknown EWMA key. } } break; } - default: - return nullptr; // Unknown key. } } @@ -1679,16 +1699,31 @@ std::shared_ptr Histogram::Import(const uint8_t* data, size_t len) { if (norm_offset < 0 || norm_offset >= counts_len) return nullptr; // Restore counts directly. + int64_t observed_total_count = 0; for (const auto& [idx, cnt] : sparse_counts) { if (idx < 0 || idx >= counts_len) return nullptr; + // The counts must add up without overflowing int64_t. + if (cnt > std::numeric_limits::max() - observed_total_count) { + return nullptr; + } + observed_total_count += cnt; histogram->histogram_->counts[idx] = cnt; } - histogram->histogram_->total_count = total_count; - histogram->histogram_->min_value = min_value; - histogram->histogram_->max_value = max_value; histogram->histogram_->normalizing_index_offset = norm_offset; histogram->histogram_->conversion_ratio = conv_ratio; + // Derive the total count, min, and max from the counts, as + // Histogram::Subtract() does. This keeps the histogram consistent when + // any of them are absent. A total count that is present must match the + // counts. Min and max values that are present are restored as recorded. + hdr_reset_internal_counters(histogram->histogram_.get()); + if (has_key(kKeyTotalCount) && + total_count != histogram->histogram_->total_count) { + return nullptr; + } + if (has_key(kKeyMin)) histogram->histogram_->min_value = min_value; + if (has_key(kKeyMax)) histogram->histogram_->max_value = max_value; + // Restore EWMA state. if (ewma_alpha > 0) { histogram->ewma_mean_ = ewma_mean; diff --git a/test/parallel/test-perf-hooks-histogram-import.js b/test/parallel/test-perf-hooks-histogram-import.js new file mode 100644 index 000000000000..e4079f2d3614 --- /dev/null +++ b/test/parallel/test-perf-hooks-histogram-import.js @@ -0,0 +1,141 @@ +'use strict'; + +// Tests validation of the CBOR payload accepted by importHistogram(). + +require('../common'); +const assert = require('node:assert'); +const { createHistogram, importHistogram } = require('node:perf_hooks'); + +// Minimal CBOR (RFC 8949) encoding helpers for building payloads. Each +// helper returns an array of bytes. +function head(major, value) { + const v = BigInt(value); + const m = major << 5; + if (v < 24n) return [m | Number(v)]; + const bytes = []; + const width = v < 0x100n ? 1 : v < 0x10000n ? 2 : v < 0x100000000n ? 4 : 8; + for (let i = width - 1; i >= 0; i--) { + bytes.push(Number((v >> BigInt(i * 8)) & 0xffn)); + } + return [m | { 1: 24, 2: 25, 4: 26, 8: 27 }[width], ...bytes]; +} +const uint = (value) => head(0, value); +const negint = (value) => head(1, value); // Encodes -1 - value. +const text = (str) => [...head(3, Buffer.byteLength(str)), ...Buffer.from(str)]; +function f64(value) { + const buf = Buffer.alloc(9); + buf[0] = 0xfb; + buf.writeDoubleBE(value, 1); + return [...buf]; +} + +function array(items) { + const out = head(4, items.length); + for (const item of items) out.push(...item); + return out; +} + +function map(entries) { + const out = head(5, entries.length); + for (const { 0: key, 1: value } of entries) out.push(...key, ...value); + return out; +} +const importBytes = (bytes) => importHistogram(new Uint8Array(bytes)); +const kInvalid = { code: 'ERR_INVALID_ARG_VALUE' }; + +// lowest=1 (the default), highest=100, figures=1 produces counts_len=64, with +// indexes below 32 mapping to the identical values. +const kLayout = [ + [uint(2), uint(100)], // highest + [uint(3), uint(1)], // figures + [uint(9), uint(64)], // counts length +]; +const counts = (...pairs) => [uint(10), array(pairs.map((v) => uint(v)))]; + +{ + // Absent total count, min, and max are derived from the counts. + const h = importBytes(map([...kLayout, counts(5, 2, 3, 1)])); + assert.strictEqual(h.count, 3); + assert.strictEqual(h.min, 5); + assert.strictEqual(h.max, 8); + assert.strictEqual(h.percentile(50), 5); + assert.strictEqual(h.percentile(100), 8); +} + +{ + // A total count that is present must match the counts. + const h = importBytes(map([...kLayout, [uint(4), uint(3)], counts(5, 2, 3, 1)])); + assert.strictEqual(h.count, 3); + assert.throws( + () => importBytes(map([...kLayout, [uint(4), uint(4)], counts(5, 2, 3, 1)])), + kInvalid); + assert.throws( + () => importBytes(map([...kLayout, [uint(4), uint(1)]])), + kInvalid); +} + +{ + // Min and max values that are present are restored as recorded. + const h = createHistogram(); + h.record(987654321); + h.record(1234567891); + const h2 = importHistogram(h.export()); + assert.strictEqual(h2.min, h.min); + assert.strictEqual(h2.max, h.max); +} + +// Duplicate keys are rejected. +assert.throws(() => importBytes(map([...kLayout, [uint(3), uint(1)]])), + kInvalid); +assert.throws( + () => importBytes(map([...kLayout, counts(5, 2), counts(6, 7)])), + kInvalid); +assert.throws(() => importBytes(map([ + ...kLayout, + [uint(11), map([[uint(0), f64(0.5)], [uint(0), f64(0.5)]])], +])), kInvalid); + +// Keys must be unsigned integers. +assert.throws(() => importBytes(map([...kLayout, [text('a'), uint(1)]])), + kInvalid); +assert.throws(() => importBytes(map([...kLayout, [negint(0), uint(1)]])), + kInvalid); + +// Values must have the expected CBOR types. +assert.throws(() => importBytes(map([ + [uint(2), uint(100)], [uint(3), text('1')], [uint(9), uint(64)], +])), kInvalid); +assert.throws(() => importBytes(map([ + [uint(2), negint(99)], [uint(3), uint(1)], [uint(9), uint(64)], +])), kInvalid); +assert.throws(() => importBytes(map([...kLayout, [uint(10), map([])]])), + kInvalid); +assert.throws(() => importBytes(map([...kLayout, [uint(11), array([])]])), + kInvalid); + +// Values must not be truncated when casting to the field's type. +assert.throws(() => importBytes(map([ + [uint(2), uint(100)], [uint(3), uint(1)], [uint(9), uint(2n ** 32n + 64n)], +])), kInvalid); +assert.throws(() => importBytes(map([ + [uint(2), uint(100)], [uint(3), uint(2n ** 32n + 1n)], [uint(9), uint(64)], +])), kInvalid); +assert.throws(() => importBytes(map([...kLayout, counts(5, 2n ** 63n)])), + kInvalid); +assert.throws(() => importBytes(map([...kLayout, counts(2n ** 31n, 1)])), + kInvalid); + +// Counts must not overflow when added up. +assert.throws(() => importBytes(map([ + ...kLayout, + counts(1, 2n ** 62n, 1, 2n ** 62n, 1, 2n ** 62n), +])), kInvalid); + +{ + // Sparse count indexes must be strictly increasing. Only the first delta, + // which is an absolute index, may be zero. + const h = importBytes(map([...kLayout, counts(0, 1, 5, 1)])); + assert.strictEqual(h.count, 2); + assert.throws(() => importBytes(map([...kLayout, counts(5, 2, 0, 1)])), + kInvalid); +} From b020abb18f46849e384a802a81ddf86b0ed36fdb Mon Sep 17 00:00:00 2001 From: James M Snell Date: Fri, 18 Sep 2026 02:11:33 +0000 Subject: [PATCH 056/228] perf_hooks: add histogram.snapshot() Cloning a histogram with `structuredClone()` or `postMessage()` shares the native histogram instead of copying it. Capturing the state of a histogram at a point in time, while other code keeps recording into it, requires serializing it with `export()` and parsing the result with `importHistogram()`. Add `histogram.snapshot()`, which returns a new, independent `Histogram` containing a copy of the histogram's configuration, recorded values, `exceeds` count, and EWMA state, without the serialization round trip. Values cannot be recorded into the returned histogram. The method is available on all histograms, including `RecordableHistogram` and `ELDHistogram` instances. Signed-off-by: James M Snell Assisted-by: OpenCode PR-URL: https://github.com/nodejs/node/pull/66099 Reviewed-By: Matteo Collina --- benchmark/perf_hooks/histogram-snapshot.js | 24 +++ doc/api/perf_hooks.md | 32 ++++ lib/internal/histogram.js | 12 ++ src/histogram.cc | 69 +++++++-- src/histogram.h | 5 + .../test-perf-hooks-histogram-snapshot.js | 138 ++++++++++++++++++ typings/internalBinding/performance.d.ts | 1 + 7 files changed, 271 insertions(+), 10 deletions(-) create mode 100644 benchmark/perf_hooks/histogram-snapshot.js create mode 100644 test/parallel/test-perf-hooks-histogram-snapshot.js diff --git a/benchmark/perf_hooks/histogram-snapshot.js b/benchmark/perf_hooks/histogram-snapshot.js new file mode 100644 index 000000000000..79b13fecb0f6 --- /dev/null +++ b/benchmark/perf_hooks/histogram-snapshot.js @@ -0,0 +1,24 @@ +'use strict'; + +const assert = require('assert'); +const common = require('../common.js'); +const { createHistogram } = require('perf_hooks'); + +const bench = common.createBenchmark(main, { + n: [1e3], + highest: [1e6, Number.MAX_SAFE_INTEGER], + figures: [2, 3], +}); + +let snapshot; + +function main({ n, highest, figures }) { + const histogram = createHistogram({ highest, figures }); + for (let i = 1; i <= 1e4; i++) histogram.record(i); + + bench.start(); + for (let i = 0; i < n; i++) snapshot = histogram.snapshot(); + bench.end(n); + + assert.strictEqual(snapshot.count, 1e4); +} diff --git a/doc/api/perf_hooks.md b/doc/api/perf_hooks.md index ca25c38ffdf2..028d6e0832a7 100644 --- a/doc/api/perf_hooks.md +++ b/doc/api/perf_hooks.md @@ -2618,6 +2618,38 @@ distribution. A positive value indicates a right-skewed distribution (longer right tail, common for latency data); a negative value indicates a left-skewed distribution. +### `histogram.snapshot()` + + + +* Returns: {Histogram} + +Returns a new, independent {Histogram} containing a copy of this histogram's +current state: its configuration, recorded values, `exceeds` count, and EWMA +state. Values recorded into this histogram after this method returns, and later +calls to `reset()`, do not change the returned histogram. This provides a stable +view of a histogram that is still recording, such as an enabled {ELDHistogram}. + +Values cannot be recorded into the returned histogram. Taking a snapshot copies +every bucket, so both its time and memory cost depend on the histogram's +`lowest`, `highest`, and `figures` configuration rather than on the number of +recorded values. + +```js +const { monitorEventLoopDelay } = require('node:perf_hooks'); + +const histogram = monitorEventLoopDelay(); +histogram.enable(); + +setTimeout(() => { + const snapshot = histogram.snapshot(); + console.log(snapshot.percentile(99)); + histogram.disable(); +}, 1000); +``` + ### `histogram.stddev` + +* `other` {Histogram} An earlier snapshot of this histogram. +* Returns: {Histogram} + +Returns a new {Histogram} containing the values recorded in this histogram after +`other` was taken. Neither histogram is changed. To get the values recorded +during each interval without calling `reset()`, compute each difference from a +snapshot and keep that snapshot as the baseline for the next interval: + +```js +const { monitorEventLoopDelay } = require('node:perf_hooks'); + +const histogram = monitorEventLoopDelay(); +histogram.enable(); +let previous = histogram.snapshot(); + +setInterval(() => { + const current = histogram.snapshot(); + // After a reset, use everything recorded since the reset. + const delta = current.resetCount === previous.resetCount ? + current.diff(previous) : current; + console.log(delta.percentile(99)); + previous = current; +}, 10_000); +``` + +The `count`, `exceeds`, and bucket counts of the returned histogram are the +differences between the two histograms. Its `min` and `max` are computed from +the buckets of the difference, it has no EWMA state, and its `resetCount` is +`0`. + +This method throws: + +* `ERR_INVALID_ARG_VALUE` if `other` has a different `lowest`, `highest`, or + `figures` configuration. +* `ERR_INVALID_STATE` if values have been removed from this histogram since + `other` was taken, which is the case when the `resetCount` of the two + histograms differs. +* `ERR_INVALID_ARG_VALUE` if `other` contains values that are not in this + histogram, for example because the histograms were passed in the wrong order. + ### `histogram.exceeds` -Resets the collected histogram data. +Resets the collected histogram data and increments `histogram.resetCount`. + +### `histogram.resetCount` + + + +* Type: {number} + +The number of times values have been removed from this histogram by `reset()` +or, for a {RecordableHistogram}, `subtract()`. A snapshot has the `resetCount` +of its source at the time it was taken, so comparing the `resetCount` of two +snapshots shows whether the source was reset between them. See +[`histogram.diff()`][]. ### `histogram.skewness` @@ -2801,7 +2861,7 @@ added: v24.21.0 Subtracts the values of `other` from this histogram. Both histograms should have compatible configurations. Bucket counts that would become negative -are clamped to zero. +are clamped to zero. Increments `histogram.resetCount`. ## Class: `SlidingWindowHistogram` @@ -3280,6 +3340,7 @@ dns.promises.resolve('localhost'); [Worker threads]: worker_threads.md#worker-threads [`'exit'`]: process.md#event-exit [`child_process.spawnSync()`]: child_process.md#child_processspawnsynccommand-args-options +[`histogram.diff()`]: #histogramdiffother [`histogram.export()`]: #histogramexport [`perf_hooks.createSlidingWindowHistogram()`]: #perf_hookscreateslidingwindowhistogramoptions [`perf_hooks.eventLoopUtilization()`]: #perf_hookseventlooputilizationutilization1-utilization2 diff --git a/lib/internal/histogram.js b/lib/internal/histogram.js index 8983289e0753..12c888c54217 100644 --- a/lib/internal/histogram.js +++ b/lib/internal/histogram.js @@ -679,6 +679,18 @@ class Histogram { this[kHandle]?.reset(); } + /** + * The number of times values have been removed from the histogram by + * `reset()` or `subtract()`. + * @readonly + * @type {number} + */ + get resetCount() { + if (!isHistogram(this)) + throw new ERR_INVALID_THIS('Histogram'); + return this[kHandle]?.resetCount(); + } + /** * Returns a new, independent histogram containing a copy of this * histogram's current state. Values cannot be recorded into the returned @@ -691,6 +703,20 @@ class Histogram { return new ClonedHistogram(this[kHandle].snapshot()); } + /** + * Returns a new histogram containing the values recorded in this histogram + * after `other`, an earlier snapshot of it, was taken. + * @param {Histogram} other + * @returns {Histogram} + */ + diff(other) { + if (!isHistogram(this)) + throw new ERR_INVALID_THIS('Histogram'); + if (!isHistogram(other)) + throw new ERR_INVALID_ARG_TYPE('other', 'Histogram', other); + return new ClonedHistogram(this[kHandle].diff(other[kHandle])); + } + [kClone]() { const handle = this[kHandle]; return { diff --git a/src/histogram-inl.h b/src/histogram-inl.h index e2704b499f31..87f92c492efb 100644 --- a/src/histogram-inl.h +++ b/src/histogram-inl.h @@ -42,6 +42,7 @@ void Histogram::Reset() { RwLock::ScopedWriteLock lock(mutex_); hdr_reset(histogram_.get()); InvalidateRecordedSnapshot(); + reset_count_++; exceeds_ = 0; prev_ = 0; ewma_mean_ = 0; @@ -90,6 +91,11 @@ size_t Histogram::Exceeds() const { return exceeds_; } +uint64_t Histogram::ResetCount() const { + RwLock::ScopedReadLock lock(mutex_); + return reset_count_; +} + int64_t Histogram::Min() const { RwLock::ScopedReadLock lock(mutex_); return hdr_min(histogram_.get()); diff --git a/src/histogram.cc b/src/histogram.cc index e07fdc12623d..9ad3ce0ad59a 100644 --- a/src/histogram.cc +++ b/src/histogram.cc @@ -93,17 +93,22 @@ void CopyRecordedData(hdr_histogram* target, const hdr_histogram* source) { } } // namespace -std::shared_ptr Histogram::Clone() const { - // The layout is fixed when the histogram is created, so the copy can be - // allocated without holding the lock. - hdr_histogram* copy; +std::shared_ptr Histogram::CreateWithSameLayout() const { + // The layout is fixed when the histogram is created, so it can be read + // without holding the lock. + hdr_histogram* histogram; if (hdr_init(histogram_->lowest_discernible_value, histogram_->highest_trackable_value, histogram_->significant_figures, - ©) != 0) { + &histogram) != 0) { return {}; } - auto clone = std::make_shared(HistogramPointer(copy), Options{}); + return std::make_shared(HistogramPointer(histogram), Options{}); +} + +std::shared_ptr Histogram::Clone() const { + std::shared_ptr clone = CreateWithSameLayout(); + if (!clone) return {}; // Every member that holds recorded or statistical state must be copied // here. The recorded snapshot cache is not copied; the clone builds its own @@ -112,6 +117,7 @@ std::shared_ptr Histogram::Clone() const { CopyRecordedData(clone->histogram_.get(), histogram_.get()); clone->prev_ = prev_; clone->exceeds_ = exceeds_; + clone->reset_count_ = reset_count_; clone->ewma_alpha_ = ewma_alpha_; clone->ewma_mean_ = ewma_mean_; clone->ewma_variance_ = ewma_variance_; @@ -121,6 +127,59 @@ std::shared_ptr Histogram::Clone() const { return clone; } +std::shared_ptr Histogram::Diff(const Histogram& other, + DiffError* error) const { + // Counts are subtracted index by index, so both histograms must map values + // to the same indexes. None of these fields change after creation. + if (!IsCompatible(other) || histogram_->normalizing_index_offset != + other.histogram_->normalizing_index_offset) { + *error = DiffError::kIncompatible; + return {}; + } + + std::shared_ptr diff = CreateWithSameLayout(); + if (!diff) { + *error = DiffError::kOutOfMemory; + return {}; + } + + // Only the recorded values and the exceeds count carry over. EWMA and timing + // state cannot be subtracted. + uint64_t reset_count; + { + RwLock::ScopedReadLock lock(mutex_); + CopyRecordedData(diff->histogram_.get(), histogram_.get()); + diff->exceeds_ = exceeds_; + reset_count = reset_count_; + } + + // `diff` is not shared yet, so only the lock of `other` is needed from here + // on. Never holding both locks at once avoids lock ordering issues. + RwLock::ScopedReadLock lock(other.mutex_); + if (reset_count != other.reset_count_) { + *error = DiffError::kReset; + return {}; + } + if (diff->exceeds_ < other.exceeds_) { + *error = DiffError::kNotEarlier; + return {}; + } + + hdr_histogram* target = diff->histogram_.get(); + const hdr_histogram* source = other.histogram_.get(); + for (int32_t i = 0; i < target->counts_len; i++) { + if (target->counts[i] < source->counts[i]) { + *error = DiffError::kNotEarlier; + return {}; + } + target->counts[i] -= source->counts[i]; + } + diff->exceeds_ -= other.exceeds_; + hdr_reset_internal_counters(target); + *error = DiffError::kNone; + return diff; +} + void Histogram::MemoryInfo(MemoryTracker* tracker) const { tracker->TrackFieldWithSize("histogram", GetMemorySize()); tracker->TrackFieldWithSize("qrde_snapshot", @@ -313,6 +372,7 @@ double Histogram::Subtract(const Histogram& other) { } hdr_reset_internal_counters(histogram_.get()); InvalidateRecordedSnapshot(); + reset_count_++; exceeds_ = (exceeds_ > other.exceeds_) ? exceeds_ - other.exceeds_ : 0; return static_cast(dropped); }; @@ -1879,6 +1939,8 @@ void HistogramImpl::AddMethods(Isolate* isolate, Local tmpl) { &fast_get_ewma_error_rate_); SetProtoMethodNoSideEffect(isolate, tmpl, "export", DoExport); SetProtoMethodNoSideEffect(isolate, tmpl, "snapshot", DoSnapshot); + SetProtoMethodNoSideEffect(isolate, tmpl, "diff", DoDiff); + SetProtoMethodNoSideEffect(isolate, tmpl, "resetCount", GetResetCount); SetFastMethod(isolate, instance, "reset", DoReset, &fast_reset_); } @@ -1929,6 +1991,8 @@ void HistogramImpl::RegisterExternalReferences( registry->Register(GetEwmaErrorRate); registry->Register(DoExport); registry->Register(DoSnapshot); + registry->Register(DoDiff); + registry->Register(GetResetCount); registry->Register(fast_get_ewma_mean_); registry->Register(fast_get_ewma_stddev_); registry->Register(fast_get_ewma_error_rate_); @@ -3069,6 +3133,39 @@ void HistogramImpl::DoSnapshot(const FunctionCallbackInfo& args) { if (result) args.GetReturnValue().Set(result->object()); } +void HistogramImpl::DoDiff(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This()); + HistogramImpl* other = HistogramImpl::FromJSObject(args[0]); + Histogram::DiffError error; + std::shared_ptr diff = + (*histogram)->Diff(*(other->histogram()), &error); + switch (error) { + case Histogram::DiffError::kNone: + break; + case Histogram::DiffError::kOutOfMemory: + return THROW_ERR_MEMORY_ALLOCATION_FAILED(env); + case Histogram::DiffError::kIncompatible: + return THROW_ERR_INVALID_ARG_VALUE( + env, "other must have the same configuration as the histogram"); + case Histogram::DiffError::kReset: + return THROW_ERR_INVALID_STATE( + env, "Values were removed from the histogram after other was taken"); + case Histogram::DiffError::kNotEarlier: + return THROW_ERR_INVALID_ARG_VALUE( + env, "other contains values that are not in the histogram"); + } + + BaseObjectPtr result = + HistogramBase::Create(env, std::move(diff)); + if (result) args.GetReturnValue().Set(result->object()); +} + +void HistogramImpl::GetResetCount(const FunctionCallbackInfo& args) { + HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This()); + args.GetReturnValue().Set(static_cast((*histogram)->ResetCount())); +} + void HistogramImpl::GetPercentilesAt(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This()); diff --git a/src/histogram.h b/src/histogram.h index f526e9cb1cc3..8aaf9f3c94d4 100644 --- a/src/histogram.h +++ b/src/histogram.h @@ -65,6 +65,23 @@ class Histogram : public MemoryRetainer { // if the copy cannot be allocated. std::shared_ptr Clone() const; + enum class DiffError { + kNone, + kOutOfMemory, + // `other` has a different layout. + kIncompatible, + // Values were removed from this histogram after `other` was taken. + kReset, + // `other` contains values that this histogram does not. + kNotEarlier, + }; + + // Returns a new histogram containing the values recorded in this histogram + // after `other`, an earlier copy of it, was taken. Returns nullptr and sets + // `error` if the difference cannot be computed. + std::shared_ptr Diff(const Histogram& other, + DiffError* error) const; + Histogram(HistogramPointer histogram, const Options& options); virtual ~Histogram() = default; @@ -80,6 +97,7 @@ class Histogram : public MemoryRetainer { inline int64_t Percentile(double percentile) const; inline size_t Exceeds() const; inline size_t Count() const; + inline uint64_t ResetCount() const; inline uint64_t RecordDelta(); @@ -165,10 +183,13 @@ class Histogram : public MemoryRetainer { inline void UpdateEwma(double value); inline void InvalidateRecordedSnapshot(); size_t GetCachedRecordedSnapshotMemorySize() const; + std::shared_ptr CreateWithSameLayout() const; HistogramPointer histogram_; uint64_t prev_ = 0; size_t exceeds_ = 0; + // Incremented whenever recorded values are removed by Reset() or Subtract(). + uint64_t reset_count_ = 0; // EWMA state (active when ewma_alpha_ > 0) double ewma_alpha_ = 0; @@ -242,6 +263,8 @@ class HistogramImpl { static void DoExport(const v8::FunctionCallbackInfo& args); static void DoImport(const v8::FunctionCallbackInfo& args); static void DoSnapshot(const v8::FunctionCallbackInfo& args); + static void DoDiff(const v8::FunctionCallbackInfo& args); + static void GetResetCount(const v8::FunctionCallbackInfo& args); static void FastReset(v8::Local receiver); static double FastGetCount(v8::Local receiver); diff --git a/test/parallel/test-perf-hooks-histogram-diff.js b/test/parallel/test-perf-hooks-histogram-diff.js new file mode 100644 index 000000000000..45dc375d2d69 --- /dev/null +++ b/test/parallel/test-perf-hooks-histogram-diff.js @@ -0,0 +1,169 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { setTimeout: delay } = require('timers/promises'); +const { + createHistogram, + createSlidingWindowHistogram, + importHistogram, + monitorEventLoopDelay, +} = require('perf_hooks'); + +{ + const histogram = createHistogram({ highest: 1000, halfLife: 4, threshold: 50 }); + for (let i = 1; i <= 10; i++) histogram.record(i); + histogram.record(2000); + const previous = histogram.snapshot(); + for (let i = 101; i <= 120; i++) histogram.record(i); + histogram.record(3000); + histogram.record(4000); + const current = histogram.snapshot(); + const exportedPrevious = previous.export(); + const exportedCurrent = current.export(); + + const delta = current.diff(previous); + assert.strictEqual(delta.constructor.name, 'Histogram'); + assert.strictEqual(delta.record, undefined); + assert.strictEqual(delta.count, 20); + assert.strictEqual(delta.exceeds, 2); + assert.strictEqual(delta.min, 101); + assert.strictEqual(delta.max, 120); + assert.strictEqual(delta.resetCount, 0); + + // The difference has the same distribution as a histogram of the values + // recorded between the snapshots, and no EWMA state. + const expected = createHistogram({ highest: 1000 }); + for (let i = 101; i <= 120; i++) expected.record(i); + assert.deepStrictEqual(delta.percentiles, expected.percentiles); + assert.strictEqual(delta.ewmaMean, 0); + assert.strictEqual(delta.ewmaStddev, 0); + assert.strictEqual(delta.ewmaErrorRate, 0); + + // Neither histogram is changed. + assert.deepStrictEqual(previous.export(), exportedPrevious); + assert.deepStrictEqual(current.export(), exportedCurrent); + + assert.strictEqual(histogram.diff(previous).count, 20); + assert.strictEqual(histogram.diff(histogram).count, 0); + assert.strictEqual(current.diff(current).count, 0); + + // Reversed arguments. + assert.throws(() => previous.diff(current), { + code: 'ERR_INVALID_ARG_VALUE', + }); +} + +{ + // Consumers with different intervals each keep their own previous snapshot. + const histogram = createHistogram(); + const consumers = [3, 10].map((interval) => ({ + interval, + previous: histogram.snapshot(), + pending: 0, + total: 0, + })); + for (let i = 1; i <= 100; i++) { + histogram.record(i); + for (const consumer of consumers) { + consumer.pending++; + if (i % consumer.interval !== 0) continue; + const current = histogram.snapshot(); + const delta = current.diff(consumer.previous); + assert.strictEqual(delta.count, consumer.pending); + assert.strictEqual(delta.min, i - consumer.pending + 1); + assert.strictEqual(delta.max, i); + consumer.total += delta.count; + consumer.previous = current; + consumer.pending = 0; + } + } + assert.strictEqual(consumers[0].total, 99); + assert.strictEqual(consumers[1].total, 100); +} + +{ + const histogram = createHistogram(); + assert.strictEqual(histogram.resetCount, 0); + histogram.record(1); + histogram.recordCorrected(100, 10); + histogram.add(createHistogram()); + assert.strictEqual(histogram.resetCount, 0); + + histogram.reset(); + assert.strictEqual(histogram.resetCount, 1); + histogram.record(1); + const previous = histogram.snapshot(); + histogram.reset(); + assert.strictEqual(histogram.resetCount, 2); + assert.strictEqual(previous.resetCount, 1); + + // A reset is detected even when every count has grown past its previous + // value since. + for (let i = 0; i < 10; i++) histogram.record(1); + assert.throws(() => histogram.diff(previous), { + code: 'ERR_INVALID_STATE', + }); + + // subtract() also removes values. Subtracting an empty histogram leaves + // every count unchanged. + const snapshot = histogram.snapshot(); + assert.strictEqual(snapshot.resetCount, 2); + histogram.subtract(createHistogram()); + assert.strictEqual(histogram.resetCount, 3); + assert.throws(() => histogram.diff(snapshot), { + code: 'ERR_INVALID_STATE', + }); +} + +{ + const histogram = createHistogram(); + for (const options of [{ lowest: 2 }, { highest: 1000 }, { figures: 2 }]) { + assert.throws(() => histogram.diff(createHistogram(options)), { + code: 'ERR_INVALID_ARG_VALUE', + }); + } + + // Histograms with a different normalizing index offset map values to + // different indexes. + const data = createHistogram().export(); + const offset = Buffer.from(data).indexOf(Buffer.from([0x06, 0x00, 0x07, 0x00])); + assert.notStrictEqual(offset, -1); + data[offset + 3] = 1; + assert.throws(() => histogram.diff(importHistogram(data)), { + code: 'ERR_INVALID_ARG_VALUE', + }); + + assert.throws(() => histogram.diff.call({}, histogram), { + code: 'ERR_INVALID_THIS', + }); + const { get } = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(histogram.snapshot()), 'resetCount'); + assert.throws(() => get.call({}), { code: 'ERR_INVALID_THIS' }); + const window = createSlidingWindowHistogram({ chunks: 1, recordsPerChunk: 1 }); + for (const other of [undefined, null, {}, 1, window]) { + assert.throws(() => histogram.diff(other), { + code: 'ERR_INVALID_ARG_TYPE', + }); + } +} + +(async () => { + const histogram = monitorEventLoopDelay({ samplePerIteration: true }); + histogram.enable(); + while (histogram.count < 2) await delay(1); + const previous = histogram.snapshot(); + while (histogram.count < previous.count + 3) await delay(1); + + // Samples are only recorded while the event loop is running. + const current = histogram.snapshot(); + assert.strictEqual(current.diff(previous).count, + current.count - previous.count); + + histogram.disable(); + histogram.reset(); + assert.strictEqual(histogram.resetCount, 1); + assert.throws(() => histogram.diff(previous), { + code: 'ERR_INVALID_STATE', + }); +})().then(common.mustCall()); diff --git a/typings/internalBinding/performance.d.ts b/typings/internalBinding/performance.d.ts index 043be9e7118f..98a176ea1ac4 100644 --- a/typings/internalBinding/performance.d.ts +++ b/typings/internalBinding/performance.d.ts @@ -42,6 +42,8 @@ declare namespace InternalPerformanceBinding { ewmaStddev(): number; ewmaErrorRate(): number; snapshot(): Histogram; + diff(other: HistogramBase): Histogram; + resetCount(): number; } interface ELDHistogram extends HistogramBase { From e6aba9172047971852ea871121ab6119911a7f11 Mon Sep 17 00:00:00 2001 From: Yuya Inoue <65857152+inoway46@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:44:58 +0900 Subject: [PATCH 058/228] buffer: fix odd UTF-16LE indexOf start UTF-16LE string searches rounded odd forward start offsets down, allowing a match before the requested range. Round the start up to the next code-unit boundary while leaving reverse search and the existing 16-bit implementation unchanged. Assisted-by: Codex Signed-off-by: inoway46 PR-URL: https://github.com/nodejs/node/pull/65960 Fixes: https://github.com/nodejs/node/issues/26448 Reviewed-By: Robert Nagy Reviewed-By: James M Snell --- src/node_buffer.cc | 4 ++++ test/parallel/test-buffer-indexof.js | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/src/node_buffer.cc b/src/node_buffer.cc index bc0f03fdc8f3..312430f3f3f2 100644 --- a/src/node_buffer.cc +++ b/src/node_buffer.cc @@ -1027,6 +1027,10 @@ void IndexOfString(const FunctionCallbackInfo& args) { } else if (is_forward && offset >= search_end) { return args.GetReturnValue().Set(-1); } + if (enc == UCS2 && is_forward) { + offset += offset % sizeof(uint16_t); + if (offset >= search_end) return args.GetReturnValue().Set(-1); + } CHECK_LT(offset, haystack_length); if ((is_forward && needle_length + offset > search_end) || needle_length > search_end) { diff --git a/test/parallel/test-buffer-indexof.js b/test/parallel/test-buffer-indexof.js index 2dd62c91e25c..bb361e10d062 100644 --- a/test/parallel/test-buffer-indexof.js +++ b/test/parallel/test-buffer-indexof.js @@ -308,6 +308,12 @@ assert.strictEqual(Buffer.from('aaaa').indexOf('你好', 'ucs2'), -1); // Haystack has odd length, but the needle is UCS2. assert.strictEqual(Buffer.from('aaaaa').indexOf('b', 'ucs2'), -1); +{ + const buf = Buffer.from('\u6881\u6882\u6881', 'utf16le'); + assert.strictEqual(buf.indexOf('\u6881', 1, 'utf16le'), 4); + assert.strictEqual(buf.indexOf('\u6881', -1, 'utf16le'), -1); +} + { // Find substrings in Utf8. const lengths = [1, 3, 15]; // Single char, simple and complex. From 6da3ce32cba70113b3181facf5de9205f2cb5abc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9?= Date: Fri, 25 Sep 2026 11:23:35 +0100 Subject: [PATCH 059/228] doc: add indexed `Array.prototype` properties to primordial exclusions Signed-off-by: Renegade334 PR-URL: https://github.com/nodejs/node/pull/66234 Reviewed-By: Antoine du Hamel Reviewed-By: Filip Skokan Reviewed-By: Marco Ippolito Reviewed-By: Luigi Pinca --- doc/contributing/primordials.md | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/doc/contributing/primordials.md b/doc/contributing/primordials.md index 4b143c485ff9..ec64e028197c 100644 --- a/doc/contributing/primordials.md +++ b/doc/contributing/primordials.md @@ -105,7 +105,9 @@ There are some built-in functions that accept a variable number of arguments the list of arguments as an array. You can use primordial function with the suffix `Apply` (e.g.: `MathMaxApply`, `ArrayPrototypePushApply`) to do that. -## Primordials with known performance issues +## Limitations and exclusions + +### Primordials with known performance issues One of the reasons why the current Node.js API is not completely tamper-proof is performance: sometimes the use of primordials can cause performance regressions @@ -137,6 +139,32 @@ performance of code in Node.js. In general, when sending or reviewing a PR that makes changes in a hot code path, use extra caution and run extensive benchmarks. +### User-defined index properties on `Array.prototype` + +A recurring theme in issues/PRs is that changing the behavior of indexed +array properties via `Array.prototype` breaks a particular Node.js API. + + + +```js +// User-land +Object.defineProperty( + Array.prototype, + '0', + { set() {} }, +); + +// Core +const array = []; +ArrayPrototypePush(array, 'some value'); +console.log(array[0]); // undefined +``` + +These patterns are expected to break both ECMAScript builtins and application +code, and Node.js does not make any attempt to harden against them. To do so +would necessitate using property descriptor methods for every single array +interaction, which is not practical. + ## Implicit use of user-mutable methods ### Unsafe array iteration From 295e8065da8c17658b051eacaa9f8e543e4ac24d Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 22 Sep 2026 22:12:55 +0200 Subject: [PATCH 060/228] crypto: match algorithm names as ASCII Reject non-ASCII equivalents of registered Web Crypto algorithm names. Skip the character scan when the input already uses the canonical name. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66237 Reviewed-By: James M Snell Reviewed-By: Aviv Keller --- lib/internal/crypto/util.js | 10 ++++++++ .../test-webcrypto-algorithm-name-ascii.js | 24 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 test/parallel/test-webcrypto-algorithm-name-ascii.js diff --git a/lib/internal/crypto/util.js b/lib/internal/crypto/util.js index 7fe6d488f57c..e4bd391a2f23 100644 --- a/lib/internal/crypto/util.js +++ b/lib/internal/crypto/util.js @@ -22,6 +22,7 @@ const { PromiseWithResolvers, SafeMap, SafeSet, + StringPrototypeCharCodeAt, StringPrototypeToUpperCase, Symbol, TypedArrayPrototypeGetBuffer, @@ -757,6 +758,15 @@ function normalizeAlgorithm(algorithm, op) { if (canonicalName === undefined) throw lazyDOMException('Unrecognized algorithm name', 'NotSupportedError'); + // Registered names are ASCII. Only check characters when case folding was + // needed, so Unicode characters such as U+017F cannot match ASCII names. + if (algName !== canonicalName) { + for (let i = 0; i < algName.length; i++) { + if (StringPrototypeCharCodeAt(algName, i) > 0x7f) + throw lazyDOMException('Unrecognized algorithm name', 'NotSupportedError'); + } + } + algName = canonicalName; const desiredType = registeredAlgorithms[algName]; diff --git a/test/parallel/test-webcrypto-algorithm-name-ascii.js b/test/parallel/test-webcrypto-algorithm-name-ascii.js new file mode 100644 index 000000000000..a1b33830884e --- /dev/null +++ b/test/parallel/test-webcrypto-algorithm-name-ascii.js @@ -0,0 +1,24 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { subtle } = globalThis.crypto; + +(async () => { + const data = new Uint8Array(0); + assert.deepStrictEqual(await subtle.digest('sha-256', data), + await subtle.digest('SHA-256', data)); + for (const name of ['\u017fha-256', 'SHA-256\u0131']) { + await assert.rejects(subtle.digest(name, data), { name: 'NotSupportedError' }); + assert.strictEqual(SubtleCrypto.supports('digest', name), false); + } + await assert.rejects(subtle.generateKey({ name: 'AE\u017f-GCM', length: 128 }, + false, ['encrypt']), + { name: 'NotSupportedError' }); + await assert.rejects(subtle.importKey('raw-secret', data, 'Argon2\u0131d', + false, ['deriveBits']), + { name: 'NotSupportedError' }); +})().then(common.mustCall()); From f60e7c6b36d7beade033d9f1288a3130fb9c21ba Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 22 Sep 2026 22:12:55 +0200 Subject: [PATCH 061/228] crypto: reject short AES-KW inputs Enforce the minimum key-wrap input lengths before the empty-input shortcut, including the integrity-check block when unwrapping. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66237 Reviewed-By: James M Snell Reviewed-By: Aviv Keller --- src/crypto/crypto_aes.cc | 9 +++- .../test-webcrypto-aes-kw-short-input.js | 49 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 test/parallel/test-webcrypto-aes-kw-short-input.js diff --git a/src/crypto/crypto_aes.cc b/src/crypto/crypto_aes.cc index 171688b92926..23d811e0cd8e 100644 --- a/src/crypto/crypto_aes.cc +++ b/src/crypto/crypto_aes.cc @@ -47,6 +47,13 @@ WebCryptoCipherStatus AES_Cipher(Environment* env, ByteSource* out) { CHECK_EQ(key_data.GetKeyType(), kKeyTypeSecret); + const bool encrypt = cipher_mode == kWebCryptoCipherEncrypt; + // AES-KW requires at least two 64-bit plaintext blocks, plus the + // 64-bit integrity check value when unwrapping. + if (params.cipher.isWrapMode() && in.size() < (encrypt ? 16u : 24u)) { + return WebCryptoCipherStatus::FAILED; + } + auto ctx = CipherCtxPointer::New(); if (!ctx) { return WebCryptoCipherStatus::FAILED; @@ -56,8 +63,6 @@ WebCryptoCipherStatus AES_Cipher(Environment* env, ctx.setAllowWrap(); } - const bool encrypt = cipher_mode == kWebCryptoCipherEncrypt; - if (!ctx.init(params.cipher, encrypt)) { // Cipher init failed return WebCryptoCipherStatus::FAILED; diff --git a/test/parallel/test-webcrypto-aes-kw-short-input.js b/test/parallel/test-webcrypto-aes-kw-short-input.js new file mode 100644 index 000000000000..09fe0268ab9b --- /dev/null +++ b/test/parallel/test-webcrypto-aes-kw-short-input.js @@ -0,0 +1,49 @@ +'use strict'; + +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { getFips } = require('crypto'); +const { hasOpenSSL } = require('../common/crypto'); +const { subtle } = globalThis.crypto; + +(async () => { + const keyToWrap = await subtle.importKey( + 'raw', new Uint8Array(16), 'AES-GCM', true, ['encrypt']); + let emptyKey; + if (hasOpenSSL(3) && getFips() !== 1) { + emptyKey = await subtle.importKey( + 'raw-secret', new Uint8Array(0), 'KMAC128', true, ['sign']); + } + + for (const length of [128, 192, 256]) { + const wrappingKey = await subtle.generateKey( + { name: 'AES-KW', length }, false, ['wrapKey', 'unwrapKey']); + + for (const byteLength of [0, 8, 16, 23]) { + // HKDF accepts an empty key, so the unwrap operation must reject + // before attempting to import the plaintext as a key. + await assert.rejects(subtle.unwrapKey( + 'raw', new Uint8Array(byteLength), wrappingKey, 'AES-KW', + 'HKDF', false, ['deriveBits']), { name: 'OperationError' }); + } + + if (emptyKey !== undefined) { + await assert.rejects(subtle.wrapKey( + 'raw-secret', emptyKey, wrappingKey, 'AES-KW'), + { name: 'OperationError' }); + } + + const wrapped = await subtle.wrapKey( + 'raw', keyToWrap, wrappingKey, 'AES-KW'); + assert.strictEqual(wrapped.byteLength, 24); + const unwrapped = await subtle.unwrapKey( + 'raw', wrapped, wrappingKey, 'AES-KW', 'AES-GCM', true, ['encrypt']); + assert.deepStrictEqual( + new Uint8Array(await subtle.exportKey('raw', unwrapped)), + new Uint8Array(16)); + } +})().then(common.mustCall()); From aa5fa2043463c5d734c16e9dd08411873e2ace69 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 22 Sep 2026 22:13:37 +0200 Subject: [PATCH 062/228] crypto: isolate cipher job errors Use only errors captured by the cipher job when converting its result. Unrelated errors on the event loop thread must not turn a successful operation into a failure or trigger an assertion. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66237 Reviewed-By: James M Snell Reviewed-By: Aviv Keller --- src/crypto/crypto_cipher.h | 7 +-- .../test-webcrypto-cipher-unrelated-errors.js | 45 +++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) create mode 100644 test/parallel/test-webcrypto-cipher-unrelated-errors.js diff --git a/src/crypto/crypto_cipher.h b/src/crypto/crypto_cipher.h index f6deb774ea36..8817171a2be4 100644 --- a/src/crypto/crypto_cipher.h +++ b/src/crypto/crypto_cipher.h @@ -219,6 +219,7 @@ class CipherJob final : public CryptoJob { WebCryptoCipherMode cipher_mode() const { return cipher_mode_; } void DoThreadPoolWork() override { + ncrypto::ClearErrorOnReturn clear_error_on_return; const WebCryptoCipherStatus status = CipherTraits::DoCipher( AsyncWrap::env(), @@ -253,11 +254,7 @@ class CipherJob final : public CryptoJob { Environment* env = AsyncWrap::env(); CryptoErrorStore* errors = CryptoJob::errors(); - if (errors->Empty()) - errors->Capture(); - - if (out_.size() > 0 || errors->Empty()) { - CHECK(errors->Empty()); + if (errors->Empty()) { *err = v8::Undefined(env->isolate()); *result = out_.ToArrayBuffer(env); if (result->IsEmpty()) { diff --git a/test/parallel/test-webcrypto-cipher-unrelated-errors.js b/test/parallel/test-webcrypto-cipher-unrelated-errors.js new file mode 100644 index 000000000000..258733b0440e --- /dev/null +++ b/test/parallel/test-webcrypto-cipher-unrelated-errors.js @@ -0,0 +1,45 @@ +'use strict'; + +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { scrypt, scryptSync } = require('crypto'); +const { subtle } = globalThis.crypto; + +if (typeof scryptSync !== 'function') + common.skip('no scrypt support'); + +function failScrypt(sync) { + // The failed parameter check leaves an error on the main thread's + // OpenSSL error queue. Cipher jobs must only use their own errors. + assert.throws(() => { + if (sync) { + scryptSync('password', 'salt', 64, { N: 2 ** 17 }); + } else { + scrypt('password', 'salt', 64, { N: 2 ** 17 }, common.mustNotCall()); + } + }, { code: 'ERR_CRYPTO_INVALID_SCRYPT_PARAMS' }); +} + +(async () => { + const key = await subtle.importKey( + 'raw', new Uint8Array(16), 'AES-GCM', false, ['encrypt', 'decrypt']); + const algorithm = { name: 'AES-GCM', iv: new Uint8Array(12) }; + + for (const sync of [true, false]) { + for (const byteLength of [0, 16]) { + const plaintext = new Uint8Array(byteLength); + failScrypt(sync); + const ciphertext = await subtle.encrypt(algorithm, key, plaintext); + assert.strictEqual(ciphertext.byteLength, byteLength + 16); + + failScrypt(sync); + assert.deepStrictEqual( + new Uint8Array(await subtle.decrypt(algorithm, key, ciphertext)), + plaintext); + } + } +})().then(common.mustCall()); From 10014dd9b30001e960f463d991d5725d4aafcc2d Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 22 Sep 2026 22:14:40 +0200 Subject: [PATCH 063/228] crypto: convert getRandomValues views Apply Web IDL view conversion before checking the integer array type, including shared and resizable backing-buffer restrictions. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66237 Reviewed-By: James M Snell Reviewed-By: Aviv Keller --- lib/internal/crypto/webcrypto.js | 3 ++- lib/internal/webidl.js | 8 ++++++++ .../test-webcrypto-random-backing-buffer.js | 20 +++++++++++++++++++ test/parallel/test-webcrypto-random.js | 5 ++++- 4 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 test/parallel/test-webcrypto-random-backing-buffer.js diff --git a/lib/internal/crypto/webcrypto.js b/lib/internal/crypto/webcrypto.js index 8969ebaf0dd8..337343cbbb31 100644 --- a/lib/internal/crypto/webcrypto.js +++ b/lib/internal/crypto/webcrypto.js @@ -2029,7 +2029,8 @@ function getRandomValues(array) { const prefix = "Failed to execute 'getRandomValues' on 'Crypto'"; webidl.requiredArguments(arguments.length, 1, { prefix }); - return ReflectApply(_getRandomValues, this, arguments); + array = convertSubtleArgument(prefix, 'ArrayBufferView', array, 0); + return _getRandomValues(array); } ObjectDefineProperties( diff --git a/lib/internal/webidl.js b/lib/internal/webidl.js index 446a4e5f5aec..7c78a4143efd 100644 --- a/lib/internal/webidl.js +++ b/lib/internal/webidl.js @@ -892,6 +892,14 @@ function getViewedArrayBuffer(V) { TypedArrayPrototypeGetBuffer(V) : DataViewPrototypeGetBuffer(V); } +converters.ArrayBufferView = (V, options = kEmptyObject) => { + if (!ArrayBufferIsView(V)) { + throw makeException('is not an ArrayBufferView.', options); + } + validateBufferSourceBacking(getViewedArrayBuffer(V), options); + return V; +}; + /** * Validates [AllowShared] and [AllowResizable] backing-store constraints. * @param {ArrayBuffer|SharedArrayBuffer} buffer Backing buffer. diff --git a/test/parallel/test-webcrypto-random-backing-buffer.js b/test/parallel/test-webcrypto-random-backing-buffer.js new file mode 100644 index 000000000000..607b5b3e5093 --- /dev/null +++ b/test/parallel/test-webcrypto-random-backing-buffer.js @@ -0,0 +1,20 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); + +for (const buffer of [ + new SharedArrayBuffer(8), + new SharedArrayBuffer(8, { maxByteLength: 16 }), + new ArrayBuffer(8, { maxByteLength: 16 }), +]) { + for (const Type of [Uint8Array, Float32Array, DataView]) { + assert.throws(() => crypto.getRandomValues(new Type(buffer)), TypeError); + } +} + +assert.throws(() => crypto.getRandomValues(new Proxy(new Uint8Array(8), {})), + TypeError); diff --git a/test/parallel/test-webcrypto-random.js b/test/parallel/test-webcrypto-random.js index c83a0964bc58..3b87c491e7bd 100644 --- a/test/parallel/test-webcrypto-random.js +++ b/test/parallel/test-webcrypto-random.js @@ -9,8 +9,11 @@ const { Buffer } = require('buffer'); const assert = require('assert'); const { crypto } = globalThis; +for (const value of [undefined, null, '', 1, {}, [], new ArrayBuffer(1)]) { + assert.throws(() => crypto.getRandomValues(value), TypeError); +} + [ - undefined, null, '', 1, {}, [], new Float32Array(1), new Float64Array(1), new DataView(new ArrayBuffer(1)), From e360a4c2d6062556a57bebe9f3e740f13d3d7bc3 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 22 Sep 2026 22:14:41 +0200 Subject: [PATCH 064/228] crypto: validate normalized hash names Check the normalized SHA algorithm instead of reading the caller hash name before conversion. This prevents changing getters from bypassing the supported hash check. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66237 Reviewed-By: James M Snell Reviewed-By: Aviv Keller --- lib/internal/crypto/util.js | 8 +++- lib/internal/crypto/webidl.js | 19 -------- .../test-webcrypto-hash-normalization.js | 47 +++++++++++++++++++ 3 files changed, 54 insertions(+), 20 deletions(-) create mode 100644 test/parallel/test-webcrypto-hash-normalization.js diff --git a/lib/internal/crypto/util.js b/lib/internal/crypto/util.js index e4bd391a2f23..03091cba771a 100644 --- a/lib/internal/crypto/util.js +++ b/lib/internal/crypto/util.js @@ -23,6 +23,7 @@ const { SafeMap, SafeSet, StringPrototypeCharCodeAt, + StringPrototypeStartsWith, StringPrototypeToUpperCase, Symbol, TypedArrayPrototypeGetBuffer, @@ -796,7 +797,12 @@ function normalizeAlgorithm(algorithm, op) { getBufferSourceBytes(idlValue), ); } else if (idlType === 'HashAlgorithmIdentifier') { - normalizedAlgorithm[member] = normalizeAlgorithm(idlValue, 'digest'); + const hash = normalizeAlgorithm(idlValue, 'digest'); + if (!StringPrototypeStartsWith(hash.name, 'SHA')) { + throw lazyDOMException( + `Only SHA hashes are supported in ${desiredType}`, 'NotSupportedError'); + } + normalizedAlgorithm[member] = hash; } else if (idlType === 'AlgorithmIdentifier') { // This extension point is not used by any supported algorithm (yet?) throw lazyDOMException('Not implemented.', 'NotSupportedError'); diff --git a/lib/internal/crypto/webidl.js b/lib/internal/crypto/webidl.js index dc8a1e6d72b3..6e66c3a635ca 100644 --- a/lib/internal/crypto/webidl.js +++ b/lib/internal/crypto/webidl.js @@ -7,7 +7,6 @@ const { ObjectPrototypeHasOwnProperty, StringPrototypeCharCodeAt, StringPrototypeSplit, - StringPrototypeStartsWith, StringPrototypeToLowerCase, } = primordials; @@ -71,18 +70,6 @@ function namedCurveValidator(V, dict) { const converters = { __proto__: null, ...webidl }; -/** - * @param {string | object} V - The hash algorithm identifier (string or object). - * @param {string} label - The dictionary name for the error message. - */ -function ensureSHA(V, label) { - const name = typeof V === 'string' ? V : V.name; - if (typeof name !== 'string' || - !StringPrototypeStartsWith(StringPrototypeToLowerCase(name), 'sha')) - throw lazyDOMException( - `Only SHA hashes are supported in ${label}`, 'NotSupportedError'); -} - converters.AlgorithmIdentifier = (V, opts) => { // Union for (object or DOMString) if (type(V) === 'Object') { @@ -226,7 +213,6 @@ converters.RsaHashedKeyGenParams = createDictionaryConverter( { key: 'hash', converter: converters.HashAlgorithmIdentifier, - validator: (V, dict) => ensureSHA(V, 'RsaHashedKeyGenParams'), required: true, }, ], @@ -239,7 +225,6 @@ converters.RsaHashedImportParams = createDictionaryConverter( { key: 'hash', converter: converters.HashAlgorithmIdentifier, - validator: (V, dict) => ensureSHA(V, 'RsaHashedImportParams'), required: true, }, ], @@ -368,7 +353,6 @@ converters.EcdsaParams = createDictionaryConverter( { key: 'hash', converter: converters.HashAlgorithmIdentifier, - validator: (V, dict) => ensureSHA(V, 'EcdsaParams'), required: true, }, ], @@ -394,7 +378,6 @@ for (let i = 0; i < kHmacDictionaries.length; i++) { { key: 'hash', converter: converters.HashAlgorithmIdentifier, - validator: (V, dict) => ensureSHA(V, name), required: true, }, { @@ -458,7 +441,6 @@ converters.HkdfParams = createDictionaryConverter( { key: 'hash', converter: converters.HashAlgorithmIdentifier, - validator: (V, dict) => ensureSHA(V, 'HkdfParams'), required: true, }, { @@ -526,7 +508,6 @@ converters.Pbkdf2Params = createDictionaryConverter( { key: 'hash', converter: converters.HashAlgorithmIdentifier, - validator: (V, dict) => ensureSHA(V, 'Pbkdf2Params'), required: true, }, ], diff --git a/test/parallel/test-webcrypto-hash-normalization.js b/test/parallel/test-webcrypto-hash-normalization.js new file mode 100644 index 000000000000..c9c09ef1b8d3 --- /dev/null +++ b/test/parallel/test-webcrypto-hash-normalization.js @@ -0,0 +1,47 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { subtle } = globalThis.crypto; + +(async () => { + const data = new Uint8Array(32); + let reads = 0; + const key = await subtle.importKey('raw', data, { + name: 'HMAC', + hash: { + get name() { + reads++; + return reads === 1 ? 'SHA-384' : 'SHA-256'; + }, + }, + }, false, ['sign']); + assert.strictEqual(reads, 1); + assert.strictEqual(key.algorithm.hash.name, 'SHA-384'); + assert.strictEqual((await subtle.sign('HMAC', key, data)).byteLength, 48); + + const converted = await subtle.importKey('raw', data, { + name: 'HMAC', + hash: { name: { toString() { return 'SHA-256'; } } }, + }, false, ['sign']); + assert.strictEqual(converted.algorithm.hash.name, 'SHA-256'); + + await assert.rejects(subtle.importKey('raw', data, { + name: 'HMAC', hash: {}, + }, false, ['sign']), TypeError); + const cshakeAvailable = SubtleCrypto.supports('digest', { + name: 'cSHAKE128', outputLength: 256, + }); + await assert.rejects(subtle.importKey('raw', data, { + name: 'HMAC', hash: 'cSHAKE128', + }, false, ['sign']), cshakeAvailable ? TypeError : { name: 'NotSupportedError' }); + await assert.rejects(subtle.importKey('raw', data, { + name: 'HMAC', hash: 'MD5', length: -1, + }, false, ['sign']), TypeError); + await assert.rejects(subtle.importKey('raw', data, { + name: 'HMAC', hash: { name: 'cSHAKE128', outputLength: 256 }, + }, false, ['sign']), { name: 'NotSupportedError' }); +})().then(common.mustCall()); From a54fc10838ceae24f3648c0d8a3f96a806ff0314 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 22 Sep 2026 22:14:41 +0200 Subject: [PATCH 065/228] crypto: allow short AES-GCM IVs Allow nonempty GCM IVs shorter than the default length and let the underlying cipher enforce its supported range. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66237 Reviewed-By: James M Snell Reviewed-By: Aviv Keller --- src/crypto/crypto_aes.cc | 9 +++- .../test-webcrypto-aes-gcm-iv-length.js | 42 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 test/parallel/test-webcrypto-aes-gcm-iv-length.js diff --git a/src/crypto/crypto_aes.cc b/src/crypto/crypto_aes.cc index 23d811e0cd8e..4f10544dd9cb 100644 --- a/src/crypto/crypto_aes.cc +++ b/src/crypto/crypto_aes.cc @@ -573,8 +573,13 @@ Maybe AESCipherTraits::AdditionalConfig( UseDefaultIV(params); } - // For OCB mode, allow variable IV lengths (1-15 bytes) - if (params->cipher.isOcbMode()) { + if (params->cipher.isGcmMode()) { + if (params->iv.size() == 0) { + THROW_ERR_CRYPTO_INVALID_IV(env); + return Nothing(); + } + } else if (params->cipher.isOcbMode()) { + // For OCB mode, allow variable IV lengths (1-15 bytes). if (params->iv.size() == 0 || params->iv.size() > 15) { THROW_ERR_CRYPTO_INVALID_IV(env); return Nothing(); diff --git a/test/parallel/test-webcrypto-aes-gcm-iv-length.js b/test/parallel/test-webcrypto-aes-gcm-iv-length.js new file mode 100644 index 000000000000..a4243bd0930b --- /dev/null +++ b/test/parallel/test-webcrypto-aes-gcm-iv-length.js @@ -0,0 +1,42 @@ +'use strict'; + +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { createCipheriv } = require('crypto'); +const { subtle } = globalThis.crypto; + +(async () => { + const plaintext = Buffer.from('AES-GCM with a variable-length IV'); + const additionalData = Buffer.from('additional data'); + + for (const length of [128, 192, 256]) { + const keyBytes = Buffer.alloc(length / 8); + const key = await subtle.importKey( + 'raw', keyBytes, 'AES-GCM', false, ['encrypt', 'decrypt']); + + for (const ivLength of [1, 8, 11, 12, 16, 128]) { + const iv = Buffer.alloc(ivLength, 1); + const algorithm = { name: 'AES-GCM', iv, additionalData }; + const cipher = createCipheriv(`aes-${length}-gcm`, keyBytes, iv); + cipher.setAAD(additionalData); + const expected = Buffer.concat([ + cipher.update(plaintext), cipher.final(), cipher.getAuthTag(), + ]); + + assert.deepStrictEqual( + Buffer.from(await subtle.encrypt(algorithm, key, plaintext)), expected); + assert.deepStrictEqual( + Buffer.from(await subtle.decrypt(algorithm, key, expected)), plaintext); + } + + const algorithm = { name: 'AES-GCM', iv: new Uint8Array(0) }; + await assert.rejects(subtle.encrypt(algorithm, key, plaintext), + { name: 'OperationError' }); + await assert.rejects(subtle.decrypt(algorithm, key, new Uint8Array(16)), + { name: 'OperationError' }); + } +})().then(common.mustCall()); From 4cda1fcfd7997bf8bfbd4e6ab3f796b9f8dad57f Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 22 Sep 2026 22:15:29 +0200 Subject: [PATCH 066/228] crypto: allow unbound SubtleCrypto.supports Static Web IDL operations do not require an interface receiver. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66237 Reviewed-By: James M Snell Reviewed-By: Aviv Keller --- lib/internal/crypto/webcrypto.js | 1 - test/parallel/test-webcrypto-constructors.js | 2 +- test/parallel/test-webcrypto-supports-receiver.js | 14 ++++++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 test/parallel/test-webcrypto-supports-receiver.js diff --git a/lib/internal/crypto/webcrypto.js b/lib/internal/crypto/webcrypto.js index 337343cbbb31..8069efbc90be 100644 --- a/lib/internal/crypto/webcrypto.js +++ b/lib/internal/crypto/webcrypto.js @@ -1761,7 +1761,6 @@ class SubtleCrypto { // TODO(panva): Make supports() account for the active FIPS state. static supports(operation, algorithm, lengthOrAdditionalAlgorithm = null) { emitExperimentalWarning('The supports Web Crypto API method'); - if (this !== SubtleCrypto) throw new ERR_INVALID_THIS('SubtleCrypto constructor'); webidl ??= require('internal/crypto/webidl'); const prefix = "Failed to execute 'supports' on 'SubtleCrypto'"; webidl.requiredArguments(arguments.length, 2, { prefix }); diff --git a/test/parallel/test-webcrypto-constructors.js b/test/parallel/test-webcrypto-constructors.js index 3d13b6c92bba..64e1a6ecbbbe 100644 --- a/test/parallel/test-webcrypto-constructors.js +++ b/test/parallel/test-webcrypto-constructors.js @@ -142,7 +142,7 @@ const notSubtle = Reflect.construct(function() {}, [], SubtleCrypto); // Test SubtleCrypto.supports { assert.throws(() => SubtleCrypto.supports.call(undefined), { - name: 'TypeError', code: 'ERR_INVALID_THIS', + name: 'TypeError', code: 'ERR_MISSING_ARGS', }); } diff --git a/test/parallel/test-webcrypto-supports-receiver.js b/test/parallel/test-webcrypto-supports-receiver.js new file mode 100644 index 000000000000..8fa8ed0d4058 --- /dev/null +++ b/test/parallel/test-webcrypto-supports-receiver.js @@ -0,0 +1,14 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { supports } = SubtleCrypto; + +assert.strictEqual(supports('digest', 'SHA-256'), true); +for (const receiver of [undefined, null, {}, globalThis.crypto.subtle]) { + assert.strictEqual(supports.call(receiver, 'digest', 'SHA-256'), true); + assert.strictEqual(supports.call(receiver, 'digest', 'unknown'), false); +} From df85b12c7b6f757d9636b49549d015933ee5a6fc Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 22 Sep 2026 22:16:42 +0200 Subject: [PATCH 067/228] crypto: resolve supports overloads by type Convert the third argument before checking the operation, and apply additional-algorithm checks only for that overload. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66237 Reviewed-By: James M Snell Reviewed-By: Aviv Keller --- lib/internal/crypto/webcrypto.js | 68 ++++++------------- test/fixtures/webcrypto/supports-level-2.mjs | 4 +- .../test-webcrypto-supports-overloads.js | 36 ++++++++++ 3 files changed, 59 insertions(+), 49 deletions(-) create mode 100644 test/parallel/test-webcrypto-supports-overloads.js diff --git a/lib/internal/crypto/webcrypto.js b/lib/internal/crypto/webcrypto.js index 8069efbc90be..748ea1dca551 100644 --- a/lib/internal/crypto/webcrypto.js +++ b/lib/internal/crypto/webcrypto.js @@ -1774,6 +1774,22 @@ class SubtleCrypto { context: '2nd argument', }); + let length = null; + let additionalAlgorithm; + if (lengthOrAdditionalAlgorithm === null || + typeof lengthOrAdditionalAlgorithm === 'number') { + if (lengthOrAdditionalAlgorithm !== null) { + length = webidl.converters['unsigned long'](lengthOrAdditionalAlgorithm, { + prefix, + context: '3rd argument', + enforceRange: true, + }); + } + } else { + additionalAlgorithm = webidl.converters.AlgorithmIdentifier( + lengthOrAdditionalAlgorithm, { prefix, context: '3rd argument' }); + } + switch (operation) { case 'decapsulateBits': case 'decapsulateKey': @@ -1797,17 +1813,7 @@ class SubtleCrypto { return false; } - let length; - let additionalAlgorithm; - if (operation === 'deriveKey') { - additionalAlgorithm = webidl.converters.AlgorithmIdentifier( - lengthOrAdditionalAlgorithm, - { - prefix, - context: '3rd argument', - }, - ); - + if (additionalAlgorithm !== undefined && operation === 'deriveKey') { if (!check('importKey', additionalAlgorithm)) { return false; } @@ -1819,39 +1825,14 @@ class SubtleCrypto { } operation = 'deriveBits'; - } else if (operation === 'wrapKey') { - additionalAlgorithm = webidl.converters.AlgorithmIdentifier( - lengthOrAdditionalAlgorithm, - { - prefix, - context: '3rd argument', - }, - ); - + } else if (additionalAlgorithm !== undefined && operation === 'wrapKey') { if (!check('exportKey', additionalAlgorithm)) { return false; } - } else if (operation === 'unwrapKey') { - additionalAlgorithm = webidl.converters.AlgorithmIdentifier( - lengthOrAdditionalAlgorithm, - { - prefix, - context: '3rd argument', - }, - ); - + } else if (additionalAlgorithm !== undefined && operation === 'unwrapKey') { if (!check('importKey', additionalAlgorithm)) { return false; } - } else if (operation === 'deriveBits') { - length = lengthOrAdditionalAlgorithm; - if (length !== null) { - length = webidl.converters['unsigned long'](length, { - prefix, - context: '3rd argument', - enforceRange: true, - }); - } } else if (operation === 'getPublicKey') { let normalizedAlgorithm; try { @@ -1872,15 +1853,8 @@ class SubtleCrypto { default: return false; } - } else if (operation === 'encapsulateKey' || operation === 'decapsulateKey') { - additionalAlgorithm = webidl.converters.AlgorithmIdentifier( - lengthOrAdditionalAlgorithm, - { - prefix, - context: '3rd argument', - }, - ); - + } else if (additionalAlgorithm !== undefined && + (operation === 'encapsulateKey' || operation === 'decapsulateKey')) { let sharedKeyLength; let normalizedAdditionalAlgorithm; try { diff --git a/test/fixtures/webcrypto/supports-level-2.mjs b/test/fixtures/webcrypto/supports-level-2.mjs index b07ce9097bb7..db1f6327396e 100644 --- a/test/fixtures/webcrypto/supports-level-2.mjs +++ b/test/fixtures/webcrypto/supports-level-2.mjs @@ -250,12 +250,12 @@ export const vectors = { [true, 'X25519'], ], 'wrapKey': [ - [false, 'AES-KW'], + [true, 'AES-KW'], [true, 'AES-KW', 'AES-CTR'], [true, 'AES-KW', 'HMAC'], ], 'unwrapKey': [ - [false, 'AES-KW'], + [true, 'AES-KW'], [true, 'AES-KW', 'AES-CTR'], ], 'unsupported operation': [ diff --git a/test/parallel/test-webcrypto-supports-overloads.js b/test/parallel/test-webcrypto-supports-overloads.js new file mode 100644 index 000000000000..d59bf24fa607 --- /dev/null +++ b/test/parallel/test-webcrypto-supports-overloads.js @@ -0,0 +1,36 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { supports } = SubtleCrypto; + +for (const operation of ['wrapKey', 'unwrapKey']) { + for (const length of [undefined, null, 0, 128]) { + assert.strictEqual(supports(operation, 'AES-KW', length), true); + } + assert.strictEqual(supports(operation, 'AES-KW'), true); +} + +for (const operation of ['digest', 'unknown', 'wrapKey', 'deriveBits']) { + for (const value of [-1, NaN, Infinity, 2 ** 32, Symbol()]) { + assert.throws(() => supports(operation, 'SHA-256', value), TypeError); + } +} + +const hkdf = { + name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(), info: new Uint8Array(), +}; +assert.strictEqual(supports('deriveBits', hkdf, 128), true); +for (const value of ['128', 'AES-GCM', {}, true, 128n]) { + assert.strictEqual(supports('deriveBits', hkdf, value), false); +} +assert.strictEqual(supports('deriveKey', hkdf), false); +assert.strictEqual(supports('deriveKey', hkdf, { name: 'AES-GCM', length: 128 }), true); + +if (supports('encapsulateBits', 'ML-KEM-768')) { + assert.strictEqual(supports('encapsulateKey', 'ML-KEM-768'), true); + assert.strictEqual(supports('decapsulateKey', 'ML-KEM-768', 128), true); +} From b0bd992f87aa709979c4abc04a25371a28d13ce1 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 22 Sep 2026 22:16:43 +0200 Subject: [PATCH 068/228] crypto: copy detached parameters as empty Treat detached BufferSource parameters as empty byte sequences during algorithm normalization, including detached DataViews. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66237 Reviewed-By: James M Snell Reviewed-By: Aviv Keller --- lib/internal/crypto/util.js | 46 +++++++++++++++---- .../test-webcrypto-detached-parameters.js | 33 +++++++++++++ 2 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 test/parallel/test-webcrypto-detached-parameters.js diff --git a/lib/internal/crypto/util.js b/lib/internal/crypto/util.js index 03091cba771a..ee1629b824ec 100644 --- a/lib/internal/crypto/util.js +++ b/lib/internal/crypto/util.js @@ -3,6 +3,7 @@ const { ArrayBufferIsView, ArrayBufferPrototypeGetByteLength, + ArrayBufferPrototypeGetDetached, ArrayPrototypeIncludes, ArrayPrototypePush, ArrayPrototypeSlice, @@ -97,6 +98,7 @@ const { const { isDataView, + isArrayBuffer, isArrayBufferView, isAnyArrayBuffer, isPromise, @@ -829,19 +831,43 @@ function getDataViewOrTypedArrayByteLength(V) { } function getBufferSourceByteLength(V) { - return ArrayBufferIsView(V) ? - getDataViewOrTypedArrayByteLength(V) : - ArrayBufferPrototypeGetByteLength(V); + // ArrayBuffer and typed array lengths already become zero when detached. + if (!ArrayBufferIsView(V)) + return ArrayBufferPrototypeGetByteLength(V); + if (!isDataView(V)) + return TypedArrayPrototypeGetByteLength(V); + const buffer = DataViewPrototypeGetBuffer(V); + if (isArrayBuffer(buffer) && ArrayBufferPrototypeGetDetached(buffer)) + return 0; + return DataViewPrototypeGetByteLength(V); } function getBufferSourceBytes(V) { - return ArrayBufferIsView(V) ? - new Uint8Array( - getDataViewOrTypedArrayBuffer(V), - getDataViewOrTypedArrayByteOffset(V), - getDataViewOrTypedArrayByteLength(V), - ) : - new Uint8Array(V, 0, ArrayBufferPrototypeGetByteLength(V)); + if (!ArrayBufferIsView(V)) { + const length = ArrayBufferPrototypeGetByteLength(V); + if (length === 0 && ArrayBufferPrototypeGetDetached(V)) + return new Uint8Array(0); + return new Uint8Array(V, 0, length); + } + + let buffer; + let offset; + let length; + if (isDataView(V)) { + buffer = DataViewPrototypeGetBuffer(V); + if (isArrayBuffer(buffer) && ArrayBufferPrototypeGetDetached(buffer)) + return new Uint8Array(0); + offset = DataViewPrototypeGetByteOffset(V); + length = DataViewPrototypeGetByteLength(V); + } else { + buffer = TypedArrayPrototypeGetBuffer(V); + offset = TypedArrayPrototypeGetByteOffset(V); + length = TypedArrayPrototypeGetByteLength(V); + if (length === 0 && isArrayBuffer(buffer) && + ArrayBufferPrototypeGetDetached(buffer)) + return new Uint8Array(0); + } + return new Uint8Array(buffer, offset, length); } function getOptionalByteLength(V) { diff --git a/test/parallel/test-webcrypto-detached-parameters.js b/test/parallel/test-webcrypto-detached-parameters.js new file mode 100644 index 000000000000..cf8b34cc9dc4 --- /dev/null +++ b/test/parallel/test-webcrypto-detached-parameters.js @@ -0,0 +1,33 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { subtle } = globalThis.crypto; + +function detached(kind) { + const buffer = new ArrayBuffer(16); + const value = kind === 'ArrayBuffer' ? buffer : + kind === 'DataView' ? new DataView(buffer) : new Uint8Array(buffer); + buffer.transfer(); + return value; +} + +(async () => { + const empty = new Uint8Array(0); + const input = new Uint8Array(16); + const key = await subtle.importKey('raw', input, 'HKDF', false, ['deriveBits']); + const algorithm = { name: 'HKDF', hash: 'SHA-256', salt: empty, info: empty }; + const expected = await subtle.deriveBits(algorithm, key, 256); + const digest = await subtle.digest('SHA-256', empty); + for (const kind of ['ArrayBuffer', 'Uint8Array', 'DataView']) { + for (const member of ['salt', 'info']) { + assert.deepStrictEqual(await subtle.deriveBits({ + ...algorithm, [member]: detached(kind), + }, key, 256), expected); + } + assert.deepStrictEqual(await subtle.digest('SHA-256', detached(kind)), digest); + } +})().then(common.mustCall()); From 00e04f368dbe9053a60b2b9f9208b8ef57314aec Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 22 Sep 2026 22:18:26 +0200 Subject: [PATCH 069/228] crypto: convert importKey data as a union Choose the BufferSource or JsonWebKey branch from the value, then check the requested format after algorithm normalization. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66237 Reviewed-By: James M Snell Reviewed-By: Aviv Keller --- lib/internal/crypto/webcrypto.js | 11 +++++- test/parallel/test-webcrypto-import-union.js | 35 ++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 test/parallel/test-webcrypto-import-union.js diff --git a/lib/internal/crypto/webcrypto.js b/lib/internal/crypto/webcrypto.js index 748ea1dca551..2ce40673a22f 100644 --- a/lib/internal/crypto/webcrypto.js +++ b/lib/internal/crypto/webcrypto.js @@ -1,6 +1,7 @@ 'use strict'; const { + ArrayBufferIsView, ArrayIsArray, ArrayPrototypeSlice, FunctionPrototypeCall, @@ -40,6 +41,7 @@ const { const { codes: { ERR_ILLEGAL_CONSTRUCTOR, + ERR_INVALID_ARG_TYPE, ERR_INVALID_THIS, }, } = require('internal/errors'); @@ -86,6 +88,7 @@ const { } = require('internal/crypto/random'); const { + isArrayBuffer, isPromise, } = require('internal/util/types'); @@ -1115,7 +1118,8 @@ function importKeyImpl( const prefix = prepareSubtleMethod(this, 'importKey', arguments.length, 5); let i = 0; format = convertSubtleArgument(prefix, 'KeyFormat', format, i++); - const type = format === 'jwk' ? 'JsonWebKey' : 'BufferSource'; + const type = ArrayBufferIsView(keyData) || isArrayBuffer(keyData) ? + 'BufferSource' : 'JsonWebKey'; keyData = convertSubtleArgument(prefix, type, keyData, i++); algorithm = convertSubtleArgument( prefix, 'AlgorithmIdentifier', algorithm, i++); @@ -1125,6 +1129,11 @@ function importKeyImpl( const normalizedAlgorithm = normalizeAlgorithm(algorithm, 'importKey'); + if ((format === 'jwk') !== (type === 'JsonWebKey')) { + throw new ERR_INVALID_ARG_TYPE( + 'keyData', format === 'jwk' ? 'JsonWebKey' : 'BufferSource', keyData); + } + return FunctionPrototypeCall( importKeySync, this, diff --git a/test/parallel/test-webcrypto-import-union.js b/test/parallel/test-webcrypto-import-union.js new file mode 100644 index 000000000000..3391ba5c8dc9 --- /dev/null +++ b/test/parallel/test-webcrypto-import-union.js @@ -0,0 +1,35 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { subtle } = globalThis.crypto; +const jwk = { kty: 'oct', k: 'AAAAAAAAAAAAAAAAAAAAAA' }; + +(async () => { + for (const data of [new ArrayBuffer(16), new Uint8Array(16), + new DataView(new ArrayBuffer(16)), Buffer.alloc(16)]) { + await assert.rejects(subtle.importKey('jwk', data, 'AES-GCM', true, ['encrypt']), + TypeError); + Object.assign(data, jwk); + await assert.rejects(subtle.importKey('jwk', data, 'AES-GCM', true, ['encrypt']), + TypeError); + await assert.rejects(subtle.importKey('jwk', data, 'unknown', true, ['encrypt']), + { name: 'NotSupportedError' }); + } + await assert.rejects(subtle.importKey('raw', {}, 'unknown', true, ['encrypt']), + { name: 'NotSupportedError' }); + await assert.rejects(subtle.importKey('raw', {}, 'AES-GCM', true, ['encrypt']), + TypeError); + for (const data of [null, new SharedArrayBuffer(16)]) { + await assert.rejects(subtle.importKey('jwk', data, 'AES-GCM', true, ['encrypt']), + { name: 'DataError' }); + } + assert.strictEqual((await subtle.importKey('jwk', jwk, 'AES-GCM', true, + ['encrypt'])).type, 'secret'); + await assert.rejects(subtle.importKey('raw', { + get kty() { throw new Error('converted JWK'); }, + }, 'unknown', true, ['encrypt']), { message: 'converted JWK' }); +})().then(common.mustCall()); From 0c6cf25e4a11ce232637a17a150f8e61898243e4 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 22 Sep 2026 22:18:27 +0200 Subject: [PATCH 070/228] crypto: copy parameters without array species Allocate byte copies directly so typed-array species cannot replace normalized parameters or bit-truncated key material. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66237 Reviewed-By: James M Snell Reviewed-By: Aviv Keller --- lib/internal/crypto/util.js | 15 ++++---- .../test-webcrypto-parameter-species.js | 36 +++++++++++++++++++ 2 files changed, 42 insertions(+), 9 deletions(-) create mode 100644 test/parallel/test-webcrypto-parameter-species.js diff --git a/lib/internal/crypto/util.js b/lib/internal/crypto/util.js index ee1629b824ec..c3c155f5e824 100644 --- a/lib/internal/crypto/util.js +++ b/lib/internal/crypto/util.js @@ -12,6 +12,7 @@ const { DataViewPrototypeGetByteLength, DataViewPrototypeGetByteOffset, MathFloor, + MathMin, Number, ObjectDefineProperty, ObjectEntries, @@ -31,7 +32,6 @@ const { TypedArrayPrototypeGetByteLength, TypedArrayPrototypeGetByteOffset, TypedArrayPrototypeGetLength, - TypedArrayPrototypeSlice, Uint8Array, } = primordials; @@ -712,14 +712,11 @@ function truncateToBitLength(length, bytes) { new Uint8Array( getDataViewOrTypedArrayBuffer(bytes), getDataViewOrTypedArrayByteOffset(bytes), - getDataViewOrTypedArrayByteLength(bytes), + MathMin(lengthBytes, getDataViewOrTypedArrayByteLength(bytes)), ) : - new Uint8Array(bytes, 0, ArrayBufferPrototypeGetByteLength(bytes)); - const result = TypedArrayPrototypeSlice( - byteView, - 0, - lengthBytes, - ); + new Uint8Array(bytes, 0, + MathMin(lengthBytes, ArrayBufferPrototypeGetByteLength(bytes))); + const result = new Uint8Array(byteView); const remainder = length % 8; if (remainder !== 0) @@ -795,7 +792,7 @@ function normalizeAlgorithm(algorithm, op) { const idlValue = normalizedAlgorithm[member]; // 3. if (idlType === 'BufferSource' && idlValue) { - normalizedAlgorithm[member] = TypedArrayPrototypeSlice( + normalizedAlgorithm[member] = new Uint8Array( getBufferSourceBytes(idlValue), ); } else if (idlType === 'HashAlgorithmIdentifier') { diff --git a/test/parallel/test-webcrypto-parameter-species.js b/test/parallel/test-webcrypto-parameter-species.js new file mode 100644 index 000000000000..2a02eb8a2810 --- /dev/null +++ b/test/parallel/test-webcrypto-parameter-species.js @@ -0,0 +1,36 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { subtle } = globalThis.crypto; + +function withSpecies(callback) { + Object.defineProperty(Uint8Array, Symbol.species, { + configurable: true, + value: Float64Array, + }); + try { + return callback(); + } finally { + delete Uint8Array[Symbol.species]; + } +} + +(async () => { + const key = await subtle.importKey('raw', new Uint8Array(16), + 'AES-GCM', false, ['encrypt']); + const algorithm = { name: 'AES-GCM', iv: new Uint8Array(12).fill(1) }; + const data = new Uint8Array(16); + const expected = await subtle.encrypt(algorithm, key, data); + assert.deepStrictEqual(await withSpecies(() => subtle.encrypt(algorithm, key, data)), expected); + + const hmac = { name: 'HMAC', hash: 'SHA-256', length: 100 }; + const secret = new Uint8Array(13).fill(1); + const normal = await subtle.importKey('raw', secret, hmac, true, ['sign']); + const tampered = await withSpecies(() => subtle.importKey('raw', secret, hmac, true, ['sign'])); + assert.deepStrictEqual(await subtle.exportKey('raw', tampered), await subtle.exportKey('raw', normal)); + assert.deepStrictEqual(await subtle.sign('HMAC', tampered, data), await subtle.sign('HMAC', normal, data)); +})().then(common.mustCall()); From d0b2e2cd19873ca70a72e1403d07507452e65f9d Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 22 Sep 2026 22:18:28 +0200 Subject: [PATCH 071/228] crypto: check RSA JWK alg with SHA-3 hashes Reject a supplied JWK alg when no matching identifier exists for the requested RSA algorithm and hash. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66237 Reviewed-By: James M Snell Reviewed-By: Aviv Keller --- lib/internal/crypto/rsa.js | 2 +- .../test-webcrypto-rsa-jwk-sha3-alg.js | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 test/parallel/test-webcrypto-rsa-jwk-sha3-alg.js diff --git a/lib/internal/crypto/rsa.js b/lib/internal/crypto/rsa.js index 27b23087d563..81ca67234296 100644 --- a/lib/internal/crypto/rsa.js +++ b/lib/internal/crypto/rsa.js @@ -202,7 +202,7 @@ function rsaImportKey( algorithm.name === 'RSA-PSS' ? normalizeHashName.kContextJwkRsaPss : normalizeHashName.kContextJwkRsaOaep); - if (expected && keyData.alg !== expected) + if (keyData.alg !== expected) throw lazyDOMException( 'JWK "alg" does not match the requested algorithm', 'DataError'); diff --git a/test/parallel/test-webcrypto-rsa-jwk-sha3-alg.js b/test/parallel/test-webcrypto-rsa-jwk-sha3-alg.js new file mode 100644 index 000000000000..88bc64194e6b --- /dev/null +++ b/test/parallel/test-webcrypto-rsa-jwk-sha3-alg.js @@ -0,0 +1,42 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const { isBoringSSL } = require('../common/crypto'); +if (isBoringSSL) + common.skip('missing SHA-3'); + +const assert = require('assert'); +const { createPrivateKey, createPublicKey } = require('crypto'); +const fixtures = require('../common/fixtures'); +const { subtle } = globalThis.crypto; + +(async () => { + const privateKey = createPrivateKey(fixtures.readKey('rsa_private_2048.pem')); + const privateJwk = privateKey.export({ format: 'jwk' }); + const publicJwk = createPublicKey(privateKey).export({ format: 'jwk' }); + + for (const name of ['RSA-PSS', 'RSASSA-PKCS1-v1_5', 'RSA-OAEP']) { + for (const hash of ['SHA3-256', 'SHA3-384', 'SHA3-512']) { + for (const jwk of [publicJwk, privateJwk]) { + const usages = name === 'RSA-OAEP' ? [jwk.d ? 'decrypt' : 'encrypt'] : + [jwk.d ? 'sign' : 'verify']; + const algorithm = { name, hash }; + // There is no JWK alg identifier for RSA with SHA-3. Omitting alg is + // valid, but an identifier for SHA-2 or an unknown identifier is not. + const key = await subtle.importKey('jwk', jwk, algorithm, true, usages); + const exported = await subtle.exportKey('jwk', key); + assert.strictEqual(Object.hasOwn(exported, 'alg'), false); + const imported = await subtle.importKey('jwk', exported, algorithm, true, usages); + assert.deepStrictEqual(imported.algorithm, key.algorithm); + assert.deepStrictEqual(await subtle.exportKey('jwk', imported), exported); + for (const alg of ['RS256', 'PS256', 'RSA-OAEP-256', 'unknown']) { + await assert.rejects(subtle.importKey( + 'jwk', { ...jwk, alg }, algorithm, true, usages), { name: 'DataError' }); + } + } + } + } +})().then(common.mustCall()); From 95795585d5fbfc404e61405b145dd764161c5ce6 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 22 Sep 2026 22:19:32 +0200 Subject: [PATCH 072/228] crypto: reject duplicate unknown key usages Check all JWK key_ops entries for duplicates before ignoring unknown usage names. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66237 Reviewed-By: James M Snell Reviewed-By: Aviv Keller --- lib/internal/crypto/util.js | 7 ++++--- .../test-webcrypto-key-ops-duplicates.js | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 test/parallel/test-webcrypto-key-ops-duplicates.js diff --git a/lib/internal/crypto/util.js b/lib/internal/crypto/util.js index c3c155f5e824..08e87f142872 100644 --- a/lib/internal/crypto/util.js +++ b/lib/internal/crypto/util.js @@ -1175,15 +1175,16 @@ function validateKeyOps(keyOps, usagesSet) { if (keyOps === undefined) return; validateArray(keyOps, 'keyData.key_ops'); let keyOpsMask = 0; + const seen = new SafeSet(); for (let n = 0; n < keyOps.length; n++) { const op = keyOps[n]; + if (seen.has(op)) + throw lazyDOMException('Duplicate key operation', 'DataError'); + seen.add(op); const opMask = kUsageMasks[op]; // Skipping unknown key ops if (opMask === undefined) continue; - // Have we seen it already? if so, error - if (keyOpsMask & opMask) - throw lazyDOMException('Duplicate key operation', 'DataError'); keyOpsMask |= opMask; // TODO(@jasnell): RFC7517 section 4.3 strong recommends validating diff --git a/test/parallel/test-webcrypto-key-ops-duplicates.js b/test/parallel/test-webcrypto-key-ops-duplicates.js new file mode 100644 index 000000000000..4a666a1caa47 --- /dev/null +++ b/test/parallel/test-webcrypto-key-ops-duplicates.js @@ -0,0 +1,20 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { subtle } = globalThis.crypto; + +(async () => { + const algorithm = { name: 'HMAC', hash: 'SHA-256' }; + const jwk = { kty: 'oct', k: 'AAAAAAAAAAAAAAAAAAAAAA' }; + await subtle.importKey('jwk', { ...jwk, key_ops: ['sign', 'future'] }, + algorithm, false, ['sign']); + for (const key_ops of [['sign', 'sign'], ['sign', 'future', 'future']]) { + await assert.rejects(subtle.importKey('jwk', { ...jwk, key_ops }, + algorithm, false, ['sign']), + { name: 'DataError' }); + } +})().then(common.mustCall()); From 40caff7041782f7a5bc474127286894b36a2c49c Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 22 Sep 2026 22:27:08 +0200 Subject: [PATCH 073/228] crypto: handle PBKDF2 iteration limits Report OperationError for unsupported iteration counts and return an empty result before invoking the backend when no output is requested. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66237 Reviewed-By: James M Snell Reviewed-By: Aviv Keller --- lib/internal/crypto/pbkdf2.js | 13 +++++++++--- lib/internal/crypto/webcrypto.js | 2 +- lib/internal/crypto/webidl.js | 10 --------- test/parallel/test-webcrypto-derivebits.js | 2 +- .../test-webcrypto-pbkdf2-iteration-limits.js | 21 +++++++++++++++++++ 5 files changed, 33 insertions(+), 15 deletions(-) create mode 100644 test/parallel/test-webcrypto-pbkdf2-iteration-limits.js diff --git a/lib/internal/crypto/pbkdf2.js b/lib/internal/crypto/pbkdf2.js index f42ce3bbd133..b9f6b59c85bd 100644 --- a/lib/internal/crypto/pbkdf2.js +++ b/lib/internal/crypto/pbkdf2.js @@ -16,6 +16,7 @@ const { } = internalBinding('crypto'); const { + isInt32, validateFunction, validateInt32, validateString, @@ -98,7 +99,7 @@ function check(password, salt, iterations, keylen, digest) { return { password, salt, iterations, keylen, digest }; } -function validatePbkdf2DeriveBitsLength(length) { +function validatePbkdf2DeriveBits({ iterations }, length) { if (length === null) throw lazyDOMException('length cannot be null', 'OperationError'); @@ -107,10 +108,16 @@ function validatePbkdf2DeriveBitsLength(length) { 'length must be a multiple of 8', 'OperationError'); } + if (iterations === 0) + throw lazyDOMException('iterations cannot be zero', 'OperationError'); + if (length !== 0 && !isInt32(iterations)) { + throw lazyDOMException( + 'iterations exceeds the implementation limit', 'OperationError'); + } } function pbkdf2DeriveBits(algorithm, baseKey, length) { - validatePbkdf2DeriveBitsLength(length); + validatePbkdf2DeriveBits(algorithm, length); const { iterations, hash, salt } = algorithm; if (length === 0) @@ -129,5 +136,5 @@ module.exports = { pbkdf2, pbkdf2Sync, pbkdf2DeriveBits, - validatePbkdf2DeriveBitsLength, + validatePbkdf2DeriveBits, }; diff --git a/lib/internal/crypto/webcrypto.js b/lib/internal/crypto/webcrypto.js index 2ce40673a22f..88306c9c6a75 100644 --- a/lib/internal/crypto/webcrypto.js +++ b/lib/internal/crypto/webcrypto.js @@ -1939,7 +1939,7 @@ function check(op, alg, length) { } if (normalizedAlgorithm.name === 'PBKDF2') { - require('internal/crypto/pbkdf2').validatePbkdf2DeriveBitsLength(length); + require('internal/crypto/pbkdf2').validatePbkdf2DeriveBits(normalizedAlgorithm, length); } if (StringPrototypeStartsWith(normalizedAlgorithm.name, 'Argon2')) { diff --git a/lib/internal/crypto/webidl.js b/lib/internal/crypto/webidl.js index 6e66c3a635ca..645dd413f4cc 100644 --- a/lib/internal/crypto/webidl.js +++ b/lib/internal/crypto/webidl.js @@ -15,7 +15,6 @@ const { kEmptyObject, } = require('internal/util'); const { - isInt32, isUint32, } = require('internal/validators'); const { @@ -494,15 +493,6 @@ converters.Pbkdf2Params = createDictionaryConverter( key: 'iterations', converter: (V, opts) => converters['unsigned long'](V, enforceRangeOptions(opts)), - validator: (V, dict) => { - if (V === 0) - throw lazyDOMException('iterations cannot be zero', 'OperationError'); - if (!isInt32(V)) { - throw lazyDOMException( - 'iterations exceeds the implementation limit', - 'NotSupportedError'); - } - }, required: true, }, { diff --git a/test/parallel/test-webcrypto-derivebits.js b/test/parallel/test-webcrypto-derivebits.js index e1e3da6ce26e..b81669f95de7 100644 --- a/test/parallel/test-webcrypto-derivebits.js +++ b/test/parallel/test-webcrypto-derivebits.js @@ -136,7 +136,7 @@ const rejectsXCurves = hasFIPS(3, 5); salt: new Uint8Array([2]), iterations: 2 ** 31, }, key, 8), - { name: 'NotSupportedError' }); + { name: 'OperationError' }); } test().then(common.mustCall()); diff --git a/test/parallel/test-webcrypto-pbkdf2-iteration-limits.js b/test/parallel/test-webcrypto-pbkdf2-iteration-limits.js new file mode 100644 index 000000000000..168ee83a6411 --- /dev/null +++ b/test/parallel/test-webcrypto-pbkdf2-iteration-limits.js @@ -0,0 +1,21 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { subtle } = globalThis.crypto; + +(async () => { + const key = await subtle.importKey('raw', new Uint8Array(16), 'PBKDF2', false, ['deriveBits']); + for (const iterations of [2 ** 31, 2 ** 32 - 1]) { + const algorithm = { name: 'PBKDF2', hash: 'SHA-256', salt: new Uint8Array(16), iterations }; + assert.strictEqual((await subtle.deriveBits(algorithm, key, 0)).byteLength, 0); + assert.strictEqual(SubtleCrypto.supports('deriveBits', algorithm, 0), true); + assert.strictEqual(SubtleCrypto.supports('deriveBits', algorithm, 8), false); + await assert.rejects(subtle.deriveBits(algorithm, key, 8), { name: 'OperationError' }); + } + const algorithm = { name: 'PBKDF2', hash: 'SHA-256', salt: new Uint8Array(16), iterations: 0 }; + await assert.rejects(subtle.deriveBits(algorithm, key, 0), { name: 'OperationError' }); +})().then(common.mustCall()); From d6760622c52d3591dd920a8ff53218a6df28a385 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 22 Sep 2026 22:25:22 +0200 Subject: [PATCH 074/228] crypto: copy derived bits without species Use the byte-copy helper for truncated ECDH results so ArrayBuffer species cannot replace or resize the returned key material. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66237 Reviewed-By: James M Snell Reviewed-By: Aviv Keller --- lib/internal/crypto/diffiehellman.js | 8 ++---- .../test-webcrypto-derived-bits-species.js | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) create mode 100644 test/parallel/test-webcrypto-derived-bits-species.js diff --git a/lib/internal/crypto/diffiehellman.js b/lib/internal/crypto/diffiehellman.js index 701adaca0745..16ee3fda7b59 100644 --- a/lib/internal/crypto/diffiehellman.js +++ b/lib/internal/crypto/diffiehellman.js @@ -2,7 +2,6 @@ const { ArrayBufferPrototypeGetByteLength, - ArrayBufferPrototypeSlice, FunctionPrototypeCall, ObjectDefineProperty, SafeSet, @@ -416,11 +415,8 @@ function ecdhDeriveBits(algorithm, baseKey, length) { if (byteLength < sliceLength) throw lazyDOMException('derived bit length is too small', 'OperationError'); - if (length % 8 === 0) { - if (byteLength === sliceLength) - return bits; - return ArrayBufferPrototypeSlice(bits, 0, sliceLength); - } + if (length % 8 === 0 && byteLength === sliceLength) + return bits; return TypedArrayPrototypeGetBuffer(truncateToBitLength(length, bits)); }); diff --git a/test/parallel/test-webcrypto-derived-bits-species.js b/test/parallel/test-webcrypto-derived-bits-species.js new file mode 100644 index 000000000000..491dc0df8771 --- /dev/null +++ b/test/parallel/test-webcrypto-derived-bits-species.js @@ -0,0 +1,26 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { subtle } = globalThis.crypto; + +(async () => { + const keys = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits']); + const algorithm = { name: 'ECDH', public: keys.publicKey }; + const expected = await subtle.deriveBits(algorithm, keys.privateKey, 128); + const species = Object.getOwnPropertyDescriptor(ArrayBuffer, Symbol.species); + Object.defineProperty(ArrayBuffer, Symbol.species, { + configurable: true, + get: common.mustNotCall(), + }); + try { + const actual = await subtle.deriveBits(algorithm, keys.privateKey, 128); + assert.deepStrictEqual(actual, expected); + assert.strictEqual(Object.getPrototypeOf(actual), ArrayBuffer.prototype); + } finally { + Object.defineProperty(ArrayBuffer, Symbol.species, species); + } +})().then(common.mustCall()); From efdc59fbb983097dfa6a90c22eab6274b8aa835d Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 22 Sep 2026 22:27:08 +0200 Subject: [PATCH 075/228] crypto: minimize RSA public exponent metadata Omit leading zero octets from generated CryptoKey publicExponent values, matching the BigInteger representation. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66237 Reviewed-By: James M Snell Reviewed-By: Aviv Keller --- lib/internal/crypto/rsa.js | 10 ++++++- .../test-webcrypto-rsa-exponent-length.js | 26 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 test/parallel/test-webcrypto-rsa-exponent-length.js diff --git a/lib/internal/crypto/rsa.js b/lib/internal/crypto/rsa.js index 81ca67234296..e98fe5cd7b9b 100644 --- a/lib/internal/crypto/rsa.js +++ b/lib/internal/crypto/rsa.js @@ -2,6 +2,8 @@ const { TypedArrayPrototypeGetBuffer, + TypedArrayPrototypeGetByteOffset, + TypedArrayPrototypeGetLength, Uint8Array, } = primordials; @@ -111,11 +113,17 @@ function rsaKeyGenerate( const allowedUsages = kUsages[name]; const usagesSet = validateKeyUsages(usages, allowedUsages.keygen, name); const publicExponentConverted = bigIntArrayToUnsignedInt(publicExponent); + let firstNonzeroByte = 0; + while (publicExponent[firstNonzeroByte] === 0) + firstNonzeroByte++; const keyAlgorithm = { name, modulusLength, - publicExponent, + publicExponent: new Uint8Array( + TypedArrayPrototypeGetBuffer(publicExponent), + TypedArrayPrototypeGetByteOffset(publicExponent) + firstNonzeroByte, + TypedArrayPrototypeGetLength(publicExponent) - firstNonzeroByte), hash, }; diff --git a/test/parallel/test-webcrypto-rsa-exponent-length.js b/test/parallel/test-webcrypto-rsa-exponent-length.js new file mode 100644 index 000000000000..c227154c7a1b --- /dev/null +++ b/test/parallel/test-webcrypto-rsa-exponent-length.js @@ -0,0 +1,26 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { getFips } = require('crypto'); +const { subtle } = globalThis.crypto; + +(async () => { + const exponents = getFips() ? [[1, 0, 1]] : [[3], [1, 0, 1]]; + for (const name of ['RSA-PSS', 'RSASSA-PKCS1-v1_5', 'RSA-OAEP']) { + const usages = name === 'RSA-OAEP' ? ['encrypt', 'decrypt'] : ['sign', 'verify']; + for (const exponent of exponents) { + const publicExponent = new Uint8Array([0, 0, 0, 0, ...exponent]); + const pair = await subtle.generateKey({ + name, modulusLength: 2048, publicExponent, hash: 'SHA-256', + }, true, usages); + for (const key of [pair.publicKey, pair.privateKey]) { + assert.deepStrictEqual(key.algorithm.publicExponent, new Uint8Array(exponent)); + } + assert.deepStrictEqual(publicExponent, new Uint8Array([0, 0, 0, 0, ...exponent])); + } + } +})().then(common.mustCall()); From 34d69668fd68db0364a1293b1b4b75e9201378a7 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 22 Sep 2026 22:29:27 +0200 Subject: [PATCH 076/228] crypto: create key usage arrays without species Build public usages and JWK key_ops from the native usage mask so array species cannot replace or mutate internal usage arrays. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66237 Reviewed-By: James M Snell Reviewed-By: Aviv Keller --- lib/internal/crypto/kem_hybrids.js | 6 ++-- lib/internal/crypto/keys.js | 5 ++-- lib/internal/crypto/webcrypto.js | 5 ++-- .../parallel/test-webcrypto-usages-species.js | 30 +++++++++++++++++++ 4 files changed, 37 insertions(+), 9 deletions(-) create mode 100644 test/parallel/test-webcrypto-usages-species.js diff --git a/lib/internal/crypto/kem_hybrids.js b/lib/internal/crypto/kem_hybrids.js index 58fcb6e8fd1a..d95a651b61b7 100644 --- a/lib/internal/crypto/kem_hybrids.js +++ b/lib/internal/crypto/kem_hybrids.js @@ -1,7 +1,6 @@ 'use strict'; const { - ArrayPrototypeSlice, BigInt, PromiseWithResolvers, SafeSet, @@ -24,7 +23,7 @@ const { getCryptoKeySeedData, getCryptoKeySecondaryHandle, getCryptoKeyType, - getCryptoKeyUsages, + getCryptoKeyUsagesMask, InternalCryptoKey, } = require('internal/crypto/keys'); @@ -51,6 +50,7 @@ const { const { getBufferSourceBytes, getUsagesMask, + getUsagesFromMask, jobPromise, jobPromiseThen, resolveWebCryptoResult, @@ -860,7 +860,7 @@ function exportJwkKey(key, rawEncapsulationKey, config) { kty: 'AKP', alg: config.name, pub: base64urlEncode(rawEncapsulationKey), - key_ops: ArrayPrototypeSlice(getCryptoKeyUsages(key), 0), + key_ops: getUsagesFromMask(getCryptoKeyUsagesMask(key)), ext: getCryptoKeyExtractable(key), }; if (getCryptoKeyType(key) === 'private') diff --git a/lib/internal/crypto/keys.js b/lib/internal/crypto/keys.js index 5ecadbab83ba..88036d1c5ad8 100644 --- a/lib/internal/crypto/keys.js +++ b/lib/internal/crypto/keys.js @@ -2,7 +2,6 @@ const { ArrayPrototypeMap, - ArrayPrototypeSlice, ObjectDefineProperties, ObjectPrototypeHasOwnProperty, ObjectSetPrototypeOf, @@ -1252,7 +1251,7 @@ const { type: getCryptoKeyType(this), extractable: getCryptoKeyExtractable(this), algorithm: cloneAlgorithm(getCryptoKeyAlgorithm(this)), - usages: ArrayPrototypeSlice(getCryptoKeyUsages(this), 0), + usages: getUsagesFromMask(getCryptoKeyUsagesMask(this)), }, opts)}`; } @@ -1286,7 +1285,7 @@ const { const slots = getSlots(this); let cached = slots[kSlotClonedUsages]; if (cached === undefined) { - cached = ArrayPrototypeSlice(getCryptoKeyUsagesFromSlots(slots), 0); + cached = getUsagesFromMask(slots[kSlotUsagesMask]); slots[kSlotClonedUsages] = cached; } return cached; diff --git a/lib/internal/crypto/webcrypto.js b/lib/internal/crypto/webcrypto.js index 88306c9c6a75..22246d41805b 100644 --- a/lib/internal/crypto/webcrypto.js +++ b/lib/internal/crypto/webcrypto.js @@ -3,7 +3,6 @@ const { ArrayBufferIsView, ArrayIsArray, - ArrayPrototypeSlice, FunctionPrototypeCall, JSONParse, JSONStringify, @@ -52,7 +51,6 @@ const { getCryptoKeyExtractable, getCryptoKeyHandle, getCryptoKeyType, - getCryptoKeyUsages, getCryptoKeyUsagesMask, hasCryptoKeyUsage, importGenericSecretKey, @@ -66,6 +64,7 @@ const { const { cleanupWebCryptoResult, getBlockSize, + getUsagesFromMask, jobPromiseThen, normalizeAlgorithm, normalizeHashName, @@ -796,7 +795,7 @@ function exportKeyJWK(key) { // `parameters` before native export populates key material. Delete it for // algorithms without a JWK alg value to keep the expected shape. const parameters = { - key_ops: ArrayPrototypeSlice(getCryptoKeyUsages(key), 0), + key_ops: getUsagesFromMask(getCryptoKeyUsagesMask(key)), ext: getCryptoKeyExtractable(key), alg, }; diff --git a/test/parallel/test-webcrypto-usages-species.js b/test/parallel/test-webcrypto-usages-species.js new file mode 100644 index 000000000000..2e1aed7ec42f --- /dev/null +++ b/test/parallel/test-webcrypto-usages-species.js @@ -0,0 +1,30 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { subtle } = globalThis.crypto; + +(async () => { + const key = await subtle.importKey('raw', new Uint8Array(16), 'AES-GCM', true, + ['encrypt', 'decrypt']); + const descriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'constructor'); + let usages; + let jwk; + try { + Object.defineProperty(Array.prototype, 'constructor', { get: common.mustNotCall() }); + usages = key.usages; + jwk = await subtle.exportKey('jwk', key); + } finally { + Object.defineProperty(Array.prototype, 'constructor', descriptor); + } + assert.strictEqual(Object.getPrototypeOf(usages), Array.prototype); + assert.strictEqual(key.usages, usages); + assert.deepStrictEqual(usages, ['encrypt', 'decrypt']); + assert.deepStrictEqual(jwk.key_ops, usages); + usages.push('sign'); + assert.deepStrictEqual((await subtle.exportKey('jwk', key)).key_ops, + ['encrypt', 'decrypt']); +})().then(common.mustCall()); From ebdaa95cbdb3c751d62722cb3406a8b5bb7d948e Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 22 Sep 2026 22:29:27 +0200 Subject: [PATCH 077/228] crypto: derive Argon2 without worker threads Keep Argon2 lane count independent from backend worker availability. When the OpenSSL thread pool is unavailable, compute lanes serially. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66237 Reviewed-By: James M Snell Reviewed-By: Aviv Keller --- deps/ncrypto/ncrypto.cc | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/deps/ncrypto/ncrypto.cc b/deps/ncrypto/ncrypto.cc index 88898735c5f7..9e2699c1c691 100644 --- a/deps/ncrypto/ncrypto.cc +++ b/deps/ncrypto/ncrypto.cc @@ -3079,7 +3079,9 @@ DataPointer argon2(const Buffer& pass, // per-context. It inherits no configuration, so availability is checked // against the default context, otherwise Argon2 works in FIPS mode. DeleteFnPtr ctx; - if (lanes > 1) { + uint32_t threads = lanes == 0 ? 0 : 1; + if (lanes > 1 && (OSSL_get_thread_support_flags() & + OSSL_THREAD_SUPPORT_FLAG_DEFAULT_SPAWN) != 0) { if (!KDF::Fetch(algorithm.data())) { return {}; } @@ -3089,8 +3091,13 @@ DataPointer argon2(const Buffer& pass, return {}; } - if (OSSL_set_max_threads(ctx.get(), lanes) != 1) { - return {}; + MarkPopErrorOnReturn mark_pop_error_on_return; + if (OSSL_set_max_threads(ctx.get(), lanes) == 1) { + threads = lanes; + } else { + // Lane count is an Argon2 input; worker threads are only an + // optimization. Compute the same lanes serially if unavailable. + ctx.reset(); } } @@ -3108,7 +3115,8 @@ DataPointer argon2(const Buffer& pass, pass.len)); params.push_back(OSSL_PARAM_construct_octet_string( OSSL_KDF_PARAM_SALT, const_cast(salt.data), salt.len)); - params.push_back(OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_THREADS, &lanes)); + params.push_back( + OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_THREADS, &threads)); params.push_back( OSSL_PARAM_construct_uint32(OSSL_KDF_PARAM_ARGON2_LANES, &lanes)); params.push_back( From ee3721d92959e3b14c17b0e5b7cd517f99158dc0 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 22 Sep 2026 22:29:27 +0200 Subject: [PATCH 078/228] crypto: report actual RSA modulus lengths Use the generated key's modulus size for both CryptoKey algorithm objects. Preserve successful backend generation when it rounds the requested size, keeping metadata consistent across export and cloning. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66237 Reviewed-By: James M Snell Reviewed-By: Aviv Keller --- lib/internal/crypto/rsa.js | 11 ++++- .../test-webcrypto-rsa-modulus-length.js | 47 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 test/parallel/test-webcrypto-rsa-modulus-length.js diff --git a/lib/internal/crypto/rsa.js b/lib/internal/crypto/rsa.js index e98fe5cd7b9b..d8e589f9e03e 100644 --- a/lib/internal/crypto/rsa.js +++ b/lib/internal/crypto/rsa.js @@ -32,6 +32,7 @@ const { bigIntArrayToUnsignedInt, getUsagesMask, jobPromise, + jobPromiseThen, normalizeHashName, validateMaxBufferLength, toUsagesSet, @@ -130,7 +131,7 @@ function rsaKeyGenerate( const keyUsages = getKeyPairUsages(usagesSet, allowedUsages); validateUsagesNotEmpty(keyUsages.private); - return jobPromise(() => new RsaKeyPairGenJob( + return jobPromiseThen(jobPromise(() => new RsaKeyPairGenJob( kCryptoJobWebCrypto, kKeyVariantRSA_SSA_PKCS1_v1_5, modulusLength, @@ -138,7 +139,13 @@ function rsaKeyGenerate( keyAlgorithm, getUsagesMask(keyUsages.public), getUsagesMask(keyUsages.private), - extractable)); + extractable)), (result) => { + const { modulusLength: actualModulusLength } = + getCryptoKeyHandle(result.publicKey).keyDetail({ __proto__: null }); + getCryptoKeyAlgorithm(result.publicKey).modulusLength = actualModulusLength; + getCryptoKeyAlgorithm(result.privateKey).modulusLength = actualModulusLength; + return result; + }); } function rsaExportKey(key, format) { diff --git a/test/parallel/test-webcrypto-rsa-modulus-length.js b/test/parallel/test-webcrypto-rsa-modulus-length.js new file mode 100644 index 000000000000..72032ed1a443 --- /dev/null +++ b/test/parallel/test-webcrypto-rsa-modulus-length.js @@ -0,0 +1,47 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { generateKeyPairSync, KeyObject } = require('crypto'); +const { subtle } = globalThis.crypto; + +(async () => { + for (const name of ['RSA-PSS', 'RSASSA-PKCS1-v1_5', 'RSA-OAEP']) { + const usages = name === 'RSA-OAEP' ? ['encrypt', 'decrypt'] : ['sign', 'verify']; + for (const modulusLength of [1025, 2048, 2049]) { + let pair; + try { + pair = await subtle.generateKey({ + name, modulusLength, hash: 'SHA-256', publicExponent: new Uint8Array([1, 0, 1]), + }, true, usages); + } catch (err) { + if (modulusLength === 2048 || err.name !== 'OperationError') + throw err; + // Only reject sizes the backend also rejects. Some backends round + // the requested size, which must still produce a usable CryptoKey. + assert.throws(() => generateKeyPairSync('rsa', { modulusLength }), + { name: 'Error' }); + continue; + } + const actualModulusLength = + KeyObject.from(pair.publicKey).asymmetricKeyDetails.modulusLength; + for (const key of [pair.publicKey, pair.privateKey]) { + assert.strictEqual(key.algorithm.modulusLength, actualModulusLength); + assert.strictEqual( + KeyObject.from(key).asymmetricKeyDetails.modulusLength, actualModulusLength); + assert.deepStrictEqual(structuredClone(key).algorithm, key.algorithm); + + const format = key.type === 'public' ? 'spki' : 'pkcs8'; + const imported = await subtle.importKey( + format, await subtle.exportKey(format, key), + { name, hash: 'SHA-256' }, true, key.usages); + assert.deepStrictEqual(imported.algorithm, key.algorithm); + } + const publicKey = await subtle.getPublicKey(pair.privateKey, pair.publicKey.usages); + assert.deepStrictEqual(publicKey.algorithm, pair.publicKey.algorithm); + } + } +})().then(common.mustCall()); From b61a94a4b56ab4631351eb983236f7e96f7d5381 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 22 Sep 2026 22:32:12 +0200 Subject: [PATCH 079/228] crypto: split hybrid keys without species Create byte views directly when splitting hybrid KEM keys and seeds, so typed-array species cannot alter the component key material. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66237 Reviewed-By: James M Snell Reviewed-By: Aviv Keller --- lib/internal/crypto/kem_hybrids.js | 16 ++++++--- .../test-webcrypto-hybrid-kem-species.js | 36 +++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) create mode 100644 test/parallel/test-webcrypto-hybrid-kem-species.js diff --git a/lib/internal/crypto/kem_hybrids.js b/lib/internal/crypto/kem_hybrids.js index d95a651b61b7..aa8fe27efed9 100644 --- a/lib/internal/crypto/kem_hybrids.js +++ b/lib/internal/crypto/kem_hybrids.js @@ -8,9 +8,9 @@ const { TypedArrayOf, TypedArrayPrototypeGetBuffer, TypedArrayPrototypeGetByteLength, + TypedArrayPrototypeGetByteOffset, TypedArrayPrototypeGetLength, TypedArrayPrototypeSet, - TypedArrayPrototypeSubarray, Uint8Array, } = primordials; @@ -111,6 +111,12 @@ const kEncapsulationUsages = ['encapsulateKey', 'encapsulateBits']; const kDecapsulationUsages = ['decapsulateKey', 'decapsulateBits']; const kUsages = createKeyUsages(kEncapsulationUsages, kDecapsulationUsages); +function getByteView(data, start, end = TypedArrayPrototypeGetByteLength(data)) { + return new Uint8Array(TypedArrayPrototypeGetBuffer(data), + TypedArrayPrototypeGetByteOffset(data) + start, + end - start); +} + /** * Adds lengths that are the concatenation of the PQ KEM component and the * traditional group component. @@ -289,8 +295,8 @@ function validateLength(data, length) { function splitAt(data, offset) { return { __proto__: null, - head: TypedArrayPrototypeSubarray(data, 0, offset), - tail: TypedArrayPrototypeSubarray(data, offset), + head: getByteView(data, 0, offset), + tail: getByteView(data, offset), }; } @@ -353,7 +359,7 @@ function randomScalar(seed, config) { offset + groupScalarLength <= TypedArrayPrototypeGetLength(seed); offset += groupScalarLength) { const scalar = copyBytes( - TypedArrayPrototypeSubarray(seed, offset, offset + groupScalarLength)); + getByteView(seed, offset, offset + groupScalarLength)); const value = os2ip(scalar); if (value !== 0n && value < groupOrder) return scalar; @@ -761,7 +767,7 @@ function combineSharedSecret( config) { // C2PRICombiner(ss_PQ, ss_T, ct_T, ek_T, Label). // https://www.ietf.org/archive/id/draft-irtf-cfrg-hybrid-kems-12.html#section-5.1.3 - const encapsulationKeyT = TypedArrayPrototypeSubarray( + const encapsulationKeyT = getByteView( encapsulationKey, config.kemPqEncapsulationKeyLength); const inputLength = diff --git a/test/parallel/test-webcrypto-hybrid-kem-species.js b/test/parallel/test-webcrypto-hybrid-kem-species.js new file mode 100644 index 000000000000..0521e0b7fc4b --- /dev/null +++ b/test/parallel/test-webcrypto-hybrid-kem-species.js @@ -0,0 +1,36 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { subtle } = globalThis.crypto; + +(async () => { + for (const name of ['MLKEM768-P256', 'MLKEM768-X25519', 'MLKEM1024-P384']) { + if (!SubtleCrypto.supports('generateKey', name)) continue; + const { privateKey, publicKey } = await subtle.generateKey( + name, true, ['encapsulateBits', 'decapsulateBits']); + const seed = await subtle.exportKey('raw-seed', privateKey); + const publicBytes = await subtle.exportKey('raw-public', publicKey); + const descriptor = Object.getOwnPropertyDescriptor(Uint8Array, Symbol.species); + try { + Object.defineProperty(Uint8Array, Symbol.species, { + configurable: true, get: common.mustNotCall(), + }); + const imported = await subtle.importKey('raw-seed', seed, name, true, + ['decapsulateBits']); + assert.deepStrictEqual(await subtle.exportKey('raw-seed', imported), seed); + const publicImported = await subtle.importKey('raw-public', publicBytes, name, + true, ['encapsulateBits']); + assert.deepStrictEqual(await subtle.exportKey('raw-public', publicImported), publicBytes); + } finally { + if (descriptor) { + Object.defineProperty(Uint8Array, Symbol.species, descriptor); + } else { + delete Uint8Array[Symbol.species]; + } + } + } +})().then(common.mustCall()); From 3c36a9cb8fd1f0babd1655054c211fbf16f82cf1 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 22 Sep 2026 22:36:59 +0200 Subject: [PATCH 080/228] crypto: use the public CryptoKey prototype Give generated, imported, converted, and transferred keys the public interface prototype directly, without an intermediate internal object. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66237 Reviewed-By: James M Snell Reviewed-By: Aviv Keller --- lib/internal/crypto/keys.js | 8 +++--- .../test-webcrypto-cryptokey-brand-check.js | 12 ++++----- test/parallel/test-webcrypto-key-prototype.js | 25 +++++++++++++++++++ 3 files changed, 35 insertions(+), 10 deletions(-) create mode 100644 test/parallel/test-webcrypto-key-prototype.js diff --git a/lib/internal/crypto/keys.js b/lib/internal/crypto/keys.js index 88036d1c5ad8..914f05e2f335 100644 --- a/lib/internal/crypto/keys.js +++ b/lib/internal/crypto/keys.js @@ -1167,10 +1167,9 @@ function getKeyObjectAsymmetricKeyDetails(key) { // KEM-only seed data) lives // on a C++ class, NativeCryptoKey, created by createCryptoKeyClass. // InternalCryptoKey is the only constructor we expose to internal -// code; it extends NativeCryptoKey to get that storage and then has -// its prototype spliced so the chain visible to user code is: -// instance -> InternalCryptoKey.prototype -// -> CryptoKey.prototype +// code; it extends NativeCryptoKey to get that storage and gives each +// instance the public interface prototype: +// instance -> CryptoKey.prototype // -> Object.prototype // // Normal construction caches all native internal slots in a private class @@ -1313,6 +1312,7 @@ const { extractable, secondaryHandle, seedData); + ObjectSetPrototypeOf(this, CryptoKey.prototype); if (algorithm !== undefined) { this.#slots = [ handle.getKeyType(), diff --git a/test/parallel/test-webcrypto-cryptokey-brand-check.js b/test/parallel/test-webcrypto-cryptokey-brand-check.js index 9174aebe8f05..bb32dc840186 100644 --- a/test/parallel/test-webcrypto-cryptokey-brand-check.js +++ b/test/parallel/test-webcrypto-cryptokey-brand-check.js @@ -38,11 +38,11 @@ const { subtle } = globalThis.crypto; } assert.strictEqual(isCryptoKey(key), true); assert.strictEqual(Object.hasOwn(CryptoKey, 'getSlots'), false); - const internalProto = Object.getPrototypeOf(key); - assert.strictEqual(Object.hasOwn(internalProto, 'getSlots'), false); - assert.strictEqual('getSlots' in internalProto, false); - assert.strictEqual(internalProto.constructor, CryptoKey); - assert.strictEqual(Object.getPrototypeOf(internalProto), CryptoKey.prototype); + const keyPrototype = Object.getPrototypeOf(key); + assert.strictEqual(Object.hasOwn(keyPrototype, 'getSlots'), false); + assert.strictEqual('getSlots' in keyPrototype, false); + assert.strictEqual(keyPrototype.constructor, CryptoKey); + assert.strictEqual(keyPrototype, CryptoKey.prototype); const invalidThis = { code: 'ERR_INVALID_THIS', name: 'TypeError' }; const invalidArgType = { code: 'ERR_INVALID_ARG_TYPE', name: 'TypeError' }; @@ -93,7 +93,7 @@ const { subtle } = globalThis.crypto; await assertInvalidReceiver(receiver); } - // Prototype spoofing with InternalCryptoKey.prototype must not pass + // Prototype spoofing with CryptoKey.prototype must not pass // util.types.isCryptoKey(). const spoofed = {}; Object.setPrototypeOf(spoofed, Object.getPrototypeOf(key)); diff --git a/test/parallel/test-webcrypto-key-prototype.js b/test/parallel/test-webcrypto-key-prototype.js new file mode 100644 index 000000000000..61c23ae93839 --- /dev/null +++ b/test/parallel/test-webcrypto-key-prototype.js @@ -0,0 +1,25 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { createSecretKey } = require('crypto'); +const { subtle } = globalThis.crypto; + +function check(key) { + assert.strictEqual(Object.getPrototypeOf(key), CryptoKey.prototype); + assert.strictEqual(Object.getPrototypeOf(structuredClone(key)), CryptoKey.prototype); + assert(key instanceof CryptoKey); +} + +(async () => { + check(await subtle.generateKey({ name: 'AES-GCM', length: 128 }, true, ['encrypt'])); + check(await subtle.importKey('raw', new Uint8Array(16), 'AES-GCM', true, ['encrypt'])); + const pair = await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, + true, ['sign', 'verify']); + check(pair.publicKey); + check(pair.privateKey); + check(createSecretKey(new Uint8Array(16)).toCryptoKey('AES-GCM', true, ['encrypt'])); +})().then(common.mustCall()); From 2ac2db2c04221aa05de7bb46f8ce087d9502273d Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 22 Sep 2026 22:36:59 +0200 Subject: [PATCH 081/228] crypto: include EC public keys in PKCS8 exports Export Web Crypto EC private keys from a clone with the public point included, even when the imported encoding omitted it. Preserve the original KeyObject encoding state. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66237 Reviewed-By: James M Snell Reviewed-By: Aviv Keller --- deps/ncrypto/ncrypto.cc | 18 +++++++ deps/ncrypto/ncrypto.h | 1 + lib/internal/crypto/ec.js | 2 +- src/crypto/crypto_keys.cc | 21 ++++++++ src/crypto/crypto_keys.h | 2 + .../test-webcrypto-ec-pkcs8-public-key.js | 49 +++++++++++++++++++ typings/internalBinding/crypto.d.ts | 1 + 7 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 test/parallel/test-webcrypto-ec-pkcs8-public-key.js diff --git a/deps/ncrypto/ncrypto.cc b/deps/ncrypto/ncrypto.cc index 9e2699c1c691..b68355c2541f 100644 --- a/deps/ncrypto/ncrypto.cc +++ b/deps/ncrypto/ncrypto.cc @@ -7533,6 +7533,24 @@ int Ec::getCurve() const { return EC_GROUP_get_curve_name(getGroup()); } +BIOPointer Ec::ExportPrivatePkcs8(const EVPKeyPointer& key) { + MarkPopErrorOnReturn mark_pop_error_on_return; + if (!key || !key.isA(KeyAlgorithm::EC)) return {}; + auto ec = ECKeyPointer(key).clone(); + if (!ec) return {}; +#if NCRYPTO_USE_LEGACY_KEY_TYPES + // Decoding an ECPrivateKey without publicKey reconstructs the public point + // but retains a flag that omits it from subsequent encodings. + EC_KEY_set_enc_flags(ec.get(), + EC_KEY_get_enc_flags(ec.get()) & ~EC_PKEY_NO_PUBKEY); +#endif + auto export_key = EVPKeyPointer::New(); + if (!export_key || !export_key.set(ec)) return {}; + auto encoded = export_key.writePrivateKey({}); + if (!encoded) return {}; + return std::move(encoded.value); +} + DataPointer Ec::TryExportPublic(const EVPKeyPointer& key, point_conversion_form_t form) { if (!key || form != POINT_CONVERSION_UNCOMPRESSED) return {}; diff --git a/deps/ncrypto/ncrypto.h b/deps/ncrypto/ncrypto.h index 3d4ad6482123..f3e8cd10e5df 100644 --- a/deps/ncrypto/ncrypto.h +++ b/deps/ncrypto/ncrypto.h @@ -778,6 +778,7 @@ class Ec final { static DataPointer TryExportPublic(const EVPKeyPointer& key, point_conversion_form_t form); static DataPointer ExportPrivate(const EVPKeyPointer& key); + static BIOPointer ExportPrivatePkcs8(const EVPKeyPointer& key); static bool GetKeyComponents(const EVPKeyPointer& key, BignumPointer* x, BignumPointer* y, diff --git a/lib/internal/crypto/ec.js b/lib/internal/crypto/ec.js index 0f1e0555202d..7f277addd872 100644 --- a/lib/internal/crypto/ec.js +++ b/lib/internal/crypto/ec.js @@ -119,7 +119,7 @@ function ecExportKey(key, format) { } case kWebCryptoKeyFormatPKCS8: { return TypedArrayPrototypeGetBuffer( - handle.export(kKeyFormatDER, kWebCryptoKeyFormatPKCS8, null, null)); + handle.exportECPrivatePkcs8()); } default: return undefined; diff --git a/src/crypto/crypto_keys.cc b/src/crypto/crypto_keys.cc index e67594e05352..1410d2a35f1a 100644 --- a/src/crypto/crypto_keys.cc +++ b/src/crypto/crypto_keys.cc @@ -1138,6 +1138,8 @@ Local KeyObjectHandle::Initialize(Environment* env) { isolate, templ, "exportECPublicRaw", ExportECPublicRaw); SetProtoMethodNoSideEffect( isolate, templ, "exportECPrivateRaw", ExportECPrivateRaw); + SetProtoMethodNoSideEffect( + isolate, templ, "exportECPrivatePkcs8", ExportECPrivatePkcs8); SetProtoMethod(isolate, templ, "keyDetail", GetKeyDetail); SetProtoMethod(isolate, templ, "equals", Equals); @@ -1161,6 +1163,7 @@ void KeyObjectHandle::RegisterExternalReferences( registry->Register(RawSeed); registry->Register(ExportECPublicRaw); registry->Register(ExportECPrivateRaw); + registry->Register(ExportECPrivatePkcs8); registry->Register(GetKeyDetail); registry->Register(Equals); } @@ -1577,6 +1580,24 @@ void KeyObjectHandle::ExportECPrivateRaw( .FromMaybe(Local())); } +void KeyObjectHandle::ExportECPrivatePkcs8( + const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + KeyObjectHandle* key; + ASSIGN_OR_RETURN_UNWRAP(&key, args.This()); + const KeyObjectData& data = key->Data(); + CHECK_EQ(data.GetKeyType(), kKeyTypePrivate); + Mutex::ScopedLock lock(data.mutex()); + auto encoded = ncrypto::Ec::ExportPrivatePkcs8(data.GetAsymmetricKey()); + if (!encoded) { + return THROW_ERR_CRYPTO_OPERATION_FAILED(env, + "Failed to export EC private key"); + } + const EVPKeyPointer::PrivateKeyEncodingConfig config; + args.GetReturnValue().Set( + ToV8Value(env, encoded, config).FromMaybe(Local())); +} + void KeyObjectHandle::RawSeed(const v8::FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); KeyObjectHandle* key; diff --git a/src/crypto/crypto_keys.h b/src/crypto/crypto_keys.h index 9556447c5a52..092e94d1be40 100644 --- a/src/crypto/crypto_keys.h +++ b/src/crypto/crypto_keys.h @@ -181,6 +181,8 @@ class KeyObjectHandle : public BaseObject { const v8::FunctionCallbackInfo& args); static void ExportECPrivateRaw( const v8::FunctionCallbackInfo& args); + static void ExportECPrivatePkcs8( + const v8::FunctionCallbackInfo& args); static void RawSeed(const v8::FunctionCallbackInfo& args); v8::MaybeLocal ExportSecretKey() const; diff --git a/test/parallel/test-webcrypto-ec-pkcs8-public-key.js b/test/parallel/test-webcrypto-ec-pkcs8-public-key.js new file mode 100644 index 000000000000..e88ac6243251 --- /dev/null +++ b/test/parallel/test-webcrypto-ec-pkcs8-public-key.js @@ -0,0 +1,49 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { createPrivateKey, KeyObject } = require('crypto'); +const fixtures = require('../common/fixtures'); +const { subtle } = globalThis.crypto; + +function der(tag, ...parts) { + const body = Buffer.concat(parts); + const length = body.length < 128 ? [body.length] : [0x81, body.length]; + return Buffer.concat([Buffer.from([tag, ...length]), body]); +} + +(async () => { + for (const [curve, oid] of [ + ['p256', '06082a8648ce3d030107'], + ['p384', '06052b81040022'], + ['p521', '06052b81040023'], + ]) { + const privateKey = createPrivateKey(fixtures.readKey(`ec_${curve}_private.pem`)); + const expected = privateKey.export({ type: 'pkcs8', format: 'der' }); + const jwk = privateKey.export({ format: 'jwk' }); + const curveOid = Buffer.from(oid, 'hex'); + const algorithmIdentifier = der( + 0x30, Buffer.from('06072a8648ce3d0201', 'hex'), curveOid); + + for (const includeParameters of [false, true]) { + const ecPrivateKey = der( + 0x30, Buffer.from('020101', 'hex'), der(0x04, Buffer.from(jwk.d, 'base64url')), + includeParameters ? der(0xa0, curveOid) : Buffer.alloc(0)); + const privateOnly = der( + 0x30, Buffer.from('020100', 'hex'), algorithmIdentifier, der(0x04, ecPrivateKey)); + for (const name of ['ECDSA', 'ECDH']) { + const key = await subtle.importKey( + 'pkcs8', privateOnly, { name, namedCurve: jwk.crv }, true, + name === 'ECDSA' ? ['sign'] : ['deriveBits']); + const original = KeyObject.from(key).export({ type: 'pkcs8', format: 'der' }); + const actual = Buffer.from(await subtle.exportKey('pkcs8', key)); + assert.deepStrictEqual(actual, expected); + assert.deepStrictEqual( + KeyObject.from(key).export({ type: 'pkcs8', format: 'der' }), original); + } + } + } +})().then(common.mustCall()); diff --git a/typings/internalBinding/crypto.d.ts b/typings/internalBinding/crypto.d.ts index b31bd413e0b6..67a67d90feda 100644 --- a/typings/internalBinding/crypto.d.ts +++ b/typings/internalBinding/crypto.d.ts @@ -583,6 +583,7 @@ declare namespace InternalCryptoBinding { getAsymmetricKeyType(): string | undefined; getSymmetricKeySize(): number; checkEcKeyData(): boolean; + exportECPrivatePkcs8(): Buffer; } interface NativeKeyObject { From 190de2024406bf570b53f5da010d0858f5bf5c83 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 22 Sep 2026 22:44:50 +0200 Subject: [PATCH 082/228] crypto: map JWK export failures to OperationError Map inaccessible key material to the Web Crypto error type, including seedless ML-DSA and ML-KEM keys converted from KeyObjects. Assisted-by: Codex Signed-off-by: Filip Skokan PR-URL: https://github.com/nodejs/node/pull/66237 Reviewed-By: James M Snell Reviewed-By: Aviv Keller --- lib/internal/crypto/webcrypto.js | 8 ++++- .../test-webcrypto-seedless-jwk-export.js | 36 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 test/parallel/test-webcrypto-seedless-jwk-export.js diff --git a/lib/internal/crypto/webcrypto.js b/lib/internal/crypto/webcrypto.js index 22246d41805b..32a43eedb593 100644 --- a/lib/internal/crypto/webcrypto.js +++ b/lib/internal/crypto/webcrypto.js @@ -801,7 +801,13 @@ function exportKeyJWK(key) { }; if (alg === undefined) delete parameters.alg; - return getCryptoKeyHandle(key).exportJwk(parameters, true); + try { + return getCryptoKeyHandle(key).exportJwk(parameters, true); + } catch (err) { + throw lazyDOMException( + 'The operation failed for an operation-specific reason', + { name: 'OperationError', cause: err }); + } } function exportKeySync(format, key) { diff --git a/test/parallel/test-webcrypto-seedless-jwk-export.js b/test/parallel/test-webcrypto-seedless-jwk-export.js new file mode 100644 index 000000000000..1dcad8c64da3 --- /dev/null +++ b/test/parallel/test-webcrypto-seedless-jwk-export.js @@ -0,0 +1,36 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const { hasOpenSSL, isBoringSSL } = require('../common/crypto'); +if (isBoringSSL || !hasOpenSSL(3, 5)) + common.skip('requires OpenSSL 3.5 or later'); + +const assert = require('assert'); +const { createPrivateKey } = require('crypto'); +const fixtures = require('../common/fixtures'); +const { subtle } = globalThis.crypto; + +(async () => { + const algorithm = { name: 'AES-GCM', iv: new Uint8Array(12) }; + const wrappingKey = await subtle.generateKey({ name: 'AES-GCM', length: 128 }, + false, ['wrapKey']); + for (const [name, usage] of [ + ['ML-KEM-512', 'decapsulateBits'], + ['ML-KEM-768', 'decapsulateBits'], + ['ML-KEM-1024', 'decapsulateBits'], + ['ML-DSA-44', 'sign'], + ['ML-DSA-65', 'sign'], + ['ML-DSA-87', 'sign'], + ]) { + const file = `${name.toLowerCase().replaceAll('-', '_')}_private_priv_only.pem`; + const key = createPrivateKey(fixtures.readKey(file)).toCryptoKey(name, true, [usage]); + await assert.rejects(subtle.exportKey('jwk', key), { + name: 'OperationError', constructor: DOMException, + }); + await assert.rejects(subtle.wrapKey('jwk', key, wrappingKey, algorithm), { + name: 'OperationError', constructor: DOMException, + }); + } +})().then(common.mustCall()); From e8155893f99fa0eb381503e1047eb80b8d47fd0b Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 22 Sep 2026 23:18:41 +0200 Subject: [PATCH 083/228] crypto: check EC coordinate conversion results Propagate coordinate conversion failures instead of importing an oversized JWK coordinate as zero. Keep equivalent short and zero-padded integer encodings accepted by the native decoder. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66237 Reviewed-By: James M Snell Reviewed-By: Aviv Keller --- deps/ncrypto/ncrypto.cc | 12 +++-- .../test-crypto-ec-jwk-coordinates.js | 53 +++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 test/parallel/test-crypto-ec-jwk-coordinates.js diff --git a/deps/ncrypto/ncrypto.cc b/deps/ncrypto/ncrypto.cc index b68355c2541f..336f8fcfc995 100644 --- a/deps/ncrypto/ncrypto.cc +++ b/deps/ncrypto/ncrypto.cc @@ -6091,8 +6091,10 @@ bool ECKeyPointer::setPublicKeyRaw(const BignumPointer& x, if (!buf) return false; unsigned char* ptr = static_cast(buf.get()); ptr[0] = POINT_CONVERSION_UNCOMPRESSED; - x.encodePaddedInto(ptr + 1, field_len); - y.encodePaddedInto(ptr + 1 + field_len, field_len); + if (x.encodePaddedInto(ptr + 1, field_len) != field_len || + y.encodePaddedInto(ptr + 1 + field_len, field_len) != field_len) { + return false; + } auto point = ECPointPointer::New(group); if (!point) return false; @@ -6320,8 +6322,10 @@ bool ECKeyPointer::setPublicKeyRaw(const BignumPointer& x, if (!buf) return false; unsigned char* ptr = static_cast(buf.get()); ptr[0] = POINT_CONVERSION_UNCOMPRESSED; - x.encodePaddedInto(ptr + 1, field_len); - y.encodePaddedInto(ptr + 1 + field_len, field_len); + if (x.encodePaddedInto(ptr + 1, field_len) != field_len || + y.encodePaddedInto(ptr + 1 + field_len, field_len) != field_len) { + return false; + } auto point = ECPointPointer::New(group_.get()); if (!point || !point.setFromBuffer({ptr, uncompressed_len}, group_.get())) { diff --git a/test/parallel/test-crypto-ec-jwk-coordinates.js b/test/parallel/test-crypto-ec-jwk-coordinates.js new file mode 100644 index 000000000000..8ae3d79e6a3d --- /dev/null +++ b/test/parallel/test-crypto-ec-jwk-coordinates.js @@ -0,0 +1,53 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { createPublicKey, subtle } = require('crypto'); + +// This is the valid P-256 point (0, sqrt(b)). A failed conversion of an +// oversized x coordinate must not silently replace it with zero. +const jwk = { + kty: 'EC', + crv: 'P-256', + x: Buffer.alloc(32).toString('base64url'), + y: 'ZkhceA4vg9ckM71dhKBrtlQcKvMdrocXKL-FahdPk_Q', +}; + +(async () => { + for (const field of ['x', 'y']) { + const invalid = { ...jwk, [field]: Buffer.alloc(33, 1).toString('base64url') }; + assert.throws(() => createPublicKey({ key: invalid, format: 'jwk' }), { + code: 'ERR_CRYPTO_INVALID_JWK', + }); + for (const name of ['ECDSA', 'ECDH']) { + await assert.rejects(subtle.importKey( + 'jwk', invalid, { name, namedCurve: 'P-256' }, true, + name === 'ECDSA' ? ['verify'] : []), { name: 'DataError' }); + } + } + + // Equivalent integer encodings remain accepted by the existing decoder. + for (const encoded of [ + jwk, + { ...jwk, x: Buffer.alloc(1).toString('base64url') }, + { + ...jwk, + x: Buffer.alloc(33).toString('base64url'), + y: Buffer.concat([Buffer.alloc(1), Buffer.from(jwk.y, 'base64url')]).toString('base64url'), + }, + ]) { + const publicKey = createPublicKey({ key: encoded, format: 'jwk' }); + assert.deepStrictEqual(publicKey.export({ format: 'jwk' }), jwk); + for (const name of ['ECDSA', 'ECDH']) { + const key = await subtle.importKey( + 'jwk', encoded, { name, namedCurve: 'P-256' }, true, + name === 'ECDSA' ? ['verify'] : []); + const exported = await subtle.exportKey('jwk', key); + assert.strictEqual(exported.x, jwk.x); + assert.strictEqual(exported.y, jwk.y); + } + } +})().then(common.mustCall()); From b2ba5b781dafb10421b530edb9f6dee3d63c6534 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Wed, 23 Sep 2026 11:11:50 +0200 Subject: [PATCH 084/228] benchmark: cover Web Crypto conversion costs Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66237 Reviewed-By: James M Snell Reviewed-By: Aviv Keller --- benchmark/crypto/webcrypto-export.js | 47 +++++++++++ benchmark/misc/webcrypto-util.js | 117 +++++++++++++++++++++++++++ benchmark/misc/webcrypto-webidl.js | 59 +++++++++++++- 3 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 benchmark/crypto/webcrypto-export.js create mode 100644 benchmark/misc/webcrypto-util.js diff --git a/benchmark/crypto/webcrypto-export.js b/benchmark/crypto/webcrypto-export.js new file mode 100644 index 000000000000..4d96b54ec74d --- /dev/null +++ b/benchmark/crypto/webcrypto-export.js @@ -0,0 +1,47 @@ +'use strict'; + +const common = require('../common.js'); +const fixtures = require('../../test/common/fixtures.js'); +const { createPrivateKey, createPublicKey, subtle } = require('node:crypto'); + +const bench = common.createBenchmark(main, { + keyType: ['hmac', 'aes-gcm', 'ecdsa-private', 'rsa-private', 'rsa-public'], + n: [1e5], +}); + +async function createKey(keyType) { + switch (keyType) { + case 'hmac': + return subtle.importKey( + 'raw', new Uint8Array(32), { name: 'HMAC', hash: 'SHA-256' }, + true, ['sign', 'verify']); + case 'aes-gcm': + return subtle.importKey( + 'raw', new Uint8Array(32), 'AES-GCM', + true, ['encrypt', 'decrypt', 'wrapKey', 'unwrapKey']); + case 'ecdsa-private': + return createPrivateKey(fixtures.readKey('ec_p256_private.pem')) + .toCryptoKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign']); + case 'rsa-private': + return createPrivateKey(fixtures.readKey('rsa_private_2048.pem')) + .toCryptoKey({ name: 'RSA-PSS', hash: 'SHA-256' }, true, ['sign']); + case 'rsa-public': + return createPublicKey(fixtures.readKey('rsa_private_2048.pem')) + .toCryptoKey({ name: 'RSA-PSS', hash: 'SHA-256' }, true, ['verify']); + default: + throw new Error(`Unsupported key type: ${keyType}`); + } +} + +async function main({ n, keyType }) { + const key = await createKey(keyType); + let result; + + bench.start(); + for (let i = 0; i < n; i++) + result = await subtle.exportKey('jwk', key); + bench.end(n); + + if (result.kty === undefined) + throw new Error('Missing key type'); +} diff --git a/benchmark/misc/webcrypto-util.js b/benchmark/misc/webcrypto-util.js new file mode 100644 index 000000000000..581602ab347f --- /dev/null +++ b/benchmark/misc/webcrypto-util.js @@ -0,0 +1,117 @@ +'use strict'; + +const common = require('../common.js'); + +const inputs = [ + 'arraybuffer', + 'uint8array', + 'dataview', + 'buffer', + 'sharedview', + 'empty-arraybuffer', + 'empty-uint8array', + 'empty-dataview', + 'detached-arraybuffer', + 'detached-uint8array', + 'detached-dataview', +]; + +const bench = common.createBenchmark(main, { + op: [ + ...inputs.flatMap((input) => [`byteLength:${input}`, `bytes:${input}`]), + 'truncate:arraybuffer:100', + 'truncate:arraybuffer:128', + 'truncate:uint8array:100', + 'truncate:uint8array:128', + 'usages:0', + 'usages:1', + 'usages:2', + 'usages:4', + ], + n: [1e6], +}, { flags: ['--expose-internals'] }); + +function createInput(input) { + const empty = input.startsWith('empty-'); + const detached = input.startsWith('detached-'); + const type = input.replace(/^(empty|detached)-/, ''); + const size = empty ? 0 : 32; + const buffer = new ArrayBuffer(size + 16); + let value; + switch (type) { + case 'arraybuffer': + value = new ArrayBuffer(size); + break; + case 'uint8array': + value = new Uint8Array(buffer, 8, size); + break; + case 'dataview': + value = new DataView(buffer, 8, size); + break; + case 'buffer': + value = Buffer.from(buffer, 8, size); + break; + case 'sharedview': + value = new Uint8Array(new SharedArrayBuffer(size)); + break; + default: + throw new Error(`Unsupported input: ${input}`); + } + if (detached) { + const backing = type === 'arraybuffer' ? value : buffer; + structuredClone(backing, { transfer: [backing] }); + } + return value; +} + +function main({ n, op }) { + const { + getBufferSourceByteLength, + getBufferSourceBytes, + getUsagesFromMask, + getUsagesMask, + truncateToBitLength, + } = require('internal/crypto/util'); + const [operation, input, length] = op.split(':'); + let run; + switch (operation) { + case 'byteLength': { + const value = createInput(input); + run = () => getBufferSourceByteLength(value); + break; + } + case 'bytes': { + const value = createInput(input); + run = () => getBufferSourceBytes(value); + break; + } + case 'truncate': { + const value = createInput(input); + const bits = Number(length); + run = () => truncateToBitLength(bits, value); + break; + } + case 'usages': { + const usages = { + 0: [], + 1: ['sign'], + 2: ['sign', 'verify'], + 4: ['encrypt', 'decrypt', 'wrapKey', 'unwrapKey'], + }; + const mask = getUsagesMask(usages[input]); + run = () => getUsagesFromMask(mask); + break; + } + default: + throw new Error(`Unsupported operation: ${operation}`); + } + + let result; + bench.start(); + for (let i = 0; i < n; i++) + result = run(); + bench.end(n); + + if (result === undefined) + throw new Error('Missing benchmark result'); +} diff --git a/benchmark/misc/webcrypto-webidl.js b/benchmark/misc/webcrypto-webidl.js index 0f6275ed0951..318ea7982071 100644 --- a/benchmark/misc/webcrypto-webidl.js +++ b/benchmark/misc/webcrypto-webidl.js @@ -6,6 +6,13 @@ const bench = common.createBenchmark(main, { op: [ 'normalizeAlgorithm-string', 'normalizeAlgorithm-dict', + 'normalizeAlgorithm-validate-aes-gcm', + 'normalizeAlgorithm-validate-aes-cbc', + 'normalizeAlgorithm-validate-aes-ctr', + 'normalizeAlgorithm-validate-aes-generate', + 'normalizeAlgorithm-validate-hkdf', + 'normalizeAlgorithm-validate-hmac', + 'normalizeAlgorithm-validate-rsa', 'webidl-dict', 'webidl-algorithm-identifier-string', 'webidl-algorithm-identifier-object', @@ -17,7 +24,7 @@ const bench = common.createBenchmark(main, { }, { flags: ['--expose-internals'] }); function main({ n, op }) { - const { normalizeAlgorithm } = require('internal/crypto/util'); + const { normalizeAlgorithm, validateAlgorithm } = require('internal/crypto/util'); switch (op) { case 'normalizeAlgorithm-string': { @@ -37,6 +44,54 @@ function main({ n, op }) { bench.end(n); break; } + case 'normalizeAlgorithm-validate-aes-gcm': + case 'normalizeAlgorithm-validate-aes-cbc': + case 'normalizeAlgorithm-validate-aes-ctr': + case 'normalizeAlgorithm-validate-aes-generate': + case 'normalizeAlgorithm-validate-hkdf': + case 'normalizeAlgorithm-validate-hmac': + case 'normalizeAlgorithm-validate-rsa': { + const cases = { + 'aes-gcm': [ + { name: 'AES-GCM', iv: new Uint8Array(12), tagLength: 128 }, + 'encrypt', + ], + 'aes-cbc': [ + { name: 'AES-CBC', iv: new Uint8Array(16) }, + 'encrypt', + ], + 'aes-ctr': [ + { name: 'AES-CTR', counter: new Uint8Array(16), length: 64 }, + 'encrypt', + ], + 'aes-generate': [{ name: 'AES-GCM', length: 256 }, 'generateKey'], + 'hkdf': [ + { + name: 'HKDF', hash: 'SHA-256', + salt: new Uint8Array(32), info: new Uint8Array(32), + }, + 'deriveBits', + ], + 'hmac': [{ name: 'HMAC', hash: 'SHA-256', length: 256 }, 'importKey'], + 'rsa': [ + { + name: 'RSA-PSS', hash: 'SHA-256', modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + }, + 'generateKey', + ], + }; + const name = op.slice('normalizeAlgorithm-validate-'.length); + const [input, operation] = cases[name]; + bench.start(); + for (let i = 0; i < n; i++) { + const normalized = normalizeAlgorithm(input, operation); + // Older revisions validate inside normalizeAlgorithm. + validateAlgorithm?.(normalized, operation); + } + bench.end(n); + break; + } case 'webidl-dict': { // WebIDL dictionary converter in isolation. const webidl = require('internal/crypto/webidl'); @@ -85,7 +140,7 @@ function main({ n, op }) { break; } case 'webidl-dict-ensure-sha': { - // Exercises ensureSHA on a hash member. + // Converts a dictionary containing a hash identifier. const webidl = require('internal/crypto/webidl'); const input = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }; const opts = { prefix: 'test', context: 'test' }; From 8d904d464bc755dc0c1afa1b453a32171601764b Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Wed, 23 Sep 2026 14:38:13 +0200 Subject: [PATCH 085/228] crypto: verify empty KMAC outputs Compare empty KMAC outputs with empty signatures as equal, as required by the verification algorithm. Skip CRYPTO_memcmp for zero-length buffers while retaining the constant-time comparison for other MACs. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66237 Reviewed-By: James M Snell Reviewed-By: Aviv Keller --- src/crypto/crypto_kmac.cc | 7 ++-- .../test-webcrypto-kmac-empty-output.js | 42 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 test/parallel/test-webcrypto-kmac-empty-output.js diff --git a/src/crypto/crypto_kmac.cc b/src/crypto/crypto_kmac.cc index d346d50fc070..292dedf6ccc8 100644 --- a/src/crypto/crypto_kmac.cc +++ b/src/crypto/crypto_kmac.cc @@ -273,9 +273,10 @@ MaybeLocal KmacTraits::EncodeOutput(Environment* env, case SignConfiguration::Mode::Verify: return Boolean::New( env->isolate(), - out->size() > 0 && out->size() == params.signature.size() && - CRYPTO_memcmp( - out->data(), params.signature.data(), out->size()) == 0); + out->size() == params.signature.size() && + (out->size() == 0 || + CRYPTO_memcmp( + out->data(), params.signature.data(), out->size()) == 0)); } UNREACHABLE(); } diff --git a/test/parallel/test-webcrypto-kmac-empty-output.js b/test/parallel/test-webcrypto-kmac-empty-output.js new file mode 100644 index 000000000000..fe88f7ab7692 --- /dev/null +++ b/test/parallel/test-webcrypto-kmac-empty-output.js @@ -0,0 +1,42 @@ +'use strict'; + +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const { hasFIPS, hasOpenSSL } = require('../common/crypto'); + +if (!hasOpenSSL(3)) + common.skip('requires OpenSSL >= 3'); +if (hasFIPS()) + common.skip('empty KMAC output is not supported in FIPS mode'); + +const assert = require('assert'); +const { subtle } = globalThis.crypto; + +(async () => { + const data = new Uint8Array([1, 2, 3]); + + for (const name of ['KMAC128', 'KMAC256']) { + const key = await subtle.importKey( + 'raw-secret', new Uint8Array(32), name, false, ['sign', 'verify']); + const otherKey = await subtle.importKey( + 'raw-secret', new Uint8Array(32).fill(1), name, false, ['verify']); + const algorithm = { name, outputLength: 0 }; + const signature = await subtle.sign(algorithm, key, data); + + assert.strictEqual(signature.byteLength, 0); + assert.strictEqual(await subtle.verify(algorithm, key, signature, data), true); + assert.strictEqual(await subtle.verify(algorithm, key, new Uint8Array([0]), data), false); + assert.strictEqual(await subtle.verify( + { name, outputLength: 256 }, key, signature, data), false); + + // Empty MACs compare equal even when the inputs differ. + assert.strictEqual(await subtle.verify( + algorithm, key, signature, new Uint8Array([4, 5, 6])), true); + assert.strictEqual(await subtle.verify(algorithm, otherKey, signature, data), true); + assert.strictEqual(await subtle.verify( + { ...algorithm, customization: new Uint8Array([1]) }, key, signature, data), true); + } +})().then(common.mustCall()); From cd0126b34c4e69e80985651dcc9da9e628b900df Mon Sep 17 00:00:00 2001 From: Xia Chao Date: Fri, 25 Sep 2026 06:49:29 -0700 Subject: [PATCH 086/228] zlib: reject reset while a zstd frame is incomplete Resetting a ZstdCompress stream while a frame is still in progress dropped the frame state, but any bytes already written out stayed at the start of the output stream. The next frame was then appended to that fragment, so the resulting stream could not be decompressed. The failure was silent: the compressor reported no error at all. ZSTD_reset_session_only cancels unflushed internal data, so refuse reset only when the incomplete frame has already emitted output. Signed-off-by: Xia Chao PR-URL: https://github.com/nodejs/node/pull/66088 Reviewed-By: James M Snell --- doc/api/zlib.md | 6 + src/node_zlib.cc | 48 +++++-- .../test-zlib-zstd-reset-incomplete-frame.js | 129 ++++++++++++++++++ 3 files changed, 175 insertions(+), 8 deletions(-) create mode 100644 test/parallel/test-zlib-zstd-reset-incomplete-frame.js diff --git a/doc/api/zlib.md b/doc/api/zlib.md index db37b7b4a85b..cf2ca441ff23 100644 --- a/doc/api/zlib.md +++ b/doc/api/zlib.md @@ -1098,6 +1098,12 @@ configured for a Zstd compressor, it applies again to the next frame. Calling `reset()` while a write is in progress throws an `Error`. +Resetting an incomplete Zstd compression frame after it has emitted output +causes the stream to error with `ERR_ZLIB_INCOMPLETE_FRAME`. Resetting at +that point would discard the frame state while the bytes already written +out remain at the start of the output stream, leaving it undecodable. Call +`.end()`, or start over with a new stream, instead. + ## Class: `ZstdOptions` > Stability: 1 - Experimental diff --git a/src/node_zlib.cc b/src/node_zlib.cc index 60e54cf94935..85e2a2437784 100644 --- a/src/node_zlib.cc +++ b/src/node_zlib.cc @@ -352,6 +352,14 @@ class ZstdCompressContext final : public ZstdContext { uint64_t pledged_src_size_ = ZSTD_CONTENTSIZE_UNKNOWN; std::optional consumed_src_size_; + + // A frame is complete once ZSTD_compressStream2() has been called with + // ZSTD_e_end and has returned 0. Resetting an incomplete frame is only unsafe + // once some of that frame has already been written out: those bytes cannot be + // discarded, and the next frame would be appended to the fragment. Unflushed + // internal state alone is cancelled by ZSTD_reset_session_only. + bool frame_complete_ = true; + bool frame_output_emitted_ = false; }; class ZstdDecompressContext final : public ZstdContext { @@ -1678,6 +1686,8 @@ CompressionError ZstdCompressContext::Init(uint64_t pledged_src_size, std::string_view dictionary, bool) { pledged_src_size_ = pledged_src_size; + frame_complete_ = true; + frame_output_emitted_ = false; if (pledged_src_size == ZSTD_CONTENTSIZE_UNKNOWN) { consumed_src_size_.reset(); } else { @@ -1718,6 +1728,17 @@ CompressionError ZstdCompressContext::Init(uint64_t pledged_src_size, } CompressionError ZstdCompressContext::ResetStream() { + // ZSTD_reset_session_only cancels unflushed internal data. Bytes that have + // already been written out cannot be taken back, so refuse reset only when + // the current incomplete frame has already emitted output. + if (!frame_complete_ && frame_output_emitted_) { + return CompressionError( + "Cannot reset a zstd stream with an incomplete frame; end the frame " + "or discard the output produced so far", + "ERR_ZLIB_INCOMPLETE_FRAME", + ZSTD_error_stage_wrong); + } + size_t result = ZSTD_CCtx_reset(cctx_.get(), ZSTD_reset_session_only); if (ZSTD_isError(result)) { const ZSTD_ErrorCode error = ZSTD_getErrorCode(result); @@ -1737,6 +1758,8 @@ CompressionError ZstdCompressContext::ResetStream() { } else { consumed_src_size_ = 0; } + frame_complete_ = true; + frame_output_emitted_ = false; error_ = ZSTD_error_no_error; error_string_.clear(); error_code_string_.clear(); @@ -1751,19 +1774,28 @@ void ZstdCompressContext::DoThreadPoolWork() { if (consumed_src_size_.has_value()) { *consumed_src_size_ += input_.pos - input_pos; } + if (output_.pos > 0) { + frame_output_emitted_ = true; + } if (ZSTD_isError(remaining)) { error_ = ZSTD_getErrorCode(remaining); error_code_string_ = ZstdStrerror(error_); error_string_ = ZSTD_getErrorString(error_); - } else if (remaining == 0 && flush_ == ZSTD_e_end && - consumed_src_size_.has_value()) { - uint64_t const consumed_src_size = *consumed_src_size_; - consumed_src_size_.reset(); - if (consumed_src_size != pledged_src_size_) { - error_ = ZSTD_error_srcSize_wrong; - error_code_string_ = ZstdStrerror(error_); - error_string_ = ZSTD_getErrorString(error_); + frame_complete_ = false; + } else if (remaining == 0 && flush_ == ZSTD_e_end) { + frame_complete_ = true; + frame_output_emitted_ = false; + if (consumed_src_size_.has_value()) { + uint64_t const consumed_src_size = *consumed_src_size_; + consumed_src_size_.reset(); + if (consumed_src_size != pledged_src_size_) { + error_ = ZSTD_error_srcSize_wrong; + error_code_string_ = ZstdStrerror(error_); + error_string_ = ZSTD_getErrorString(error_); + } } + } else { + frame_complete_ = false; } } diff --git a/test/parallel/test-zlib-zstd-reset-incomplete-frame.js b/test/parallel/test-zlib-zstd-reset-incomplete-frame.js new file mode 100644 index 000000000000..587155d26c45 --- /dev/null +++ b/test/parallel/test-zlib-zstd-reset-incomplete-frame.js @@ -0,0 +1,129 @@ +'use strict'; + +// Tests that reset() refuses to run only when an incomplete zstd frame has +// already emitted output. +// +// ZSTD_reset_session_only cancels unflushed internal data, so write()-then- +// reset() with no emitted bytes is safe. Once flush() (or a write that filled +// the output buffer) has written a fragment out, resetting would append the +// next frame to that fragment and produce an undecodable stream. + +require('../common'); +const assert = require('assert'); +const { finished } = require('stream/promises'); +const test = require('node:test'); +const zlib = require('zlib'); + +test('ZstdCompress reset throws when an incomplete frame has emitted output', + async () => { + const stream = zlib.createZstdCompress(); + const chunks = []; + stream.on('data', (chunk) => chunks.push(chunk)); + + stream.write(Buffer.from('hello')); + await new Promise((resolve) => stream.flush(resolve)); + assert.ok(Buffer.concat(chunks).length > 0); + + // A fragment of the frame is already outside the compressor, so reset() + // must refuse instead of silently producing a stream that cannot be + // decoded. + stream.reset(); + stream.end(Buffer.from('world')); + + await assert.rejects(finished(stream), { + code: 'ERR_ZLIB_INCOMPLETE_FRAME', + }); + }); + +test('ZstdCompress reset throws when write itself emitted frame output', + async () => { + const stream = zlib.createZstdCompress(); + const chunks = []; + stream.on('data', (chunk) => chunks.push(chunk)); + + // Fill a buffer that is large enough for zstd to emit compressed bytes + // during write(), without an explicit flush(). + const input = Buffer.allocUnsafe(512 * 1024); + for (let i = 0; i < input.length; i++) { + input[i] = i & 0xff; + } + await new Promise((resolve, reject) => { + stream.write(input, (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + assert.ok(Buffer.concat(chunks).length > 0); + + stream.reset(); + stream.end(Buffer.from('world')); + + await assert.rejects(finished(stream), { + code: 'ERR_ZLIB_INCOMPLETE_FRAME', + }); + }); + +test('ZstdCompress reset after write without emitted output still works', + async () => { + const stream = zlib.createZstdCompress(); + const chunks = []; + stream.on('data', (chunk) => chunks.push(chunk)); + + // Small writes often stay buffered inside zstd until flush/end. + await new Promise((resolve, reject) => { + stream.write(Buffer.from('hello'), (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + assert.strictEqual(Buffer.concat(chunks).length, 0); + + // No bytes have left the compressor, so session reset is allowed. + stream.reset(); + stream.end(Buffer.from('world')); + await finished(stream); + + assert.strictEqual( + zlib.zstdDecompressSync(Buffer.concat(chunks)).toString(), + 'world', + ); + }); + +test('ZstdCompress flush followed by end still produces a valid stream', + async () => { + const stream = zlib.createZstdCompress(); + const chunks = []; + stream.on('data', (chunk) => chunks.push(chunk)); + + stream.write(Buffer.from('hello')); + await new Promise((resolve) => stream.flush(resolve)); + stream.end(Buffer.from('world')); + await finished(stream); + + assert.strictEqual( + zlib.zstdDecompressSync(Buffer.concat(chunks)).toString(), + 'helloworld', + ); + }); + +test('ZstdCompress reset before any write still works', async () => { + const stream = zlib.createZstdCompress(); + const chunks = []; + stream.on('data', (chunk) => chunks.push(chunk)); + + // No frame has been started yet, so reset() is allowed. + stream.reset(); + stream.end(Buffer.from('hello')); + await finished(stream); + + assert.strictEqual( + zlib.zstdDecompressSync(Buffer.concat(chunks)).toString(), + 'hello', + ); +}); From 0514b1dc08a50fe5d853adca5cc2f42c23423a9f Mon Sep 17 00:00:00 2001 From: Hilary Asogwa <76795262+Dansatch@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:49:42 +0100 Subject: [PATCH 087/228] module: normalize package map paths Signed-off-by: dansatch PR-URL: https://github.com/nodejs/node/pull/66054 Fixes: https://github.com/nodejs/node/issues/66043 Reviewed-By: Antoine du Hamel Reviewed-By: Marco Ippolito Reviewed-By: James M Snell --- lib/internal/modules/package_map.js | 2 +- test/es-module/test-esm-package-map-paths.mjs | 12 +++++ .../map-dir/package-map-parent-url.json | 12 +++++ .../package-map-directory-urls.json | 12 +++++ .../package-map-duplicate-directory-path.json | 16 ++++++ test/parallel/test-require-package-map.js | 51 +++++++++++++++++++ 6 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/package-map/nested-project/map-dir/package-map-parent-url.json create mode 100644 test/fixtures/package-map/package-map-directory-urls.json create mode 100644 test/fixtures/package-map/package-map-duplicate-directory-path.json diff --git a/lib/internal/modules/package_map.js b/lib/internal/modules/package_map.js index 38e7e12206c9..1e01eb389a21 100644 --- a/lib/internal/modules/package_map.js +++ b/lib/internal/modules/package_map.js @@ -105,7 +105,7 @@ class PackageMap { let absolutePath; try { - absolutePath = fileURLToPath(packageURL); + absolutePath = pathResolve(fileURLToPath(packageURL)); } catch (err) { const error = new ERR_PACKAGE_MAP_INVALID( this.#configPath, diff --git a/test/es-module/test-esm-package-map-paths.mjs b/test/es-module/test-esm-package-map-paths.mjs index fc7c45c631ad..611fe833314e 100644 --- a/test/es-module/test-esm-package-map-paths.mjs +++ b/test/es-module/test-esm-package-map-paths.mjs @@ -27,6 +27,18 @@ spawnSyncAndAssert(process.execPath, [ trim: true, }); +// Directory-form URLs match package files even when the map is nested below them. +spawnSyncAndAssert(process.execPath, [ + '--no-warnings', + '--experimental-package-map', + fixtures.path('package-map/nested-project/map-dir/package-map-parent-url.json'), + '--input-type=module', + '--eval', `import dep from 'dep-a'; console.log(dep);`, +], { cwd: fixtures.path('package-map/nested-project/src') }, { + stdout: /dep-a-value/, + trim: true, +}); + // URL fields are decoded as URLs before being converted to paths. spawnSyncAndAssert(process.execPath, [ '--no-warnings', diff --git a/test/fixtures/package-map/nested-project/map-dir/package-map-parent-url.json b/test/fixtures/package-map/nested-project/map-dir/package-map-parent-url.json new file mode 100644 index 000000000000..b1647cc8877d --- /dev/null +++ b/test/fixtures/package-map/nested-project/map-dir/package-map-parent-url.json @@ -0,0 +1,12 @@ +{ + "packages": { + "app": { + "url": "..", + "dependencies": {"dep-a": "dep-a"} + }, + "dep-a": { + "url": "../../dep-a/", + "dependencies": {} + } + } +} diff --git a/test/fixtures/package-map/package-map-directory-urls.json b/test/fixtures/package-map/package-map-directory-urls.json new file mode 100644 index 000000000000..ef24742fa6c4 --- /dev/null +++ b/test/fixtures/package-map/package-map-directory-urls.json @@ -0,0 +1,12 @@ +{ + "packages": { + "root": { + "url": "./root/", + "dependencies": {"dep-a": "dep-a"} + }, + "dep-a": { + "url": "./dep-a/", + "dependencies": {} + } + } +} diff --git a/test/fixtures/package-map/package-map-duplicate-directory-path.json b/test/fixtures/package-map/package-map-duplicate-directory-path.json new file mode 100644 index 000000000000..e29d7c955b16 --- /dev/null +++ b/test/fixtures/package-map/package-map-duplicate-directory-path.json @@ -0,0 +1,16 @@ +{ + "packages": { + "root": { + "url": "./root", + "dependencies": {"dep-a": "dep-a"} + }, + "dep-a": { + "url": "./dep-a", + "dependencies": {} + }, + "dep-a-directory": { + "url": "./dep-a/", + "dependencies": {} + } + } +} diff --git a/test/parallel/test-require-package-map.js b/test/parallel/test-require-package-map.js index 87786d62738a..08ec289346a6 100644 --- a/test/parallel/test-require-package-map.js +++ b/test/parallel/test-require-package-map.js @@ -32,6 +32,40 @@ writeFileSync(fileUrlFixturePath, JSON.stringify({ describe('CJS: --experimental-package-map', { concurrency: !process.env.TEST_PARALLEL }, () => { describe('basic resolution', () => { + it('resolves dependencies from a parent directory URL', () => { + const { status, stdout, stderr } = spawnSync(process.execPath, [ + '--no-warnings', + '--experimental-package-map', + fixtures.path('package-map/nested-project/map-dir/package-map-parent-url.json'), + '-e', + `const dep = require('dep-a'); console.log(dep.default);`, + ], { + cwd: fixtures.path('package-map/nested-project/src'), + encoding: 'utf8', + }); + + assert.strictEqual(stderr, ''); + assert.match(stdout, /dep-a-value/); + assert.strictEqual(status, 0, stderr); + }); + + it('resolves packages with directory-form URLs', () => { + const { status, stdout, stderr } = spawnSync(process.execPath, [ + '--no-warnings', + '--experimental-package-map', + fixtures.path('package-map/package-map-directory-urls.json'), + '-e', + `const dep = require('dep-a'); console.log(dep.default);`, + ], { + cwd: fixtures.path('package-map/root'), + encoding: 'utf8', + }); + + assert.strictEqual(stderr, ''); + assert.match(stdout, /dep-a-value/); + assert.strictEqual(status, 0, stderr); + }); + it('resolves require() through package map', () => { const { status, stdout, stderr } = spawnSync(process.execPath, [ '--no-warnings', @@ -197,6 +231,23 @@ describe('CJS: --experimental-package-map', { concurrency: !process.env.TEST_PAR assert.match(stderr, /pkg-b/); assert.notStrictEqual(status, 0, stderr); }); + + it('throws for package URLs differing only by a trailing separator', () => { + const { status, stderr } = spawnSync(process.execPath, [ + '--no-warnings', + '--experimental-package-map', + fixtures.path('package-map/package-map-duplicate-directory-path.json'), + '-e', + `require('dep-a');`, + ], { + cwd: fixtures.path('package-map/root'), + encoding: 'utf8', + }); + + assert.match(stderr, /ERR_PACKAGE_MAP_INVALID/); + assert.match(stderr, /dep-a-directory/); + assert.notStrictEqual(status, 0, stderr); + }); }); describe('conditional exports', () => { From 5e0a0b1766b5e26597d812683c5df03bc3333f4a Mon Sep 17 00:00:00 2001 From: Romain Lanz <2793951+RomainLanz@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:49:56 +0200 Subject: [PATCH 088/228] url: create URLSearchParams collision map lazily Object property keys are already unique unless USVString normalization changes a key. Avoid allocating and updating a SafeMap for ordinary record keys. Create and backfill it when normalization first changes a key. This improves object construction by 10% to 25% in the measured cases and reduces allocation by about 31% for a ten-property record. Assisted-by: Amp Signed-off-by: Romain Lanz PR-URL: https://github.com/nodejs/node/pull/65801 Reviewed-By: James M Snell --- lib/internal/url.js | 18 +++++++++++++++--- ...atwg-url-custom-searchparams-constructor.js | 13 +++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/lib/internal/url.js b/lib/internal/url.js index f58a26d94e7e..de5925b26a41 100644 --- a/lib/internal/url.js +++ b/lib/internal/url.js @@ -443,7 +443,9 @@ class URLSearchParams { } else { // Record // Need to use reflection APIs for full spec compliance. - const visited = new SafeMap(); + // Object keys are unique, but USVString normalization may merge + // distinct keys. Lazily track normalized keys if a key changes. + let visited; const keys = ReflectOwnKeys(init); for (let i = 0; i < keys.length; i++) { const key = keys[i]; @@ -452,8 +454,18 @@ class URLSearchParams { const typedKey = toUSVString(key); const typedValue = toUSVString(init[key]); - // Two different keys may become the same USVString after normalization. - // In that case, we retain the later one. Refer to WPT. + if (visited === undefined && typedKey === key) { + ArrayPrototypePush(this.#searchParams, typedKey, typedValue); + continue; + } + + if (visited === undefined) { + visited = new SafeMap(); + for (let j = 0; j < this.#searchParams.length; j += 2) { + visited.set(this.#searchParams[j], j + 1); + } + } + const keyIdx = visited.get(typedKey); if (keyIdx !== undefined) { this.#searchParams[keyIdx] = typedValue; diff --git a/test/parallel/test-whatwg-url-custom-searchparams-constructor.js b/test/parallel/test-whatwg-url-custom-searchparams-constructor.js index 9cfbd03a9737..6caae46666ee 100644 --- a/test/parallel/test-whatwg-url-custom-searchparams-constructor.js +++ b/test/parallel/test-whatwg-url-custom-searchparams-constructor.js @@ -47,6 +47,19 @@ function makeIterableFunc(array) { params = new URLSearchParams({ hasOwnProperty: 1 }); assert.strictEqual(params.get('hasOwnProperty'), '1'); assert.strictEqual(params.toString(), 'hasOwnProperty=1'); + // A malformed key can collide with a valid key collected before the map + // exists. + params = new URLSearchParams({ + 'before': '0', + '\uFFFDx': 'first', + '\uD835x': 'last', + 'after': '3' + }); + assert.deepStrictEqual([...params], [ + ['before', '0'], + ['\uFFFDx', 'last'], + ['after', '3'], + ]); assert.throws(() => new URLSearchParams([[1]]), tupleError); assert.throws(() => new URLSearchParams([[1, 2, 3]]), tupleError); assert.throws(() => new URLSearchParams({ [Symbol('test')]: 42 }), From 7094765ed8f3a58778c4bf08867eafbf282bfb74 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Fri, 25 Sep 2026 09:59:16 -0400 Subject: [PATCH 089/228] net: skip IPv6 regex when the string has no colon IPv6 addresses always contain ':'. Reject IPv4 addresses and hostnames before running the IPv6 regular expression. Official benchmark/net/net-is-ip-v6.js is about 27% faster. net-is-ip-v4.js is unchanged. Assisted-by: a closed-source coding agent Signed-off-by: Yagiz Nizipli Co-authored-by: Yagiz Nizipli PR-URL: https://github.com/nodejs/node/pull/66165 Reviewed-By: Tim Perry Reviewed-By: Luigi Pinca --- lib/internal/net.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/internal/net.js b/lib/internal/net.js index 2553449f0505..3c7bb7d4a95a 100644 --- a/lib/internal/net.js +++ b/lib/internal/net.js @@ -3,6 +3,7 @@ const { RegExp, RegExpPrototypeTest, + StringPrototypeIndexOf, Symbol, } = primordials; @@ -46,6 +47,11 @@ function isIPv4(s) { * @returns {boolean} */ function isIPv6(s) { + if (typeof s === 'string') { + // IPv6 always contains ':'. Skip the regex for IPv4 and hostnames. + if (s.length < 2 || StringPrototypeIndexOf(s, ':') === -1) + return false; + } // TODO(aduh95): Replace RegExpPrototypeTest with RegExpPrototypeExec when it // no longer creates a perf regression in the dns benchmark. // eslint-disable-next-line node-core/avoid-prototype-pollution From 6d01aec6276ad4c45b13268ae3e545aee96e3152 Mon Sep 17 00:00:00 2001 From: Jihwan Date: Fri, 25 Sep 2026 22:59:31 +0900 Subject: [PATCH 090/228] test: ensure fsPromises.watch rejects on abort Signed-off-by: hanityx PR-URL: https://github.com/nodejs/node/pull/65768 Refs: https://github.com/nodejs/node/pull/58490 Reviewed-By: Luigi Pinca --- test/parallel/test-fs-promises-watch.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/parallel/test-fs-promises-watch.js b/test/parallel/test-fs-promises-watch.js index 65ac429a59a1..397297b4f4ca 100644 --- a/test/parallel/test-fs-promises-watch.js +++ b/test/parallel/test-fs-promises-watch.js @@ -146,10 +146,8 @@ assert.rejects( const ac = new AbortController(); const { signal } = ac; setImmediate(() => ac.abort()); - try { + await assert.rejects(async () => { // eslint-disable-next-line no-unused-vars, no-empty for await (const _ of watch(__filename, { signal })) { } - } catch (err) { - assert.strictEqual(err.name, 'AbortError'); - } + }, { name: 'AbortError' }); })().then(common.mustCall()); From 8e87fbac40b86bf5ef7e561615f67c7aa8863f97 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Fri, 25 Sep 2026 10:39:01 -0400 Subject: [PATCH 091/228] net: speed up BlockList.check for strings Skip toLowerCase() for the documented ipv4/ipv6 families and add a V8 Fast API for one-byte address strings. The FastOneByteString callback takes FastApiCallbackOptions so V8 can use the fast path reliably (required for string-shaped Fast API arguments). Overlong IPv6 zone ids are parsed like uv_ip6_addr (unknown zone is scope_id 0; address parts 40+ chars stay a miss). IPv4 zone suffixes stay a miss, matching uv_ip4_addr. Assisted-by: a closed-source coding agent Signed-off-by: Yagiz Nizipli PR-URL: https://github.com/nodejs/node/pull/66166 Reviewed-By: James M Snell Reviewed-By: Tim Perry --- lib/internal/blocklist.js | 13 +++++- src/node_sockaddr.cc | 58 +++++++++++++++++++++++- src/node_sockaddr.h | 8 ++++ test/parallel/test-blocklist-fast-api.js | 19 ++++++-- test/parallel/test-blocklist.js | 23 ++++++++++ 5 files changed, 114 insertions(+), 7 deletions(-) diff --git a/lib/internal/blocklist.js b/lib/internal/blocklist.js index 9436078eaa2c..b4d6fe905129 100644 --- a/lib/internal/blocklist.js +++ b/lib/internal/blocklist.js @@ -315,8 +315,17 @@ class BlockList { validateString(family, 'family'); // Fast path: pass the string directly to C++ which does // inet_pton + Apply() without allocating a JS SocketAddress wrapper. - const af = StringPrototypeToLowerCase(family) === 'ipv4' ? - AF_INET : AF_INET6; + // The documented values are 'ipv4' / 'ipv6' (and the IPvX aliases). + // Avoid toLowerCase on that hot path. + let af; + if (family === 'ipv4' || family === 'IPv4') { + af = AF_INET; + } else if (family === 'ipv6' || family === 'IPv6') { + af = AF_INET6; + } else { + af = StringPrototypeToLowerCase(family) === 'ipv4' ? + AF_INET : AF_INET6; + } return this[kHandle].checkString(address, af); } return Boolean(this[kHandle].check(address[kSocketAddressHandle])); diff --git a/src/node_sockaddr.cc b/src/node_sockaddr.cc index e12fb86a3a95..5e456c634892 100644 --- a/src/node_sockaddr.cc +++ b/src/node_sockaddr.cc @@ -8,6 +8,9 @@ #include "node_hash.h" #include "node_sockaddr-inl.h" // NOLINT(build/include_inline) #include "uv.h" +#include "v8-fast-api-calls.h" + +#include #include #include @@ -18,6 +21,8 @@ namespace node { using v8::Array; using v8::CFunction; using v8::Context; +using v8::FastApiCallbackOptions; +using v8::FastOneByteString; using v8::FunctionCallbackInfo; using v8::FunctionTemplate; using v8::Int32; @@ -1005,6 +1010,53 @@ void SocketAddressBlockListWrap::CheckString( args.GetReturnValue().Set(wrap->blocklist_->Apply(addr)); } +bool SocketAddressBlockListWrap::FastCheckString( + Local receiver, + const FastOneByteString& address, + int32_t family, + // NOLINTNEXTLINE(runtime/references) This is V8 api. + FastApiCallbackOptions& options) { + // FastOneByteString is not NUL-terminated. Copy onto the stack + // before any other work: a GC would invalidate `address`. + // + // uv_ip6_addr only needs the address part (≤39 chars) plus an + // optional %zone. A zone longer than UV_IF_NAMESIZE is unknown + // (scope_id 0), same as omitting it. Keep that behavior when the + // full string does not fit so Fast API and CheckString agree. + constexpr size_t kMax = INET6_ADDRSTRLEN + UV_IF_NAMESIZE; + char buf[kMax]; + if (address.length < kMax) { + memcpy(buf, address.data, address.length); + buf[address.length] = '\0'; + } else if (family == AF_INET6) { + // uv_ip4_addr rejects %zone. Only IPv6 may drop an overlong zone + // (unknown zone → scope_id 0, same as uv_ip6_addr). + // uv_ip6_addr copies the address part into a 40-byte buffer + // (39 chars + NUL). A 40+ char prefix (mixed notation) is + // truncated and fails inet_pton — do not keep the full prefix. + const char* percent = + static_cast(memchr(address.data, '%', address.length)); + if (percent == nullptr) return false; + const size_t addr_len = static_cast(percent - address.data); + if (addr_len >= 40) return false; + memcpy(buf, address.data, addr_len); + buf[addr_len] = '\0'; + } else { + return false; + } + USE(options); + + TRACK_V8_FAST_API_CALL("blocklist.checkString"); + SocketAddressBlockListWrap* wrap = + FromJSObject(receiver); + SocketAddress addr; + if (!SocketAddress::New(family, buf, 0, &addr)) return false; + return wrap->blocklist_->Apply(addr); +} + +CFunction SocketAddressBlockListWrap::fast_check_string_( + CFunction::Make(&SocketAddressBlockListWrap::FastCheckString)); + void SocketAddressBlockListWrap::GetRules( const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); @@ -1060,7 +1112,11 @@ Local SocketAddressBlockListWrap::GetConstructorTemplate( SetProtoMethod(isolate, tmpl, "removeSubnet", RemoveSubnet); SetFastMethod( isolate, tmpl->PrototypeTemplate(), "check", Check, &fast_check_); - SetProtoMethod(isolate, tmpl, "checkString", CheckString); + SetFastMethod(isolate, + tmpl->PrototypeTemplate(), + "checkString", + CheckString, + &fast_check_string_); SetProtoMethod(isolate, tmpl, "getRules", GetRules); SetProtoMethodNoSideEffect(isolate, tmpl, "getSize", GetSize); SetProtoMethod(isolate, tmpl, "clear", Clear); diff --git a/src/node_sockaddr.h b/src/node_sockaddr.h index 55354138fa84..f80079d15321 100644 --- a/src/node_sockaddr.h +++ b/src/node_sockaddr.h @@ -9,6 +9,7 @@ #include "node.h" #include "node_worker.h" #include "uv.h" +#include "v8-fast-api-calls.h" #include "v8.h" #include @@ -403,6 +404,12 @@ class SocketAddressBlockListWrap : public BaseObject { static bool FastCheck(v8::Local receiver, v8::Local addr_obj); static void CheckString(const v8::FunctionCallbackInfo& args); + static bool FastCheckString( + v8::Local receiver, + const v8::FastOneByteString& address, + int32_t family, + // NOLINTNEXTLINE(runtime/references) This is V8 api. + v8::FastApiCallbackOptions& options); static void GetRules(const v8::FunctionCallbackInfo& args); static void GetSize(const v8::FunctionCallbackInfo& args); static void Clear(const v8::FunctionCallbackInfo& args); @@ -450,6 +457,7 @@ class SocketAddressBlockListWrap : public BaseObject { private: std::shared_ptr blocklist_; static v8::CFunction fast_check_; + static v8::CFunction fast_check_string_; }; } // namespace node diff --git a/test/parallel/test-blocklist-fast-api.js b/test/parallel/test-blocklist-fast-api.js index 3c59ad361d1a..c0d9db2d4c7d 100644 --- a/test/parallel/test-blocklist-fast-api.js +++ b/test/parallel/test-blocklist-fast-api.js @@ -6,15 +6,14 @@ const assert = require('assert'); const { BlockList } = require('net'); const { internalBinding } = require('internal/test/binding'); -// The fast API is on the native check() method which takes a -// SocketAddressBase object. The JS BlockList.prototype.check() routes -// string arguments to checkString() which has no fast API, so we need -// to use SocketAddress objects to exercise the fast API path. +// The native check() method takes a SocketAddressBase object. +// String arguments go through checkString(), which also has a fast API. const { kHandle: kBlockListHandle } = require('internal/blocklist'); const { SocketAddress, kHandle: kSocketAddressHandle, } = require('internal/socketaddress'); +const { AF_INET } = internalBinding('block_list'); const blockList = new BlockList(); blockList.addAddress('1.1.1.1'); @@ -31,12 +30,24 @@ function testFastCheck() { assert.strictEqual(handle.check(addr3), true); } +function checkString(address) { + return handle.checkString(address, AF_INET); +} + eval('%PrepareFunctionForOptimization(testFastCheck)'); testFastCheck(); eval('%OptimizeFunctionOnNextCall(testFastCheck)'); testFastCheck(); +eval('%PrepareFunctionForOptimization(checkString)'); +assert.strictEqual(checkString('1.1.1.1'), true); +eval('%OptimizeFunctionOnNextCall(checkString)'); +assert.strictEqual(checkString('1.1.1.1'), true); +assert.strictEqual(checkString('2.2.2.2'), false); +assert.strictEqual(checkString('10.0.0.5'), true); + if (common.isDebug) { const { getV8FastApiCallCount } = internalBinding('debug'); assert.strictEqual(getV8FastApiCallCount('blocklist.check'), 3); + assert.strictEqual(getV8FastApiCallCount('blocklist.checkString'), 3); } diff --git a/test/parallel/test-blocklist.js b/test/parallel/test-blocklist.js index f1fc42f6304e..b0fe4303bfb2 100644 --- a/test/parallel/test-blocklist.js +++ b/test/parallel/test-blocklist.js @@ -868,6 +868,29 @@ const util = require('util'); assert.strictEqual(blockList.check('not_valid_ipv6', 'ipv6'), false); } +// uv_ip6_addr ignores an unknown/overlong zone (scope_id 0). A Fast API +// stack limit must not turn that into a miss after JIT. +{ + const blockList = new BlockList(); + blockList.addAddress('fe80::1', 'ipv6'); + const longZone = `fe80::1%${'z'.repeat(200)}`; + assert.strictEqual(blockList.check('fe80::1', 'ipv6'), true); + assert.strictEqual(blockList.check(longZone, 'ipv6'), true); + assert.strictEqual(blockList.check('x'.repeat(200), 'ipv6'), false); + + // uv_ip4_addr rejects zone suffixes. The Fast API overflow path + // must not strip %zone and treat this as 1.1.1.1. + blockList.addAddress('1.1.1.1'); + assert.strictEqual(blockList.check(`1.1.1.1%${'z'.repeat(200)}`), false); + + // Mixed-notation IPv6 is 45 chars. uv_ip6_addr truncates the + // address part to 39 when a zone is present, so this is a miss. + const mixed = 'ffff:ffff:ffff:ffff:ffff:ffff:255.255.255.255'; + blockList.addAddress(mixed, 'ipv6'); + assert.strictEqual(blockList.check(mixed, 'ipv6'), true); + assert.strictEqual(blockList.check(`${mixed}%${'z'.repeat(200)}`, 'ipv6'), false); +} + // check() family parameter is case-insensitive. { const blockList = new BlockList(); From 1a97ad5701c5996382c74b757446060b04fcd828 Mon Sep 17 00:00:00 2001 From: Thomas Hunter II Date: Fri, 25 Sep 2026 08:36:32 -0700 Subject: [PATCH 092/228] util,assert: expose util.isPartialDeepStrictEqual() Signed-off-by: Thomas Hunter II PR-URL: https://github.com/nodejs/node/pull/66118 Reviewed-By: Ruben Bridgewater Reviewed-By: James M Snell Reviewed-By: Moshe Atlow --- doc/api/util.md | 20 +++++++ lib/util.js | 7 +++ .../test-util-isPartialDeepStrictEqual.js | 56 +++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 test/parallel/test-util-isPartialDeepStrictEqual.js diff --git a/doc/api/util.md b/doc/api/util.md index 6e15405e9337..ad0733feb072 100644 --- a/doc/api/util.md +++ b/doc/api/util.md @@ -1872,6 +1872,25 @@ console.log(util.isDeepStrictEqual(foo, bar, true)); See [`assert.deepStrictEqual()`][] for more information about deep strict equality. +## `util.isPartialDeepStrictEqual(val1, val2)` + + + +* `val1` {any} +* `val2` {any} +* Returns: {boolean} + +Returns `true` if there is partial deep strict equality between `val1` and +`val2`. Otherwise, returns `false`. + +"Partial" equality means that only properties that exist on `val2` are going +to be compared. + +See [`assert.partialDeepStrictEqual()`][] for more information about partial +deep strict equality. + ## `util.markPromiseAsHandled(promise)` For inflate and deflate streams, reset the compressor/decompressor to factory defaults. +For Brotli streams, start a new compression or decompression session while +preserving the configured parameters and dictionary. + For Zstd streams, cancel the current frame and start a new session while preserving the configured parameters and dictionary. If `pledgedSrcSize` was configured for a Zstd compressor, it applies again to the next frame. diff --git a/src/node_zlib.cc b/src/node_zlib.cc index 65e4e4bffe25..3cb736c22aac 100644 --- a/src/node_zlib.cc +++ b/src/node_zlib.cc @@ -52,6 +52,8 @@ #include #include #include +#include +#include namespace node { @@ -178,6 +180,18 @@ struct CompressionError { inline bool IsError() const { return code != nullptr; } }; +void RecordBrotliParam(std::vector>* params, + int key, + uint32_t value) { + for (auto& entry : *params) { + if (entry.first == key) { + entry.second = value; + return; + } + } + params->emplace_back(key, value); +} + class ZlibContext final : public MemoryRetainer { public: ZlibContext() = default; @@ -286,6 +300,8 @@ class BrotliEncoderContext final : public BrotliContext { prepared_dictionary_; // Dictionary data must remain valid while the prepared dictionary is alive. std::vector dictionary_; + // Last successful parameters, replayed by ResetStream. + std::vector> params_; }; class BrotliDecoderContext final : public BrotliContext { @@ -308,6 +324,8 @@ class BrotliDecoderContext final : public BrotliContext { DeleteFnPtr state_; // Dictionary data must remain valid for the lifetime of the decoder. std::vector dictionary_; + // Last successful parameters, replayed by ResetStream. + std::vector> params_; }; class ZstdContext : public MemoryRetainer { @@ -1505,6 +1523,7 @@ void BrotliEncoderContext::Close() { state_.reset(); prepared_dictionary_.reset(); dictionary_.clear(); + params_.clear(); mode_ = NONE; } @@ -1518,6 +1537,7 @@ CompressionError BrotliEncoderContext::Init(std::vector&& dictionary) { // Clean up any previous dictionary state before re-initializing. prepared_dictionary_.reset(); dictionary_.clear(); + params_.clear(); state_.reset(BrotliEncoderCreateInstance(alloc, free, opaque)); if (!state_) { @@ -1557,7 +1577,19 @@ CompressionError BrotliEncoderContext::Init(std::vector&& dictionary) { } CompressionError BrotliEncoderContext::ResetStream() { - return Init(); + std::vector dictionary = dictionary_; + const auto params = params_; + CompressionError err = Init(std::move(dictionary)); + if (err.IsError()) { + return err; + } + for (const auto& entry : params) { + err = SetParams(entry.first, entry.second); + if (err.IsError()) { + return err; + } + } + return CompressionError{}; } CompressionError BrotliEncoderContext::SetParams(int key, uint32_t value) { @@ -1568,6 +1600,7 @@ CompressionError BrotliEncoderContext::SetParams(int key, uint32_t value) { "ERR_BROTLI_PARAM_SET_FAILED", -1); } else { + RecordBrotliParam(¶ms_, key, value); return CompressionError {}; } } @@ -1586,6 +1619,7 @@ CompressionError BrotliEncoderContext::GetErrorInfo() const { void BrotliDecoderContext::Close() { state_.reset(); dictionary_.clear(); + params_.clear(); mode_ = NONE; } @@ -1615,6 +1649,7 @@ CompressionError BrotliDecoderContext::Init(std::vector&& dictionary) { // Clean up any previous dictionary state before re-initializing. dictionary_.clear(); + params_.clear(); state_.reset(BrotliDecoderCreateInstance(alloc, free, opaque)); if (!state_) { @@ -1642,7 +1677,19 @@ CompressionError BrotliDecoderContext::Init(std::vector&& dictionary) { } CompressionError BrotliDecoderContext::ResetStream() { - return Init(); + std::vector dictionary = dictionary_; + const auto params = params_; + CompressionError err = Init(std::move(dictionary)); + if (err.IsError()) { + return err; + } + for (const auto& entry : params) { + err = SetParams(entry.first, entry.second); + if (err.IsError()) { + return err; + } + } + return CompressionError{}; } CompressionError BrotliDecoderContext::SetParams(int key, uint32_t value) { @@ -1653,6 +1700,7 @@ CompressionError BrotliDecoderContext::SetParams(int key, uint32_t value) { "ERR_BROTLI_PARAM_SET_FAILED", -1); } else { + RecordBrotliParam(¶ms_, key, value); return CompressionError {}; } } diff --git a/test/parallel/test-zlib-brotli-reset.js b/test/parallel/test-zlib-brotli-reset.js new file mode 100644 index 000000000000..6641648c89d2 --- /dev/null +++ b/test/parallel/test-zlib-brotli-reset.js @@ -0,0 +1,48 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { finished } = require('stream/promises'); +const test = require('node:test'); +const zlib = require('zlib'); + +const dictionary = Buffer.from( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' + + 'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', +); +const input = Buffer.from( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. '.repeat(100), +); + +async function collect(stream, ...data) { + const chunks = []; + stream.on('data', (chunk) => chunks.push(chunk)); + for (let i = 0; i < data.length - 1; i++) { + stream.write(data[i]); + } + stream.end(data[data.length - 1]); + await finished(stream); + return Buffer.concat(chunks); +} + +test('BrotliCompress reset preserves its initial options', async () => { + const options = { + dictionary, + params: { + [zlib.constants.BROTLI_PARAM_QUALITY]: 0, + }, + }; + const expected = await collect(zlib.createBrotliCompress(options), input); + const reset = zlib.createBrotliCompress(options); + reset.reset(); + + assert.deepStrictEqual(await collect(reset, input), expected); +}); + +test('BrotliDecompress reset preserves its dictionary', async () => { + const compressed = zlib.brotliCompressSync(input, { dictionary }); + const decompress = zlib.createBrotliDecompress({ dictionary }); + decompress.reset(); + + assert.deepStrictEqual(await collect(decompress, compressed), input); +}); From 90f36fb28db7623588790bf9a056dbf2dd03124f Mon Sep 17 00:00:00 2001 From: Dayun Date: Sun, 27 Sep 2026 04:32:45 +0900 Subject: [PATCH 108/228] stream: fix pipeline function tail deadlock Signed-off-by: Dayun PR-URL: https://github.com/nodejs/node/pull/65559 Fixes: https://github.com/nodejs/node/issues/40685 Reviewed-By: Robert Nagy Reviewed-By: Matteo Collina Reviewed-By: James M Snell --- lib/stream/promises.js | 12 +++++++++++- test/parallel/test-stream-pipeline.js | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/lib/stream/promises.js b/lib/stream/promises.js index a8b65d62b096..43c378a20f22 100644 --- a/lib/stream/promises.js +++ b/lib/stream/promises.js @@ -1,6 +1,7 @@ 'use strict'; const { + ArrayIsArray, ArrayPrototypePop, Promise, } = primordials; @@ -28,13 +29,22 @@ function pipeline(...streams) { end = options.end; } - pl(streams, (err, value) => { + let lastStream = streams[streams.length - 1]; + if (streams.length === 1 && ArrayIsArray(streams[0])) { + lastStream = streams[0][streams[0].length - 1]; + } + + const stream = pl(streams, (err, value) => { if (err) { reject(err); } else { resolve(value); } }, { signal, end }); + + if (typeof lastStream === 'function' && stream.readable) { + stream.resume(); + } }); } diff --git a/test/parallel/test-stream-pipeline.js b/test/parallel/test-stream-pipeline.js index 4e7ff2f8c79d..ad515a7bf38a 100644 --- a/test/parallel/test-stream-pipeline.js +++ b/test/parallel/test-stream-pipeline.js @@ -1657,6 +1657,22 @@ tmpdir.refresh(); })); } +{ + async function* passThrough(source) { + for await (const chunk of source) { + yield chunk; + } + } + + const streams = () => [ + Readable.from(Array.from({ length: 100 }, (_, i) => i)), + passThrough, + ]; + + pipelinep(...streams()).then(common.mustCall()); + pipelinep(streams()).then(common.mustCall()); +} + { const r = new Readable(); for (let i = 0; i < 4000; i++) { From 06ce26dec5d2b35509b69960c2846f72dc946475 Mon Sep 17 00:00:00 2001 From: Dayun Date: Sun, 27 Sep 2026 04:42:29 +0900 Subject: [PATCH 109/228] worker: remove messageerror listeners on exit Signed-off-by: Dayun PR-URL: https://github.com/nodejs/node/pull/66075 Fixes: https://github.com/nodejs/node/issues/65782 Reviewed-By: James M Snell --- lib/internal/worker.js | 2 +- .../test-worker-messageerror-listener-cleanup.js | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 test/parallel/test-worker-messageerror-listener-cleanup.js diff --git a/lib/internal/worker.js b/lib/internal/worker.js index bbc67222b09b..7dc099836cbf 100644 --- a/lib/internal/worker.js +++ b/lib/internal/worker.js @@ -388,7 +388,7 @@ class Worker extends EventEmitter { drainMessagePort(this[kPort]); destroyMainThreadPort(this.threadId); this.removeAllListeners('message'); - this.removeAllListeners('messageerrors'); + this.removeAllListeners('messageerror'); this[kPublicPort].unref(); this[kPort].unref(); this[kDispose](); diff --git a/test/parallel/test-worker-messageerror-listener-cleanup.js b/test/parallel/test-worker-messageerror-listener-cleanup.js new file mode 100644 index 000000000000..1ede0d88254b --- /dev/null +++ b/test/parallel/test-worker-messageerror-listener-cleanup.js @@ -0,0 +1,16 @@ +'use strict'; + +const common = require('../common'); +const assert = require('node:assert'); +const { Worker } = require('node:worker_threads'); + +const worker = new Worker('', { eval: true }); +const listener = () => {}; + +worker.on('message', listener); +worker.on('messageerror', listener); + +worker.on('exit', common.mustCall(() => { + assert.strictEqual(worker.listenerCount('message'), 0); + assert.strictEqual(worker.listenerCount('messageerror'), 0); +})); From 4e0cd188ec6f85f84b80d63d803353cfd9be5546 Mon Sep 17 00:00:00 2001 From: Christian Aurich Zanettini Martins Date: Sat, 26 Sep 2026 16:52:53 -0300 Subject: [PATCH 110/228] fs: fix crash on negative zero file descriptor `isInt32()` accepts -0 because `-0 === (-0 | 0)`, but V8 does not represent -0 as an Int32 value, so `Value::IsInt32()` rejects it. The utf8 fast paths of `readFileSync()` and `writeFileSync()` hand the value straight to the binding, which then took it for a path and aborted on the null check. Coerce -0 to 0 before the call, matching `getValidatedFd()` and the rest of fs, where -0 is a valid way to name file descriptor 0. Signed-off-by: Christian Aurich PR-URL: https://github.com/nodejs/node/pull/65888 Fixes: https://github.com/nodejs/node/issues/65886 Reviewed-By: Xuguang Mei --- lib/fs.js | 12 ++++++++++-- test/parallel/test-fs-negative-zero.js | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/lib/fs.js b/lib/fs.js index 14e0be975e79..bba81e0cdcef 100644 --- a/lib/fs.js +++ b/lib/fs.js @@ -477,7 +477,11 @@ function readFileSync(path, options) { if ((options.encoding === 'utf8' || options.encoding === 'utf-8') && !hasUserBuffer) { - if (!isInt32(path)) { + if (isInt32(path)) { + // V8 does not report -0 as an int32, so it would reach the binding as a + // path instead of a file descriptor. + path |= 0; + } else { path = getValidatedPath(path); } return binding.readFileUtf8(path, stringToFlags(options.flag)); @@ -2475,7 +2479,11 @@ function writeFileSync(path, data, options) { // C++ fast path for string data and UTF8 encoding if (typeof data === 'string' && (options.encoding === 'utf8' || options.encoding === 'utf-8')) { - if (!isInt32(path)) { + if (isInt32(path)) { + // V8 does not report -0 as an int32, so it would reach the binding as a + // path instead of a file descriptor. + path |= 0; + } else { path = getValidatedPath(path); } diff --git a/test/parallel/test-fs-negative-zero.js b/test/parallel/test-fs-negative-zero.js index 538cea67faaa..09f965eb171f 100644 --- a/test/parallel/test-fs-negative-zero.js +++ b/test/parallel/test-fs-negative-zero.js @@ -2,6 +2,8 @@ require('../common'); +const assert = require('assert'); +const { spawnSync } = require('child_process'); const fs = require('fs'); const path = require('path'); const os = require('os'); @@ -29,5 +31,18 @@ ignoreExpectedError(() => fs.mkdirSync(missing, { mode: -0 })); ignoreExpectedError(() => fs.chmodSync(missing, -0)); ignoreExpectedError(() => fs.writeFileSync(missing, '', { mode: -0 })); +// -0 is accepted as file descriptor 0. Writing an empty string reaches the +// utf8 fast path without issuing a write on the descriptor. +fs.writeFileSync(-0, ''); +fs.appendFileSync(-0, ''); + +const child = spawnSync( + process.execPath, + ['-e', 'process.stdout.write(require("fs").readFileSync(-0, "utf8"))'], + { input: 'hello' }, +); +assert.strictEqual(child.status, 0); +assert.strictEqual(child.stdout.toString(), 'hello'); + fs.watchFile(missing, { interval: -0 }, () => {}); fs.unwatchFile(missing); From 035725b18a3ab8e55e81437226f44f855989ea84 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Sat, 26 Sep 2026 16:34:53 -0400 Subject: [PATCH 111/228] querystring: speed up default parse and unescape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skip the %XX walk in unescapeBuffer when the input has no '%'. Add a dedicated '&'/'=' scanner for the default parse path so it does not build separator code arrays or run the multi-char state machine. Official benchmark/querystring/querystring-parse.js: encodemany is about 38% faster, manyblankpairs about 17%, encodelast about 10%, noencode about 8%. Official querystring-unescapebuffer.js with no escapes is about 36% faster. Assisted-by: a closed-source coding agent Signed-off-by: Yagiz Nizipli Co-authored-by: Yagiz Nizipli PR-URL: https://github.com/nodejs/node/pull/66175 Reviewed-By: James M Snell Reviewed-By: Gürgün Dayıoğlu --- lib/querystring.js | 130 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/lib/querystring.js b/lib/querystring.js index 30e159d647be..6aca65dee5b6 100644 --- a/lib/querystring.js +++ b/lib/querystring.js @@ -32,6 +32,7 @@ const { ObjectKeys, String, StringPrototypeCharCodeAt, + StringPrototypeIndexOf, StringPrototypeSlice, decodeURIComponent, } = primordials; @@ -82,6 +83,11 @@ const unhexTable = new Int8Array([ * @returns {string} */ function unescapeBuffer(s, decodeSpaces) { + // No encoded bytes: latin1-copy the string without walking %XX. + if (StringPrototypeIndexOf(s, '%') === -1) { + if (!decodeSpaces || StringPrototypeIndexOf(s, '+') === -1) + return Buffer.from(s, 'latin1'); + } const out = Buffer.allocUnsafe(s.length); let index = 0; let outIndex = 0; @@ -303,6 +309,120 @@ function addKeyVal(obj, key, value, keyEncoded, valEncoded, decode) { } } +/** + * Default querystring parser: separator '&', equals '=', native unescape. + * Single-character separators avoid the sep/eq index state machine. + * @param {string} qs + * @param {Record} obj + * @returns {Record} + */ +function parseAmpEq(qs, obj) { + let pairs = 1000; + let lastPos = 0; + let eqPos = -1; + let key = ''; + let value = ''; + let keyEncoded = false; + let valEncoded = false; + let encodeCheck = 0; + const plusChar = ' '; + const len = qs.length; + + for (let i = 0; i < len; ++i) { + const code = StringPrototypeCharCodeAt(qs, i); + + if (code === 38) { // '&' + const end = i; + if (eqPos === -1) { + if (lastPos < end) + key += StringPrototypeSlice(qs, lastPos, end); + else if (key.length === 0) { + if (--pairs === 0) + return obj; + lastPos = i + 1; + encodeCheck = 0; + continue; + } + } else if (lastPos < end) { + value += StringPrototypeSlice(qs, lastPos, end); + } + + addKeyVal(obj, key, value, keyEncoded, valEncoded, qsUnescape); + + if (--pairs === 0) + return obj; + keyEncoded = false; + valEncoded = false; + key = ''; + value = ''; + encodeCheck = 0; + lastPos = i + 1; + eqPos = -1; + continue; + } + + if (eqPos === -1) { + if (code === 61) { // '=' + if (lastPos < i) + key += StringPrototypeSlice(qs, lastPos, i); + encodeCheck = 0; + lastPos = i + 1; + eqPos = i; + continue; + } + if (!keyEncoded) { + if (code === 37) { + encodeCheck = 1; + } else if (encodeCheck > 0) { + if (isHexTable[code] === 1) { + if (++encodeCheck === 3) + keyEncoded = true; + } else { + encodeCheck = 0; + } + } + } + if (code === 43) { + if (lastPos < i) + key += StringPrototypeSlice(qs, lastPos, i); + key += plusChar; + lastPos = i + 1; + } + continue; + } + + if (code === 43) { + if (lastPos < i) + value += StringPrototypeSlice(qs, lastPos, i); + value += plusChar; + lastPos = i + 1; + } else if (!valEncoded) { + if (code === 37) { + encodeCheck = 1; + } else if (encodeCheck > 0) { + if (isHexTable[code] === 1) { + if (++encodeCheck === 3) + valEncoded = true; + } else { + encodeCheck = 0; + } + } + } + } + + if (lastPos < len) { + if (eqPos === -1) + key += StringPrototypeSlice(qs, lastPos); + else + value += StringPrototypeSlice(qs, lastPos); + } else if (eqPos === -1 && key.length === 0) { + return obj; + } + + addKeyVal(obj, key, value, keyEncoded, valEncoded, qsUnescape); + return obj; +} + /** * Parse a key/val string. * @param {string} qs @@ -321,6 +441,16 @@ function parse(qs, sep, eq, options) { return obj; } + // Default '&' / '=' / unescape: single-char scanner, no sep/eq arrays. + if ((sep === undefined || sep === '&') && + (eq === undefined || eq === '=') && + QueryString.unescape === qsUnescape && + (!options || + (options.maxKeys === undefined && + options.decodeURIComponent === undefined))) { + return parseAmpEq(qs, obj); + } + const sepCodes = (!sep ? defSepCodes : charCodes(String(sep))); const eqCodes = (!eq ? defEqCodes : charCodes(String(eq))); const sepLen = sepCodes.length; From f6f705e39da21f53f35491afa00f59d191e75a74 Mon Sep 17 00:00:00 2001 From: Lazizbek Ergashev Date: Tue, 25 Aug 2026 13:10:33 +0500 Subject: [PATCH 112/228] worker: add Symbol.toStringTag to MessageChannel and MessagePort Signed-off-by: Lazizbek Ergashev PR-URL: https://github.com/nodejs/node/pull/65532 Fixes: https://github.com/nodejs/node/issues/65527 Reviewed-By: James M Snell --- lib/internal/worker/io.js | 13 +++++++++++++ .../test-eventtarget-memoryleakwarning.js | 4 ++-- test/parallel/test-messagechannel-string-tag.js | 17 +++++++++++++++++ ...ker-message-port-inspect-during-init-hook.js | 2 +- 4 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 test/parallel/test-messagechannel-string-tag.js diff --git a/lib/internal/worker/io.js b/lib/internal/worker/io.js index b73256734079..0d26854ad7ea 100644 --- a/lib/internal/worker/io.js +++ b/lib/internal/worker/io.js @@ -15,6 +15,7 @@ const { ReflectApply, Symbol, SymbolFor, + SymbolToStringTag, } = primordials; const { @@ -208,6 +209,18 @@ ObjectDefineProperty(MessagePort.prototype, inspect.custom, { }, }); +ObjectDefineProperty(MessagePort.prototype, SymbolToStringTag, { + __proto__: null, + configurable: true, + value: 'MessagePort', +}); + +ObjectDefineProperty(MessageChannel.prototype, SymbolToStringTag, { + __proto__: null, + configurable: true, + value: 'MessageChannel', +}); + function setupPortReferencing(port, eventEmitter, eventName) { // Keep track of whether there are any workerMessage listeners: // If there are some, ref() the channel so it keeps the event loop alive. diff --git a/test/parallel/test-eventtarget-memoryleakwarning.js b/test/parallel/test-eventtarget-memoryleakwarning.js index 38fc5efc9bd2..c8c1faa2175b 100644 --- a/test/parallel/test-eventtarget-memoryleakwarning.js +++ b/test/parallel/test-eventtarget-memoryleakwarning.js @@ -15,12 +15,12 @@ common.expectWarning({ 'EventTarget. MaxListeners is 2. Use events.setMaxListeners() ' + 'to increase limit'], ['Possible EventTarget memory leak detected. 3 foo listeners added to ' + - '[MessagePort [EventTarget]]. ' + + '[MessagePort]. ' + 'MaxListeners is 2. ' + 'Use events.setMaxListeners() to increase ' + 'limit'], ['Possible EventTarget memory leak detected. 3 foo listeners added to ' + - '[MessagePort [EventTarget]]. ' + + '[MessagePort]. ' + 'MaxListeners is 2. ' + 'Use events.setMaxListeners() to increase ' + 'limit'], diff --git a/test/parallel/test-messagechannel-string-tag.js b/test/parallel/test-messagechannel-string-tag.js new file mode 100644 index 000000000000..d824f3e4ffc2 --- /dev/null +++ b/test/parallel/test-messagechannel-string-tag.js @@ -0,0 +1,17 @@ +'use strict'; + +require('../common'); + +const assert = require('assert'); + +const classesToBeTested = [ MessageChannel, MessagePort ]; + +classesToBeTested.forEach((cls) => { + assert.strictEqual(cls.prototype[Symbol.toStringTag], cls.name); + assert.deepStrictEqual(Object.getOwnPropertyDescriptor(cls.prototype, Symbol.toStringTag), + { configurable: true, enumerable: false, value: cls.name, writable: false }); +}); + +const channel = new MessageChannel(); +assert.strictEqual(Object.prototype.toString.call(channel), '[object MessageChannel]'); +assert.strictEqual(Object.prototype.toString.call(channel.port1), '[object MessagePort]'); diff --git a/test/parallel/test-worker-message-port-inspect-during-init-hook.js b/test/parallel/test-worker-message-port-inspect-during-init-hook.js index 8f9678de1e97..d5784dceb71d 100644 --- a/test/parallel/test-worker-message-port-inspect-during-init-hook.js +++ b/test/parallel/test-worker-message-port-inspect-during-init-hook.js @@ -12,7 +12,7 @@ async_hooks.createHook({ init: common.mustCall((id, type, triggerId, resource) => { assert.strictEqual( util.inspect(resource), - 'MessagePort [EventTarget] { active: true, refed: false }'); + 'MessagePort { active: true, refed: false }'); }, 2) }).enable(); From 701d7dedb8670de8b6e3823d0edb8f936e4a10fc Mon Sep 17 00:00:00 2001 From: Lazizbek Ergashev Date: Tue, 25 Aug 2026 15:02:46 +0500 Subject: [PATCH 113/228] worker: add Symbol.toStringTag to BroadcastChannel Signed-off-by: Lazizbek Ergashev PR-URL: https://github.com/nodejs/node/pull/65532 Fixes: https://github.com/nodejs/node/issues/65527 Reviewed-By: James M Snell --- lib/internal/worker/io.js | 11 +++++++++++ ...nel-string-tag.js => test-messaging-string-tag.js} | 6 +++++- 2 files changed, 16 insertions(+), 1 deletion(-) rename test/parallel/{test-messagechannel-string-tag.js => test-messaging-string-tag.js} (69%) diff --git a/lib/internal/worker/io.js b/lib/internal/worker/io.js index 0d26854ad7ea..7a312232f97f 100644 --- a/lib/internal/worker/io.js +++ b/lib/internal/worker/io.js @@ -211,12 +211,16 @@ ObjectDefineProperty(MessagePort.prototype, inspect.custom, { ObjectDefineProperty(MessagePort.prototype, SymbolToStringTag, { __proto__: null, + writable: false, + enumerable: false, configurable: true, value: 'MessagePort', }); ObjectDefineProperty(MessageChannel.prototype, SymbolToStringTag, { __proto__: null, + writable: false, + enumerable: false, configurable: true, value: 'MessageChannel', }); @@ -474,6 +478,13 @@ class BroadcastChannel extends EventTarget { } ObjectDefineProperties(BroadcastChannel.prototype, { + [SymbolToStringTag]: { + __proto__: null, + writable: false, + enumerable: false, + configurable: true, + value: 'BroadcastChannel', + }, name: kEnumerableProperty, close: kEnumerableProperty, postMessage: kEnumerableProperty, diff --git a/test/parallel/test-messagechannel-string-tag.js b/test/parallel/test-messaging-string-tag.js similarity index 69% rename from test/parallel/test-messagechannel-string-tag.js rename to test/parallel/test-messaging-string-tag.js index d824f3e4ffc2..77f9484ef55b 100644 --- a/test/parallel/test-messagechannel-string-tag.js +++ b/test/parallel/test-messaging-string-tag.js @@ -4,7 +4,7 @@ require('../common'); const assert = require('assert'); -const classesToBeTested = [ MessageChannel, MessagePort ]; +const classesToBeTested = [ MessageChannel, MessagePort, BroadcastChannel ]; classesToBeTested.forEach((cls) => { assert.strictEqual(cls.prototype[Symbol.toStringTag], cls.name); @@ -15,3 +15,7 @@ classesToBeTested.forEach((cls) => { const channel = new MessageChannel(); assert.strictEqual(Object.prototype.toString.call(channel), '[object MessageChannel]'); assert.strictEqual(Object.prototype.toString.call(channel.port1), '[object MessagePort]'); + +const broadcastChannel = new BroadcastChannel('string-tag'); +assert.strictEqual(Object.prototype.toString.call(broadcastChannel), '[object BroadcastChannel]'); +broadcastChannel.close(); From fb8125fe142678308cb1c1352676d77f0a29dc96 Mon Sep 17 00:00:00 2001 From: agape1225 <49804691+agape1225@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:54:54 +0900 Subject: [PATCH 114/228] typings: wire up spawn_sync internal binding types Signed-off-by: agape1225 <49804691+agape1225@users.noreply.github.com> PR-URL: https://github.com/nodejs/node/pull/65996 Reviewed-By: Daeyeon Jeong --- typings/globals.d.ts | 2 ++ typings/internalBinding/spawn_sync.d.ts | 33 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 typings/internalBinding/spawn_sync.d.ts diff --git a/typings/globals.d.ts b/typings/globals.d.ts index f10d6eeb6bbc..7b6cb962c82d 100644 --- a/typings/globals.d.ts +++ b/typings/globals.d.ts @@ -35,6 +35,7 @@ import { QuicBinding } from './internalBinding/quic'; import { SeaBinding } from './internalBinding/sea'; import { SerdesBinding } from './internalBinding/serdes'; import { SignalWrapBinding } from './internalBinding/signal_wrap'; +import { SpawnSyncBinding } from './internalBinding/spawn_sync'; import { StreamPipeBinding } from './internalBinding/stream_pipe'; import { StringDecoderBinding } from './internalBinding/string_decoder'; import { SymbolsBinding } from './internalBinding/symbols'; @@ -88,6 +89,7 @@ interface InternalBindingMap { sea: SeaBinding; serdes: SerdesBinding; signal_wrap: SignalWrapBinding; + spawn_sync: SpawnSyncBinding; stream_pipe: StreamPipeBinding; string_decoder: StringDecoderBinding; symbols: SymbolsBinding; diff --git a/typings/internalBinding/spawn_sync.d.ts b/typings/internalBinding/spawn_sync.d.ts new file mode 100644 index 000000000000..1671f2cf3079 --- /dev/null +++ b/typings/internalBinding/spawn_sync.d.ts @@ -0,0 +1,33 @@ +type SpawnSyncStdio = + | { type: 'ignore' } + | { type: 'pipe'; readable: boolean; writable: boolean; input?: ArrayBufferView } + | { type: 'inherit'; fd: number } + | { type: 'fd'; fd: number }; + +export interface SpawnSyncOptions { + file: string; + args: readonly string[]; + cwd?: string | null; + envPairs?: readonly string[] | null; + uid?: number | null; + gid?: number | null; + detached?: boolean; + windowsHide?: boolean; + windowsVerbatimArguments?: boolean; + timeout?: number | null; + maxBuffer?: number | null; + killSignal?: number | null; + stdio: readonly SpawnSyncStdio[]; +} + +export interface SpawnSyncResult { + error?: number; + status: number | null; + signal: string | null; + output: (Uint8Array | null)[] | null; + pid: number; +} + +export interface SpawnSyncBinding { + spawn(options: SpawnSyncOptions): SpawnSyncResult; +} From 3e1ced38f94f828a0dcdfb08ddd573e1d663ba81 Mon Sep 17 00:00:00 2001 From: Marco Date: Sun, 27 Sep 2026 11:36:07 +0200 Subject: [PATCH 115/228] watch: detect files replaced via unlink and create Signed-off-by: marcopiraccini PR-URL: https://github.com/nodejs/node/pull/63888 Fixes: https://github.com/nodejs/node/issues/51621 Refs: https://github.com/nodejs/node/issues/54774 Reviewed-By: Paolo Insogna Reviewed-By: Filip Skokan --- lib/internal/watch_mode/files_watcher.js | 19 +++++++--- .../test-watch-mode-files_watcher.mjs | 35 +++++++++++++++++-- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/lib/internal/watch_mode/files_watcher.js b/lib/internal/watch_mode/files_watcher.js index c87f489b6377..8b5faeaf0e9d 100644 --- a/lib/internal/watch_mode/files_watcher.js +++ b/lib/internal/watch_mode/files_watcher.js @@ -25,6 +25,9 @@ const { setTimeout, clearTimeout } = require('timers'); const supportsRecursiveWatching = process.platform === 'win32' || process.platform === 'darwin'; +const supportsDirectoryWatching = supportsRecursiveWatching || + process.platform === 'linux'; + const isParentPath = (parentCandidate, childCandidate) => { const parent = resolve(parentCandidate); const child = resolve(childCandidate); @@ -117,13 +120,15 @@ class FilesWatcher extends EventEmitter { if (this.#isPathWatched(path)) { return; } - const { allowMissing = false } = options; + // `watchEntries` tells that `path` is a directory whose entries are + // reported by name, as opposed to a single watched file. + const { allowMissing = false, watchEntries = recursive } = options; const watcher = watch(path, { recursive, signal: this.#signal, throwIfNoEntry: !allowMissing }); watcher.on('change', (eventType, fileName) => { // `fileName` can be `null` if it cannot be determined. See // https://github.com/nodejs/node/pull/49891#issuecomment-1744673430. - this.#onChange(recursive ? resolve(path, fileName ?? '') : path, eventType); + this.#onChange(watchEntries ? resolve(path, fileName ?? '') : path, eventType); }); this.#watchers.set(path, { handle: watcher, recursive }); if (recursive) { @@ -135,9 +140,15 @@ class FilesWatcher extends EventEmitter { if (!file) return; if (supportsRecursiveWatching) { this.watchPath(dirname(file), true, options); + } else if (supportsDirectoryWatching) { + // Watch the parent directory non-recursively rather than the file + // itself. A watch bound to the file's inode stops receiving events once + // the file is replaced via unlink+create or rename (atomic saves, Docker + // Compose watch, ...), so only the first replacement would be detected. + // Watching the directory keeps working across replacements, and unrelated + // siblings are discarded by the `filter` mode check in `#onChange`. + this.watchPath(dirname(file), false, { __proto__: null, ...options, watchEntries: true }); } else { - // Having multiple FSWatcher's seems to be slower - // than a single recursive FSWatcher this.watchPath(file, false, options); } this.#filteredFiles.add(file); diff --git a/test/parallel/test-watch-mode-files_watcher.mjs b/test/parallel/test-watch-mode-files_watcher.mjs index e1595350cd0f..10e078286e07 100644 --- a/test/parallel/test-watch-mode-files_watcher.mjs +++ b/test/parallel/test-watch-mode-files_watcher.mjs @@ -6,7 +6,7 @@ import path from 'node:path'; import assert from 'node:assert'; import process from 'node:process'; import { describe, it, beforeEach, afterEach } from 'node:test'; -import { writeFileSync, mkdirSync, appendFileSync } from 'node:fs'; +import { writeFileSync, mkdirSync, appendFileSync, rmSync } from 'node:fs'; import { createInterface } from 'node:readline'; import { setTimeout } from 'node:timers/promises'; import { once } from 'node:events'; @@ -17,6 +17,9 @@ if (common.isIBMi) common.skip('IBMi does not support `fs.watch()`'); const supportsRecursiveWatching = common.isMacOS || common.isWindows; +// Elsewhere a directory watch does not report changes to its entries by name, +// so the files are watched directly. +const watchesParentDirectory = supportsRecursiveWatching || common.isLinux; const { FilesWatcher } = watcher; tmpdir.refresh(); @@ -44,6 +47,19 @@ describe('watch mode file watcher', () => { }); } + function replaceAndWaitForChanges(watcher, file) { + return new Promise((resolve) => { + const interval = setInterval(() => { + rmSync(file, { force: true }); + writeFileSync(file, `replace ${counter++}`); + }, 100); + watcher.once('changed', () => { + clearInterval(interval); + resolve(); + }); + }); + } + it('should watch changed files', async () => { const file = tmpdir.resolve('file1'); writeFileSync(file, 'written'); @@ -52,6 +68,19 @@ describe('watch mode file watcher', () => { assert.strictEqual(changesCount, 1); }); + it('should keep detecting files replaced via unlink and create', { skip: !watchesParentDirectory }, async () => { + // Regression test for https://github.com/nodejs/node/issues/51621: a watch + // bound to the file inode stops firing after the first replacement, so the + // second `replaceAndWaitForChanges` call would hang on the buggy behavior. + const file = tmpdir.resolve('replaced.js'); + writeFileSync(file, 'written'); + watcher.filterFile(file); + await replaceAndWaitForChanges(watcher, file); + await replaceAndWaitForChanges(watcher, file); + await replaceAndWaitForChanges(watcher, file); + assert.ok(changesCount >= 3, `expected at least 3 changes, got ${changesCount}`); + }); + it('should watch changed files with same prefix path string', async () => { mkdirSync(tmpdir.resolve('subdir')); mkdirSync(tmpdir.resolve('sub')); @@ -205,7 +234,9 @@ describe('watch mode file watcher', () => { watcher.watchChildProcessModules(child); await once(child, 'exit'); let expected = [file, tmpdir.resolve('file')]; - if (supportsRecursiveWatching) { + if (watchesParentDirectory) { + // The parent directory is watched so that files replaced via + // unlink+create or rename are still detected. expected = expected.map((file) => path.dirname(file)); } assert.deepStrictEqual(watcher.watchedPaths, expected); From 9f980218968f97dbfd86fc5024c94d4ded7ea649 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Sun, 27 Sep 2026 14:34:10 +0200 Subject: [PATCH 116/228] tools: check CI availability and workload Select mergeable PRs and check Jenkins availability and workload before removing request labels. Leave requests for a later run when Jenkins is unavailable or the workload has reached the configured limit. Make the batch size and workload limit repository variables, defaulting to 5 and 10 respectively. Refs: https://github.com/nodejs/node-core-utils/pull/1204 Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66280 Reviewed-By: Antoine du Hamel Reviewed-By: Matteo Collina --- .github/workflows/auto-start-ci.yml | 19 ++++++++++++++--- tools/actions/start-ci.sh | 32 +++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/.github/workflows/auto-start-ci.yml b/.github/workflows/auto-start-ci.yml index c8b090fdc044..85f3e92f2ffb 100644 --- a/.github/workflows/auto-start-ci.yml +++ b/.github/workflows/auto-start-ci.yml @@ -1,6 +1,9 @@ # This action uses the following secrets: # JENKINS_USER: GitHub user whose Jenkins token is defined below # JENKINS_TOKEN: Jenkins token, to be used to start or resume CI +# Optional repository variables: +# AUTO_START_CI_BATCH_SIZE: maximum PRs to process per run (default: 5) +# AUTO_START_CI_MAX_WORKLOAD: pause at this many running or queued PR jobs (default: 15) name: Auto Start CI on: @@ -30,16 +33,25 @@ jobs: steps: - name: Get Pull Requests id: get_prs_for_ci + # Explicit bash enables pipefail so jq cannot hide a failed gh query. + shell: bash run: | + if ! [[ "$BATCH_SIZE" =~ ^[1-9][0-9]*$ ]]; then + echo '::error::AUTO_START_CI_BATCH_SIZE must be a strictly positive integer' + exit 1 + fi + # Filter before selecting the batch; UNKNOWN mergeability is retried later. numbers=$(gh pr list \ --repo "$GITHUB_REPOSITORY" \ - --json 'number' \ + --json 'number,mergeable' \ --search 'review:approved label:request-ci,resume-ci' \ - -t '{{ range . }}{{ .number }} {{ end }}' \ - --limit 5) + --limit 100 \ + | jq --argjson limit "$BATCH_SIZE" \ + '[.[] | select(.mergeable == "MERGEABLE") | .number] | .[:$limit] | map(tostring) | join(" ")' -r) echo "numbers=$numbers" >> "$GITHUB_OUTPUT" env: GH_TOKEN: ${{ github.token }} + BATCH_SIZE: ${{ vars.AUTO_START_CI_BATCH_SIZE || '5' }} start-ci: permissions: checks: read @@ -78,3 +90,4 @@ jobs: env: GH_TOKEN: ${{ github.token }} PULL_REQUESTS: ${{ needs.get-prs-for-ci.outputs.numbers }} + MAX_WORKLOAD: ${{ vars.AUTO_START_CI_MAX_WORKLOAD || '15' }} diff --git a/tools/actions/start-ci.sh b/tools/actions/start-ci.sh index 4573b17761c9..6482f366fa31 100755 --- a/tools/actions/start-ci.sh +++ b/tools/actions/start-ci.sh @@ -3,6 +3,14 @@ set -xe cqurl="${GITHUB_SERVER_URL:?}/${GITHUB_REPOSITORY:?}/actions/runs/${GITHUB_RUN_ID:?}" +max_workload=${MAX_WORKLOAD:-15} +case $max_workload in + *[!0-9]*) + echo '::error::AUTO_START_CI_MAX_WORKLOAD must be a positive integer' + exit 1 + ;; + *) ;; +esac escape_code_block_or_line() { case $1 in @@ -19,6 +27,30 @@ escape_code_block_or_line() { } for pr in "$@"; do + # Sleep in between. + [ "$pr" = "$1" ] || sleep 20 + + # Keep request labels until Jenkins is ready to accept more work. + if ! ncu-ci available; then + echo '::notice::CI is unavailable; leaving CI requests for a later run.' + break + fi + if ! workload=$(ncu-ci workload); then + echo '::notice::CI workload could not be checked; leaving CI requests for a later run.' + break + fi + case $workload in + ''|*[!0-9]*) + echo '::error::ncu-ci workload did not return a positive integer' + exit 1 + ;; + *) ;; + esac + if ! [ "$workload" -lt "$max_workload" ]; then + echo "::notice::CI workload is $workload (limit: $max_workload); leaving CI requests for a later run." + break + fi + request_labels=$(gh -R "$GITHUB_REPOSITORY" pr view "$pr" --json labels \ --jq '[.labels[].name | select(. == "request-ci" or . == "resume-ci")] | sort | join(",")') case "$request_labels" in From 51759ddf46d302f1a254547081f9ff88cb68b2e3 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Fri, 25 Sep 2026 16:47:42 -0400 Subject: [PATCH 117/228] http: avoid toLowerCase on the server hot path After #65802, Host/Expect/body checks still allocate toLowerCase() copies and Expect/HTTP/1.0 TE still read req.headers. Compare names without allocating, read those values from rawHeaders, intern parser header names, and skip Title-Case toLowerCase on common outgoing fields. Refs: https://github.com/nodejs/node/pull/65802 Signed-off-by: Yagiz Nizipli Assisted-by: a closed-source coding agent PR-URL: https://github.com/nodejs/node/pull/66120 Refs: https://github.com/nodejs/node/pull/65332 Reviewed-By: Robert Nagy Reviewed-By: Filip Skokan --- lib/_http_incoming.js | 50 +++++++++++++-- lib/_http_outgoing.js | 30 ++++++++- lib/_http_server.js | 52 ++++++++------- .../test-http-server-raw-header-lookup.js | 63 +++++++++++++++++++ 4 files changed, 165 insertions(+), 30 deletions(-) create mode 100644 test/parallel/test-http-server-raw-header-lookup.js diff --git a/lib/_http_incoming.js b/lib/_http_incoming.js index 4fec5008b2a5..d4955974221c 100644 --- a/lib/_http_incoming.js +++ b/lib/_http_incoming.js @@ -56,16 +56,15 @@ function readStop(socket) { /* Abstract base class for ServerRequest and ClientResponse. */ function IncomingMessage(socket) { - let streamOptions; + Readable.call(this); if (socket) { - streamOptions = { - highWaterMark: socket.readableHighWaterMark, - }; + const hwm = socket.readableHighWaterMark; + if (this._readableState.highWaterMark !== hwm) { + this._readableState.highWaterMark = hwm; + } } - Readable.call(this, streamOptions); - this._readableState.readingMore = true; this.socket = socket; @@ -579,6 +578,43 @@ IncomingMessage.prototype._dump = function _dump() { } }; +// Case-insensitive ASCII compare against an already-lowercased name. +// Avoids allocating a lowercased copy of every header name. +function asciiEqualIgnoreCase(a, lower) { + const len = lower.length; + if (a.length !== len) + return false; + if (a === lower) + return true; + for (let i = 0; i < len; i++) { + let c = a.charCodeAt(i); + if (c >= 65 && c <= 90) + c += 32; + if (c !== lower.charCodeAt(i)) + return false; + } + return true; +} + +function getRawHeader(msg, lowerName, joinDuplicates) { + const rawHeaders = msg.rawHeaders; + const count = msg[kHeadersCount]; + let result; + for (let i = 0; i < count; i += 2) { + if (!asciiEqualIgnoreCase(rawHeaders[i], lowerName)) + continue; + const value = rawHeaders[i + 1]; + if (result === undefined) { + result = value; + if (!joinDuplicates) + return result; + } else { + result += ', ' + value; + } + } + return result; +} + function onError(self, error, cb) { // This is to keep backward compatible behavior. // An error is emitted only if there are listeners attached to the event. @@ -593,6 +629,8 @@ module.exports = { IncomingMessage, kDetachAbortSignal, kHeadersCount, + asciiEqualIgnoreCase, + getRawHeader, readStart, readStop, }; diff --git a/lib/_http_outgoing.js b/lib/_http_outgoing.js index 484c3e272a8d..f76376d21420 100644 --- a/lib/_http_outgoing.js +++ b/lib/_http_outgoing.js @@ -652,10 +652,38 @@ function storeHeader(self, state, key, value, validate, lenient) { matchHeader(self, state, key, value); } +function lowerOutgoingHeaderName(field) { + switch (field) { + case 'Connection': + case 'connection': + return 'connection'; + case 'Content-Length': + case 'content-length': + return 'content-length'; + case 'Transfer-Encoding': + case 'transfer-encoding': + return 'transfer-encoding'; + case 'Date': + case 'date': + return 'date'; + case 'Expect': + case 'expect': + return 'expect'; + case 'Trailer': + case 'trailer': + return 'trailer'; + case 'Keep-Alive': + case 'keep-alive': + return 'keep-alive'; + default: + return field.toLowerCase(); + } +} + function matchHeader(self, state, field, value) { if (field.length < 4 || field.length > 17) return; - field = field.toLowerCase(); + field = lowerOutgoingHeaderName(field); switch (field) { case 'connection': state.connection = true; diff --git a/lib/_http_server.js b/lib/_http_server.js index 86ee02c00f02..56f90fb2ffac 100644 --- a/lib/_http_server.js +++ b/lib/_http_server.js @@ -72,6 +72,8 @@ const { IncomingMessage, kDetachAbortSignal, kHeadersCount, + asciiEqualIgnoreCase, + getRawHeader, } = require('_http_incoming'); const { ConnResetException, @@ -217,7 +219,8 @@ function ServerResponse(req, options) { this._expect_continue = false; if (req.httpVersionMajor < 1 || req.httpVersionMinor < 1) { - this.useChunkedEncodingByDefault = chunkExpression.test(req.headers.te); + this.useChunkedEncodingByDefault = + chunkExpression.test(getRawHeader(req, 'te', true)); this.shouldKeepAlive = false; } @@ -1182,10 +1185,8 @@ function hasRequestHeader(req, length, lowerName) { const count = req[kHeadersCount]; for (let i = 0; i < count; i += 2) { const key = rawHeaders[i]; - if (key.length === length && - (key === lowerName || key.toLowerCase() === lowerName)) { + if (key.length === length && asciiEqualIgnoreCase(key, lowerName)) return true; - } } return false; } @@ -1194,12 +1195,14 @@ function hasBodyHeaders(req) { const rawHeaders = req.rawHeaders; const count = req[kHeadersCount]; for (let i = 0; i < count; i += 2) { - const length = rawHeaders[i].length; - if (length === 14 || length === 17) { - const key = rawHeaders[i].toLowerCase(); - if (key === 'content-length' || key === 'transfer-encoding') { + const key = rawHeaders[i]; + const length = key.length; + if (length === 14) { + if (asciiEqualIgnoreCase(key, 'content-length')) + return true; + } else if (length === 17) { + if (asciiEqualIgnoreCase(key, 'transfer-encoding')) return true; - } } } return false; @@ -1333,22 +1336,25 @@ function parserOnIncoming(server, socket, state, req, keepAlive) { server.emit('dropRequest', req, socket); res.writeHead(503); res.end(); - } else if (hasRequestHeader(req, 6, 'expect')) { - handled = true; - - if (continueExpression.test(req.headers.expect)) { - res._expect_continue = true; - if (server.listenerCount('checkContinue') > 0) { - server.emit('checkContinue', req, res); + } else { + const expect = getRawHeader(req, 'expect', true); + if (expect !== undefined) { + handled = true; + + if (continueExpression.test(expect)) { + res._expect_continue = true; + if (server.listenerCount('checkContinue') > 0) { + server.emit('checkContinue', req, res); + } else { + res.writeContinue(); + server.emit('request', req, res); + } + } else if (server.listenerCount('checkExpectation') > 0) { + server.emit('checkExpectation', req, res); } else { - res.writeContinue(); - server.emit('request', req, res); + res.writeHead(417); + res.end(); } - } else if (server.listenerCount('checkExpectation') > 0) { - server.emit('checkExpectation', req, res); - } else { - res.writeHead(417); - res.end(); } } } diff --git a/test/parallel/test-http-server-raw-header-lookup.js b/test/parallel/test-http-server-raw-header-lookup.js new file mode 100644 index 000000000000..620321c0ff30 --- /dev/null +++ b/test/parallel/test-http-server-raw-header-lookup.js @@ -0,0 +1,63 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); +const net = require('net'); + +const server = http.createServer({ + optimizeEmptyRequests: true, +}, common.mustCall((req, res) => { + // Host / Expect lookups must not force materialization of req.headers. + // Accessing headers afterwards still works, including unusual casing. + assert.strictEqual(req.headers.host, 'example.test'); + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('ok'); +}, 3)); + +server.listen(0, common.mustCall(() => { + const port = server.address().port; + + function request(headers) { + return new Promise((resolve, reject) => { + const socket = net.connect(port, '127.0.0.1', () => { + socket.write(headers); + }); + let data = ''; + socket.setEncoding('utf8'); + socket.on('data', (chunk) => { data += chunk; }); + socket.on('end', () => resolve(data)); + socket.on('error', reject); + }); + } + + (async () => { + // Mixed-case Host must satisfy requireHostHeader. + const mixedHost = await request( + 'GET / HTTP/1.1\r\nHOST: example.test\r\nConnection: close\r\n\r\n', + ); + assert.match(mixedHost, /^HTTP\/1\.1 200 /); + + // Unusual Host casing. + const oddHost = await request( + 'GET / HTTP/1.1\r\nhOsT: example.test\r\nConnection: close\r\n\r\n', + ); + assert.match(oddHost, /^HTTP\/1\.1 200 /); + + // Mixed-case Expect: 100-continue still triggers the continue path. + const expectContinue = await request( + 'POST / HTTP/1.1\r\nHost: example.test\r\n' + + 'EXPECT: 100-continue\r\nContent-Length: 0\r\nConnection: close\r\n\r\n', + ); + assert.match(expectContinue, /^HTTP\/1\.1 100 Continue\r\n/); + assert.match(expectContinue, /HTTP\/1\.1 200 /); + + // Missing Host is still rejected. + const missingHost = await request( + 'GET / HTTP/1.1\r\nConnection: close\r\n\r\n', + ); + assert.match(missingHost, /^HTTP\/1\.1 400 /); + + server.close(); + })().then(common.mustCall()); +})); From edcf9b55bf14e94d3d0d167e76da5d1846a6192e Mon Sep 17 00:00:00 2001 From: James M Snell Date: Mon, 28 Sep 2026 06:22:57 +0200 Subject: [PATCH 118/228] buffer: add isLatin1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a fast check to determine if a string is a valid byte string (only chars <= 0xff). Signed-off-by: James M Snell PR-URL: https://github.com/nodejs/node/pull/66298 Reviewed-By: René Reviewed-By: Filip Skokan Reviewed-By: Anna Henningsen Reviewed-By: Robert Nagy From cb44e48802efc5b95896ba5aab77cd1589105481 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Mon, 28 Sep 2026 09:51:16 +0200 Subject: [PATCH 119/228] stream: keep webstreams nil requests in fast mode The shared "no pending request" records in the writable stream were `__proto__: null` literals, which V8 creates in dictionary mode. They sit in inFlightWriteRequest, closeRequest and pendingAbortRequest whenever nothing is pending, and their promise field is checked several times per write, so those loads did a hash lookup on every write and every pipe. They are now built as plain literals and get their null prototype afterwards, which keeps them in fast mode. The readable controllers also initialized their state slot with an empty object that setup replaced immediately. That throwaway allocation is gone, matching the writable and transform controllers. Add a writable-write benchmark: nothing in benchmark/webstreams drove WritableStreamDefaultWriter.write() directly. Signed-off-by: Matteo Collina PR-URL: https://github.com/nodejs/node/pull/66230 Reviewed-By: Mattias Buelens Reviewed-By: Filip Skokan --- benchmark/webstreams/writable-write.js | 31 ++++++++++++++++++++++ lib/internal/webstreams/readablestream.js | 4 +-- lib/internal/webstreams/transformstream.js | 1 + lib/internal/webstreams/writablestream.js | 15 ++++++----- 4 files changed, 42 insertions(+), 9 deletions(-) create mode 100644 benchmark/webstreams/writable-write.js diff --git a/benchmark/webstreams/writable-write.js b/benchmark/webstreams/writable-write.js new file mode 100644 index 000000000000..9cc6a6af2423 --- /dev/null +++ b/benchmark/webstreams/writable-write.js @@ -0,0 +1,31 @@ +'use strict'; +const common = require('../common.js'); +const assert = require('node:assert'); +const { WritableStream } = require('node:stream/web'); + +const bench = common.createBenchmark(main, { + n: [1e5], + type: ['await', 'queued'], +}); + +async function main({ n, type }) { + let count = 0; + const ws = new WritableStream({ + write() { + count++; + }, + }, { highWaterMark: type === 'queued' ? n : 1 }); + const writer = ws.getWriter(); + bench.start(); + if (type === 'await') { + for (let i = 0; i < n; i++) + await writer.write('a'); + } else { + for (let i = 1; i < n; i++) + writer.write('a'); + await writer.write('a'); + } + bench.end(n); + await writer.close(); + assert.strictEqual(count, n); +} diff --git a/lib/internal/webstreams/readablestream.js b/lib/internal/webstreams/readablestream.js index 6b7c857092d5..bbb3d41d0996 100644 --- a/lib/internal/webstreams/readablestream.js +++ b/lib/internal/webstreams/readablestream.js @@ -1140,7 +1140,7 @@ ObjectDefineProperties(ReadableStreamBYOBReader.prototype, { class ReadableStreamDefaultController { [kType] = 'ReadableStreamDefaultController'; - [kState] = {}; + [kState]; constructor(skipThrowSymbol = undefined) { if (skipThrowSymbol !== kSkipThrow) { @@ -1203,7 +1203,7 @@ ObjectDefineProperties(ReadableStreamDefaultController.prototype, { class ReadableByteStreamController { [kType] = 'ReadableByteStreamController'; - [kState] = {}; + [kState]; constructor(skipThrowSymbol = undefined) { if (skipThrowSymbol !== kSkipThrow) { diff --git a/lib/internal/webstreams/transformstream.js b/lib/internal/webstreams/transformstream.js index 25771fb6a558..81f60b284f53 100644 --- a/lib/internal/webstreams/transformstream.js +++ b/lib/internal/webstreams/transformstream.js @@ -303,6 +303,7 @@ TransferredTransformStream.prototype[kDeserialize] = () => {}; class TransformStreamDefaultController { [kType] = 'TransformStreamDefaultController'; + [kState]; constructor(skipThrowSymbol = undefined) { if (skipThrowSymbol !== kSkipThrow) { diff --git a/lib/internal/webstreams/writablestream.js b/lib/internal/webstreams/writablestream.js index 73d7ed0fbf8a..31f2a1f875dc 100644 --- a/lib/internal/webstreams/writablestream.js +++ b/lib/internal/webstreams/writablestream.js @@ -105,19 +105,19 @@ const kSkipThrow = Symbol('kSkipThrow'); // Shared sentinels for the "no pending request" state records. These // records are only ever replaced wholesale and never mutated in place, // so single shared instances are safe and avoid an allocation on every -// state reset (one per write on the hot path). -const kNilRequest = { - __proto__: null, +// state reset (one per write on the hot path). The prototype is nulled +// after creation: a `__proto__: null` literal is created in dictionary +// mode, and these fields are loaded several times per write. +const kNilRequest = ObjectSetPrototypeOf({ promise: undefined, resolve: undefined, reject: undefined, -}; -const kNilPendingAbortRequest = { - __proto__: null, +}, null); +const kNilPendingAbortRequest = ObjectSetPrototypeOf({ abort: kNilRequest, reason: undefined, wasAlreadyErroring: false, -}; +}, null); let releasedError; @@ -502,6 +502,7 @@ ObjectDefineProperties(WritableStreamDefaultWriter.prototype, { class WritableStreamDefaultController { [kType] = 'WritableStreamDefaultController'; + [kState]; constructor(skipThrowSymbol = undefined) { if (skipThrowSymbol !== kSkipThrow) { From c53447a002ec543583b7bbed0439fdb9b62a7ae2 Mon Sep 17 00:00:00 2001 From: Hilary Asogwa <76795262+Dansatch@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:58:14 +0100 Subject: [PATCH 120/228] buffer: fix negative index for large buffers Fix incorrect negative index results for large buffers. Add tests for number, Buffer and string searches beyond 2 GiB. Fixes: https://github.com/nodejs/node/issues/66294 Signed-off-by: dansatch PR-URL: https://github.com/nodejs/node/pull/66325 Reviewed-By: Anna Henningsen Reviewed-By: Filip Skokan --- src/node_buffer.cc | 14 ++++++------ test/pummel/test-buffer-indexof-large.js | 27 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) create mode 100644 test/pummel/test-buffer-indexof-large.js diff --git a/src/node_buffer.cc b/src/node_buffer.cc index 312430f3f3f2..a85e982951e9 100644 --- a/src/node_buffer.cc +++ b/src/node_buffer.cc @@ -1098,8 +1098,8 @@ void IndexOfString(const FunctionCallbackInfo& args) { is_forward); } - args.GetReturnValue().Set(result >= search_end ? -1 - : static_cast(result)); + args.GetReturnValue().Set( + result >= search_end ? -1 : static_cast(result)); } void IndexOfBuffer(const FunctionCallbackInfo& args) { @@ -1186,11 +1186,11 @@ void IndexOfBuffer(const FunctionCallbackInfo& args) { is_forward); } - args.GetReturnValue().Set(result >= search_end ? -1 - : static_cast(result)); + args.GetReturnValue().Set( + result >= search_end ? -1 : static_cast(result)); } -int32_t IndexOfNumberImpl(Local buffer_obj, +int64_t IndexOfNumberImpl(Local buffer_obj, const uint32_t needle, const int64_t offset_i64, const int64_t end_i64, @@ -1217,7 +1217,7 @@ int32_t IndexOfNumberImpl(Local buffer_obj, ptr = nbytes::stringsearch::MemrchrFill(buffer_data, needle, backward_end); } const uint8_t* ptr_uint8 = static_cast(ptr); - return ptr != nullptr ? static_cast(ptr_uint8 - buffer_data) : -1; + return ptr != nullptr ? static_cast(ptr_uint8 - buffer_data) : -1; } void SlowIndexOfNumber(const FunctionCallbackInfo& args) { @@ -1238,7 +1238,7 @@ void SlowIndexOfNumber(const FunctionCallbackInfo& args) { IndexOfNumberImpl(buffer_obj, needle, offset_i64, end_i64, is_forward)); } -int32_t FastIndexOfNumber(Local, +int64_t FastIndexOfNumber(Local, Local buffer_obj, uint32_t needle, int64_t offset_i64, diff --git a/test/pummel/test-buffer-indexof-large.js b/test/pummel/test-buffer-indexof-large.js new file mode 100644 index 000000000000..0f0999705417 --- /dev/null +++ b/test/pummel/test-buffer-indexof-large.js @@ -0,0 +1,27 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); + +common.skipIf32Bits(); + +const match = 2 ** 31 + 100; +let buffer; +try { + buffer = Buffer.allocUnsafe(match + 1); +} catch (error) { + if (error.code === 'ERR_MEMORY_ALLOCATION_FAILED' || + /Array buffer allocation failed/.test(error.message)) { + common.skip('insufficient space for Buffer.allocUnsafe'); + } + throw error; +} + +buffer[match] = 0x0a; + +assert.strictEqual(buffer.indexOf(0x0a, -1), match); +assert.strictEqual(buffer.indexOf(Buffer.from([0x0a]), -1), match); +assert.strictEqual(buffer.indexOf('\n', -1), match); +assert.strictEqual(buffer.lastIndexOf(0x0a, -1), match); +assert.strictEqual(buffer.lastIndexOf(Buffer.from([0x0a]), -1), match); +assert.strictEqual(buffer.lastIndexOf('\n', -1), match); From 6ee1f6d08eb5505a1afb207fa73ec209077735da Mon Sep 17 00:00:00 2001 From: Maksim Romanov Date: Mon, 28 Sep 2026 22:12:21 +0200 Subject: [PATCH 121/228] test: use common.PIPE in fs.cp socket tests The socket was created under a nested tmpdir using an absolute path, which can exceed the 104-byte sun_path limit on macOS when the checkout lives in a long directory. common.PIPE uses a path relative to the cwd, as the other pipe tests do. Fixes: https://github.com/nodejs/node/issues/66324 Assisted-by: a closed-source coding agent Signed-off-by: Maksim Romanov PR-URL: https://github.com/nodejs/node/pull/66329 Reviewed-By: Filip Skokan Reviewed-By: Luigi Pinca Reviewed-By: Chemi Atlow --- test/parallel/test-fs-cp-async-socket.mjs | 10 +++------- test/parallel/test-fs-cp-sync-copy-socket-error.mjs | 10 +++------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/test/parallel/test-fs-cp-async-socket.mjs b/test/parallel/test-fs-cp-async-socket.mjs index f2010e6b3fe3..41c37d26d47e 100644 --- a/test/parallel/test-fs-cp-async-socket.mjs +++ b/test/parallel/test-fs-cp-async-socket.mjs @@ -2,9 +2,8 @@ import * as common from '../common/index.mjs'; import assert from 'node:assert'; -import { cp, mkdirSync } from 'node:fs'; +import { cp } from 'node:fs'; import { createServer } from 'node:net'; -import { join } from 'node:path'; import { nextdir } from '../common/fs.js'; import tmpdir from '../common/tmpdir.js'; @@ -21,13 +20,10 @@ if (common.isInsideDirWithUnusualChars) { tmpdir.refresh(); { - const src = nextdir(); - mkdirSync(src); const dest = nextdir(); - const sock = join(src, `${process.pid}.sock`); const server = createServer(); - server.listen(sock); - cp(sock, dest, common.mustCall((err) => { + server.listen(common.PIPE); + cp(common.PIPE, dest, common.mustCall((err) => { assert.strictEqual(err.code, 'ERR_FS_CP_SOCKET'); server.close(); })); diff --git a/test/parallel/test-fs-cp-sync-copy-socket-error.mjs b/test/parallel/test-fs-cp-sync-copy-socket-error.mjs index dae8786a78df..f198ca33bba9 100644 --- a/test/parallel/test-fs-cp-sync-copy-socket-error.mjs +++ b/test/parallel/test-fs-cp-sync-copy-socket-error.mjs @@ -2,9 +2,8 @@ import * as common from '../common/index.mjs'; import { nextdir } from '../common/fs.js'; import assert from 'node:assert'; -import { cpSync, mkdirSync } from 'node:fs'; +import { cpSync } from 'node:fs'; import { createServer } from 'node:net'; -import { join } from 'node:path'; import tmpdir from '../common/tmpdir.js'; const isWindows = process.platform === 'win32'; @@ -20,14 +19,11 @@ if (common.isInsideDirWithUnusualChars) { tmpdir.refresh(); { - const src = nextdir(); - mkdirSync(src); const dest = nextdir(); - const sock = join(src, `${process.pid}.sock`); const server = createServer(); - server.listen(sock); + server.listen(common.PIPE); assert.throws( - () => cpSync(sock, dest), + () => cpSync(common.PIPE, dest), { code: 'ERR_FS_CP_SOCKET' } ); server.close(); From a9a15e0b3c970ae5ccc607310b0808e00527df59 Mon Sep 17 00:00:00 2001 From: npm CLI robot Date: Thu, 24 Sep 2026 18:28:22 +0000 Subject: [PATCH 122/228] deps: upgrade npm to 11.20.0 PR-URL: https://github.com/nodejs/node/pull/66259 Reviewed-By: Mike McCready <66998419+MikeMcC399@users.noreply.github.com> Reviewed-By: Richard Lau --- deps/npm/docs/content/commands/npm-ci.md | 2 +- deps/npm/docs/content/commands/npm-exec.md | 2 +- .../content/commands/npm-install-ci-test.md | 2 +- .../docs/content/commands/npm-install-test.md | 2 +- deps/npm/docs/content/commands/npm-install.md | 2 +- deps/npm/docs/content/commands/npm-ls.md | 2 +- deps/npm/docs/content/commands/npm-publish.md | 6 + deps/npm/docs/content/commands/npm-rebuild.md | 2 +- deps/npm/docs/content/commands/npm-stage.md | 2 +- deps/npm/docs/content/commands/npm-token.md | 7 +- deps/npm/docs/content/commands/npm-update.md | 2 +- deps/npm/docs/content/commands/npm.md | 2 +- deps/npm/docs/content/using-npm/config.md | 15 +- deps/npm/docs/output/commands/npm-access.html | 4 +- .../npm/docs/output/commands/npm-adduser.html | 4 +- .../output/commands/npm-approve-scripts.html | 4 +- deps/npm/docs/output/commands/npm-audit.html | 4 +- deps/npm/docs/output/commands/npm-bugs.html | 4 +- deps/npm/docs/output/commands/npm-cache.html | 4 +- deps/npm/docs/output/commands/npm-ci.html | 5 +- .../docs/output/commands/npm-completion.html | 4 +- deps/npm/docs/output/commands/npm-config.html | 4 +- deps/npm/docs/output/commands/npm-dedupe.html | 4 +- .../output/commands/npm-deny-scripts.html | 4 +- .../docs/output/commands/npm-deprecate.html | 4 +- deps/npm/docs/output/commands/npm-diff.html | 4 +- .../docs/output/commands/npm-dist-tag.html | 4 +- deps/npm/docs/output/commands/npm-docs.html | 4 +- deps/npm/docs/output/commands/npm-doctor.html | 4 +- deps/npm/docs/output/commands/npm-edit.html | 4 +- deps/npm/docs/output/commands/npm-exec.html | 5 +- .../npm/docs/output/commands/npm-explain.html | 4 +- .../npm/docs/output/commands/npm-explore.html | 4 +- .../docs/output/commands/npm-find-dupes.html | 4 +- deps/npm/docs/output/commands/npm-fund.html | 4 +- deps/npm/docs/output/commands/npm-get.html | 4 +- .../docs/output/commands/npm-help-search.html | 4 +- deps/npm/docs/output/commands/npm-help.html | 4 +- deps/npm/docs/output/commands/npm-init.html | 4 +- .../output/commands/npm-install-ci-test.html | 5 +- .../output/commands/npm-install-scripts.html | 4 +- .../output/commands/npm-install-test.html | 5 +- .../npm/docs/output/commands/npm-install.html | 5 +- deps/npm/docs/output/commands/npm-link.html | 4 +- deps/npm/docs/output/commands/npm-ll.html | 4 +- deps/npm/docs/output/commands/npm-login.html | 4 +- deps/npm/docs/output/commands/npm-logout.html | 4 +- deps/npm/docs/output/commands/npm-ls.html | 6 +- deps/npm/docs/output/commands/npm-org.html | 4 +- .../docs/output/commands/npm-outdated.html | 4 +- deps/npm/docs/output/commands/npm-owner.html | 4 +- deps/npm/docs/output/commands/npm-pack.html | 4 +- deps/npm/docs/output/commands/npm-ping.html | 4 +- deps/npm/docs/output/commands/npm-pkg.html | 4 +- deps/npm/docs/output/commands/npm-prefix.html | 4 +- .../npm/docs/output/commands/npm-profile.html | 4 +- deps/npm/docs/output/commands/npm-prune.html | 4 +- .../npm/docs/output/commands/npm-publish.html | 8 +- deps/npm/docs/output/commands/npm-query.html | 4 +- .../npm/docs/output/commands/npm-rebuild.html | 5 +- deps/npm/docs/output/commands/npm-repo.html | 4 +- .../npm/docs/output/commands/npm-restart.html | 4 +- deps/npm/docs/output/commands/npm-root.html | 4 +- deps/npm/docs/output/commands/npm-run.html | 4 +- deps/npm/docs/output/commands/npm-sbom.html | 4 +- deps/npm/docs/output/commands/npm-search.html | 4 +- deps/npm/docs/output/commands/npm-set.html | 4 +- .../docs/output/commands/npm-shrinkwrap.html | 4 +- deps/npm/docs/output/commands/npm-stage.html | 6 +- deps/npm/docs/output/commands/npm-star.html | 4 +- deps/npm/docs/output/commands/npm-stars.html | 4 +- deps/npm/docs/output/commands/npm-start.html | 4 +- deps/npm/docs/output/commands/npm-stop.html | 4 +- deps/npm/docs/output/commands/npm-team.html | 4 +- deps/npm/docs/output/commands/npm-test.html | 4 +- deps/npm/docs/output/commands/npm-token.html | 11 +- deps/npm/docs/output/commands/npm-trust.html | 4 +- .../docs/output/commands/npm-undeprecate.html | 4 +- .../docs/output/commands/npm-uninstall.html | 4 +- .../docs/output/commands/npm-unpublish.html | 4 +- deps/npm/docs/output/commands/npm-unstar.html | 4 +- deps/npm/docs/output/commands/npm-update.html | 5 +- .../npm/docs/output/commands/npm-version.html | 4 +- deps/npm/docs/output/commands/npm-view.html | 4 +- deps/npm/docs/output/commands/npm-whoami.html | 4 +- deps/npm/docs/output/commands/npm.html | 6 +- deps/npm/docs/output/commands/npx.html | 4 +- .../docs/output/configuring-npm/folders.html | 4 +- .../docs/output/configuring-npm/install.html | 4 +- .../output/configuring-npm/npm-global.html | 4 +- .../docs/output/configuring-npm/npm-json.html | 4 +- .../configuring-npm/npm-shrinkwrap-json.html | 4 +- .../docs/output/configuring-npm/npmrc.html | 4 +- .../output/configuring-npm/package-json.html | 4 +- .../configuring-npm/package-lock-json.html | 4 +- deps/npm/docs/output/using-npm/config.html | 16 +- .../using-npm/dependency-selectors.html | 4 +- .../npm/docs/output/using-npm/developers.html | 4 +- deps/npm/docs/output/using-npm/logging.html | 4 +- deps/npm/docs/output/using-npm/orgs.html | 4 +- .../docs/output/using-npm/package-spec.html | 4 +- deps/npm/docs/output/using-npm/registry.html | 4 +- deps/npm/docs/output/using-npm/removal.html | 4 +- deps/npm/docs/output/using-npm/scope.html | 4 +- deps/npm/docs/output/using-npm/scripts.html | 4 +- .../npm/docs/output/using-npm/workspaces.html | 4 +- deps/npm/lib/commands/token.js | 19 ++ deps/npm/lib/utils/allow-scripts-writer.js | 4 +- deps/npm/lib/utils/key-values.js | 14 +- deps/npm/lib/utils/oidc.js | 6 +- deps/npm/lib/utils/reify-output.js | 5 +- deps/npm/man/man1/npm-access.1 | 2 +- deps/npm/man/man1/npm-adduser.1 | 2 +- deps/npm/man/man1/npm-approve-scripts.1 | 2 +- deps/npm/man/man1/npm-audit.1 | 2 +- deps/npm/man/man1/npm-bugs.1 | 2 +- deps/npm/man/man1/npm-cache.1 | 2 +- deps/npm/man/man1/npm-ci.1 | 4 +- deps/npm/man/man1/npm-completion.1 | 2 +- deps/npm/man/man1/npm-config.1 | 2 +- deps/npm/man/man1/npm-dedupe.1 | 2 +- deps/npm/man/man1/npm-deny-scripts.1 | 2 +- deps/npm/man/man1/npm-deprecate.1 | 2 +- deps/npm/man/man1/npm-diff.1 | 2 +- deps/npm/man/man1/npm-dist-tag.1 | 2 +- deps/npm/man/man1/npm-docs.1 | 2 +- deps/npm/man/man1/npm-doctor.1 | 2 +- deps/npm/man/man1/npm-edit.1 | 2 +- deps/npm/man/man1/npm-exec.1 | 4 +- deps/npm/man/man1/npm-explain.1 | 2 +- deps/npm/man/man1/npm-explore.1 | 2 +- deps/npm/man/man1/npm-find-dupes.1 | 2 +- deps/npm/man/man1/npm-fund.1 | 2 +- deps/npm/man/man1/npm-get.1 | 2 +- deps/npm/man/man1/npm-help-search.1 | 2 +- deps/npm/man/man1/npm-help.1 | 2 +- deps/npm/man/man1/npm-init.1 | 2 +- deps/npm/man/man1/npm-install-ci-test.1 | 4 +- deps/npm/man/man1/npm-install-scripts.1 | 2 +- deps/npm/man/man1/npm-install-test.1 | 4 +- deps/npm/man/man1/npm-install.1 | 4 +- deps/npm/man/man1/npm-link.1 | 2 +- deps/npm/man/man1/npm-ll.1 | 2 +- deps/npm/man/man1/npm-login.1 | 2 +- deps/npm/man/man1/npm-logout.1 | 2 +- deps/npm/man/man1/npm-ls.1 | 4 +- deps/npm/man/man1/npm-org.1 | 2 +- deps/npm/man/man1/npm-outdated.1 | 2 +- deps/npm/man/man1/npm-owner.1 | 2 +- deps/npm/man/man1/npm-pack.1 | 2 +- deps/npm/man/man1/npm-ping.1 | 2 +- deps/npm/man/man1/npm-pkg.1 | 2 +- deps/npm/man/man1/npm-prefix.1 | 2 +- deps/npm/man/man1/npm-profile.1 | 2 +- deps/npm/man/man1/npm-prune.1 | 2 +- deps/npm/man/man1/npm-publish.1 | 6 +- deps/npm/man/man1/npm-query.1 | 2 +- deps/npm/man/man1/npm-rebuild.1 | 4 +- deps/npm/man/man1/npm-repo.1 | 2 +- deps/npm/man/man1/npm-restart.1 | 2 +- deps/npm/man/man1/npm-root.1 | 2 +- deps/npm/man/man1/npm-run.1 | 2 +- deps/npm/man/man1/npm-sbom.1 | 2 +- deps/npm/man/man1/npm-search.1 | 2 +- deps/npm/man/man1/npm-set.1 | 2 +- deps/npm/man/man1/npm-shrinkwrap.1 | 2 +- deps/npm/man/man1/npm-stage.1 | 4 +- deps/npm/man/man1/npm-star.1 | 2 +- deps/npm/man/man1/npm-stars.1 | 2 +- deps/npm/man/man1/npm-start.1 | 2 +- deps/npm/man/man1/npm-stop.1 | 2 +- deps/npm/man/man1/npm-team.1 | 2 +- deps/npm/man/man1/npm-test.1 | 2 +- deps/npm/man/man1/npm-token.1 | 6 +- deps/npm/man/man1/npm-trust.1 | 2 +- deps/npm/man/man1/npm-undeprecate.1 | 2 +- deps/npm/man/man1/npm-uninstall.1 | 2 +- deps/npm/man/man1/npm-unpublish.1 | 2 +- deps/npm/man/man1/npm-unstar.1 | 2 +- deps/npm/man/man1/npm-update.1 | 4 +- deps/npm/man/man1/npm-version.1 | 2 +- deps/npm/man/man1/npm-view.1 | 2 +- deps/npm/man/man1/npm-whoami.1 | 2 +- deps/npm/man/man1/npm.1 | 4 +- deps/npm/man/man1/npx.1 | 2 +- deps/npm/man/man5/folders.5 | 2 +- deps/npm/man/man5/install.5 | 2 +- deps/npm/man/man5/npm-global.5 | 2 +- deps/npm/man/man5/npm-json.5 | 2 +- deps/npm/man/man5/npm-shrinkwrap-json.5 | 2 +- deps/npm/man/man5/npmrc.5 | 2 +- deps/npm/man/man5/package-json.5 | 2 +- deps/npm/man/man5/package-lock-json.5 | 2 +- deps/npm/man/man7/config.7 | 12 +- deps/npm/man/man7/dependency-selectors.7 | 2 +- deps/npm/man/man7/developers.7 | 2 +- deps/npm/man/man7/logging.7 | 2 +- deps/npm/man/man7/orgs.7 | 2 +- deps/npm/man/man7/package-spec.7 | 2 +- deps/npm/man/man7/registry.7 | 2 +- deps/npm/man/man7/removal.7 | 2 +- deps/npm/man/man7/scope.7 | 2 +- deps/npm/man/man7/scripts.7 | 2 +- deps/npm/man/man7/workspaces.7 | 2 +- .../arborist/lib/arborist/build-ideal-tree.js | 26 ++ .../@npmcli/arborist/lib/script-allowed.js | 12 +- .../@npmcli/arborist/package.json | 2 +- .../config/lib/definitions/definitions.js | 14 +- .../node_modules/@npmcli/config/package.json | 2 +- deps/npm/node_modules/libnpmdiff/package.json | 4 +- deps/npm/node_modules/libnpmexec/package.json | 4 +- deps/npm/node_modules/libnpmfund/package.json | 4 +- deps/npm/node_modules/libnpmpack/package.json | 4 +- deps/npm/node_modules/libnpmpublish/README.md | 8 +- .../node_modules/libnpmpublish/lib/publish.js | 6 + .../node_modules/libnpmpublish/package.json | 2 +- deps/npm/package.json | 16 +- .../tap-snapshots/test/lib/docs.js.test.cjs | 17 +- deps/npm/test/fixtures/mock-oidc.js | 7 +- deps/npm/test/lib/commands/pack.js | 10 +- deps/npm/test/lib/commands/publish.js | 252 ++++++++++++++++++ deps/npm/test/lib/commands/stage/list.js | 7 + deps/npm/test/lib/commands/stage/view.js | 3 + deps/npm/test/lib/commands/token.js | 112 ++++++++ deps/npm/test/lib/commands/uninstall.js | 28 ++ .../npm/test/lib/utils/allow-scripts-prune.js | 21 ++ .../test/lib/utils/allow-scripts-writer.js | 41 +++ deps/npm/test/lib/utils/key-values.js | 34 +++ deps/npm/test/lib/utils/reify-output.js | 76 ++++++ .../test/lib/utils/resolve-allow-scripts.js | 16 ++ 230 files changed, 1105 insertions(+), 351 deletions(-) diff --git a/deps/npm/docs/content/commands/npm-ci.md b/deps/npm/docs/content/commands/npm-ci.md index 741caf5eae70..8cf6a42690b3 100644 --- a/deps/npm/docs/content/commands/npm-ci.md +++ b/deps/npm/docs/content/commands/npm-ci.md @@ -290,7 +290,7 @@ Each name is matched against a dependency's resolved identity, not against the package's self-reported name. `--ignore-scripts` and `--dangerously-allow-all-scripts` both override this setting. - +This value is not exported to the environment for child processes. #### `strict-allow-scripts` diff --git a/deps/npm/docs/content/commands/npm-exec.md b/deps/npm/docs/content/commands/npm-exec.md index ff08d07786a8..87be6dbfd672 100644 --- a/deps/npm/docs/content/commands/npm-exec.md +++ b/deps/npm/docs/content/commands/npm-exec.md @@ -178,7 +178,7 @@ Each name is matched against a dependency's resolved identity, not against the package's self-reported name. `--ignore-scripts` and `--dangerously-allow-all-scripts` both override this setting. - +This value is not exported to the environment for child processes. #### `strict-allow-scripts` diff --git a/deps/npm/docs/content/commands/npm-install-ci-test.md b/deps/npm/docs/content/commands/npm-install-ci-test.md index 2194a4df84a8..667748635734 100644 --- a/deps/npm/docs/content/commands/npm-install-ci-test.md +++ b/deps/npm/docs/content/commands/npm-install-ci-test.md @@ -243,7 +243,7 @@ Each name is matched against a dependency's resolved identity, not against the package's self-reported name. `--ignore-scripts` and `--dangerously-allow-all-scripts` both override this setting. - +This value is not exported to the environment for child processes. #### `strict-allow-scripts` diff --git a/deps/npm/docs/content/commands/npm-install-test.md b/deps/npm/docs/content/commands/npm-install-test.md index e13f79a51e6f..4311f80279d9 100644 --- a/deps/npm/docs/content/commands/npm-install-test.md +++ b/deps/npm/docs/content/commands/npm-install-test.md @@ -320,7 +320,7 @@ Each name is matched against a dependency's resolved identity, not against the package's self-reported name. `--ignore-scripts` and `--dangerously-allow-all-scripts` both override this setting. - +This value is not exported to the environment for child processes. #### `strict-allow-scripts` diff --git a/deps/npm/docs/content/commands/npm-install.md b/deps/npm/docs/content/commands/npm-install.md index 98d69d5e8424..efc07bc7598f 100644 --- a/deps/npm/docs/content/commands/npm-install.md +++ b/deps/npm/docs/content/commands/npm-install.md @@ -662,7 +662,7 @@ Each name is matched against a dependency's resolved identity, not against the package's self-reported name. `--ignore-scripts` and `--dangerously-allow-all-scripts` both override this setting. - +This value is not exported to the environment for child processes. #### `strict-allow-scripts` diff --git a/deps/npm/docs/content/commands/npm-ls.md b/deps/npm/docs/content/commands/npm-ls.md index b38603c203fc..e3be39fcf040 100644 --- a/deps/npm/docs/content/commands/npm-ls.md +++ b/deps/npm/docs/content/commands/npm-ls.md @@ -23,7 +23,7 @@ Note that nested packages will *also* show the paths to the specified packages. For example, running `npm ls promzard` in npm's source tree will show: ```bash -npm@11.19.1 /path/to/npm +npm@11.20.0 /path/to/npm └─┬ init-package-json@0.0.4 └── promzard@0.1.5 ``` diff --git a/deps/npm/docs/content/commands/npm-publish.md b/deps/npm/docs/content/commands/npm-publish.md index 04c020b3563f..f0999657fc05 100644 --- a/deps/npm/docs/content/commands/npm-publish.md +++ b/deps/npm/docs/content/commands/npm-publish.md @@ -222,6 +222,9 @@ This value is not exported to the environment for child processes. When publishing from a supported cloud CI/CD system, the package will be publicly linked to where it was built and published from. +When the `provenance-file` config is set, it takes precedence and automatic +provenance generation (including via trusted publishing/OIDC) is skipped. + This config cannot be used with: `provenance-file` #### `provenance-file` @@ -231,6 +234,9 @@ This config cannot be used with: `provenance-file` When publishing, the provenance bundle at the given path will be used. +This takes precedence over automatic provenance generation in trusted +publishing flows. + This config cannot be used with: `provenance` ### See Also diff --git a/deps/npm/docs/content/commands/npm-rebuild.md b/deps/npm/docs/content/commands/npm-rebuild.md index c70307a2a7fe..6b3095ff9d17 100644 --- a/deps/npm/docs/content/commands/npm-rebuild.md +++ b/deps/npm/docs/content/commands/npm-rebuild.md @@ -120,7 +120,7 @@ Each name is matched against a dependency's resolved identity, not against the package's self-reported name. `--ignore-scripts` and `--dangerously-allow-all-scripts` both override this setting. - +This value is not exported to the environment for child processes. #### `strict-allow-scripts` diff --git a/deps/npm/docs/content/commands/npm-stage.md b/deps/npm/docs/content/commands/npm-stage.md index 798d6c2d953a..4228a43cce77 100644 --- a/deps/npm/docs/content/commands/npm-stage.md +++ b/deps/npm/docs/content/commands/npm-stage.md @@ -158,7 +158,7 @@ npm stage publish | `--workspace`, `-w` | | String (can be set multiple times) | Enable running a command in the context of the configured workspaces of the current project while filtering by running only the workspaces defined by this configuration option. Valid values for the `workspace` config are either: * Workspace names * Path to a workspace directory * Path to a parent workspace directory (will result in selecting all workspaces within that folder) When set for the `npm init` command, this may be set to the folder of a workspace which does not yet exist, to create the folder and set it up as a brand new workspace within the project. | | `--workspaces` | null | null or Boolean | Set to true to run the command in the context of **all** configured workspaces. Explicitly setting this to false will cause commands like `install` to ignore workspaces altogether. When not set explicitly: - Commands that operate on the `node_modules` tree (install, update, etc.) will link workspaces into the `node_modules` folder. - Commands that do other things (test, exec, publish, etc.) will operate on the root project, _unless_ one or more workspaces are specified in the `workspace` config. | | `--include-workspace-root` | false | Boolean | Include the workspace root when workspaces are enabled for a command. When false, specifying individual workspaces via the `workspace` config, or all workspaces via the `workspaces` flag, will cause npm to operate only on the specified workspaces, and not on the root project. | -| `--provenance` | false | Boolean | When publishing from a supported cloud CI/CD system, the package will be publicly linked to where it was built and published from. | +| `--provenance` | false | Boolean | When publishing from a supported cloud CI/CD system, the package will be publicly linked to where it was built and published from. When the `provenance-file` config is set, it takes precedence and automatic provenance generation (including via trusted publishing/OIDC) is skipped. | ### `npm stage list` diff --git a/deps/npm/docs/content/commands/npm-token.md b/deps/npm/docs/content/commands/npm-token.md index 3c8e08d7fc38..45e9347f9d04 100644 --- a/deps/npm/docs/content/commands/npm-token.md +++ b/deps/npm/docs/content/commands/npm-token.md @@ -110,11 +110,14 @@ the token access to specific organizations. #### `packages-and-scopes-permission` * Default: null -* Type: null, "read-only", "read-write", or "no-access" +* Type: null, "read-only", "read-write", "read-write-stage-only", or + "no-access" When creating a Granular Access Token with `npm token create`, sets the permission level for packages and scopes. Options are "read-only", -"read-write", or "no-access". +"read-write", "read-write-stage-only", or "no-access". +"read-write-stage-only" grants publish access that stages releases instead +of publishing them directly. diff --git a/deps/npm/docs/content/commands/npm-update.md b/deps/npm/docs/content/commands/npm-update.md index 317f85f7d0db..6da8ef5ac0e2 100644 --- a/deps/npm/docs/content/commands/npm-update.md +++ b/deps/npm/docs/content/commands/npm-update.md @@ -330,7 +330,7 @@ Each name is matched against a dependency's resolved identity, not against the package's self-reported name. `--ignore-scripts` and `--dangerously-allow-all-scripts` both override this setting. - +This value is not exported to the environment for child processes. #### `strict-allow-scripts` diff --git a/deps/npm/docs/content/commands/npm.md b/deps/npm/docs/content/commands/npm.md index 75157bd7b598..7789159b987f 100644 --- a/deps/npm/docs/content/commands/npm.md +++ b/deps/npm/docs/content/commands/npm.md @@ -14,7 +14,7 @@ Note: This command is unaware of workspaces. ### Version -11.19.1 +11.20.0 ### Description diff --git a/deps/npm/docs/content/using-npm/config.md b/deps/npm/docs/content/using-npm/config.md index 5d951493e8a1..71743e7fea3d 100644 --- a/deps/npm/docs/content/using-npm/config.md +++ b/deps/npm/docs/content/using-npm/config.md @@ -271,7 +271,7 @@ Each name is matched against a dependency's resolved identity, not against the package's self-reported name. `--ignore-scripts` and `--dangerously-allow-all-scripts` both override this setting. - +This value is not exported to the environment for child processes. #### `allow-scripts-pending` @@ -1473,11 +1473,14 @@ token access to all packages instead of limiting to specific packages. #### `packages-and-scopes-permission` * Default: null -* Type: null, "read-only", "read-write", or "no-access" +* Type: null, "read-only", "read-write", "read-write-stage-only", or + "no-access" When creating a Granular Access Token with `npm token create`, sets the permission level for packages and scopes. Options are "read-only", -"read-write", or "no-access". +"read-write", "read-write-stage-only", or "no-access". +"read-write-stage-only" grants publish access that stages releases instead +of publishing them directly. @@ -1575,6 +1578,9 @@ Set to `false` to suppress the progress bar. When publishing from a supported cloud CI/CD system, the package will be publicly linked to where it was built and published from. +When the `provenance-file` config is set, it takes precedence and automatic +provenance generation (including via trusted publishing/OIDC) is skipped. + This config cannot be used with: `provenance-file` #### `provenance-file` @@ -1584,6 +1590,9 @@ This config cannot be used with: `provenance-file` When publishing, the provenance bundle at the given path will be used. +This takes precedence over automatic provenance generation in trusted +publishing flows. + This config cannot be used with: `provenance` #### `proxy` diff --git a/deps/npm/docs/output/commands/npm-access.html b/deps/npm/docs/output/commands/npm-access.html index ac6df64b960b..9e232c895e99 100644 --- a/deps/npm/docs/output/commands/npm-access.html +++ b/deps/npm/docs/output/commands/npm-access.html @@ -186,9 +186,9 @@
-

+

npm-access - @11.19.1 + @11.20.0

Set access level on published packages
diff --git a/deps/npm/docs/output/commands/npm-adduser.html b/deps/npm/docs/output/commands/npm-adduser.html index 29aec6314e96..f1fdd66f3096 100644 --- a/deps/npm/docs/output/commands/npm-adduser.html +++ b/deps/npm/docs/output/commands/npm-adduser.html @@ -186,9 +186,9 @@
-

+

npm-adduser - @11.19.1 + @11.20.0

Add a registry user account
diff --git a/deps/npm/docs/output/commands/npm-approve-scripts.html b/deps/npm/docs/output/commands/npm-approve-scripts.html index 3d61cc8ea23a..8b54f89168b2 100644 --- a/deps/npm/docs/output/commands/npm-approve-scripts.html +++ b/deps/npm/docs/output/commands/npm-approve-scripts.html @@ -186,9 +186,9 @@
-

+

npm-approve-scripts - @11.19.1 + @11.20.0

Approve install scripts for specific dependencies
diff --git a/deps/npm/docs/output/commands/npm-audit.html b/deps/npm/docs/output/commands/npm-audit.html index 9117faa9bed5..d093dc1d253d 100644 --- a/deps/npm/docs/output/commands/npm-audit.html +++ b/deps/npm/docs/output/commands/npm-audit.html @@ -186,9 +186,9 @@
-

+

npm-audit - @11.19.1 + @11.20.0

Run a security audit
diff --git a/deps/npm/docs/output/commands/npm-bugs.html b/deps/npm/docs/output/commands/npm-bugs.html index 63f4c29210a2..98cc06a6698f 100644 --- a/deps/npm/docs/output/commands/npm-bugs.html +++ b/deps/npm/docs/output/commands/npm-bugs.html @@ -186,9 +186,9 @@
-

+

npm-bugs - @11.19.1 + @11.20.0

Report bugs for a package in a web browser
diff --git a/deps/npm/docs/output/commands/npm-cache.html b/deps/npm/docs/output/commands/npm-cache.html index 3377b00cc56c..9f49c5378467 100644 --- a/deps/npm/docs/output/commands/npm-cache.html +++ b/deps/npm/docs/output/commands/npm-cache.html @@ -186,9 +186,9 @@
-

+

npm-cache - @11.19.1 + @11.20.0

Manipulates packages cache
diff --git a/deps/npm/docs/output/commands/npm-ci.html b/deps/npm/docs/output/commands/npm-ci.html index edde5bbdef66..5ef13e2947be 100644 --- a/deps/npm/docs/output/commands/npm-ci.html +++ b/deps/npm/docs/output/commands/npm-ci.html @@ -186,9 +186,9 @@
-

+

npm-ci - @11.19.1 + @11.20.0

Clean install a project
@@ -414,6 +414,7 @@

allow-scripts

Each name is matched against a dependency's resolved identity, not against the package's self-reported name. --ignore-scripts and --dangerously-allow-all-scripts both override this setting.

+

This value is not exported to the environment for child processes.

strict-allow-scripts