From 5da6eaa8afa4cf77aeeda9e8d7212aa7ed6ae3a5 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Tue, 4 Aug 2026 12:05:47 +0100 Subject: [PATCH 001/344] sqlite: prevent database close during callbacks Co-authored-by: Asroy Cristian Sitorus Signed-off-by: Matteo Collina PR-URL: https://github.com/nodejs/node/pull/64743 Reviewed-By: Yagiz Nizipli Reviewed-By: Edy Silva Reviewed-By: James M Snell --- src/node_sqlite.cc | 42 +++++++++++++++++++++----- src/node_sqlite.h | 18 +++++++++++ test/parallel/test-sqlite-udf-close.js | 39 ++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 test/parallel/test-sqlite-udf-close.js diff --git a/src/node_sqlite.cc b/src/node_sqlite.cc index 272e6ffd0aeb..038af9812f9e 100644 --- a/src/node_sqlite.cc +++ b/src/node_sqlite.cc @@ -349,6 +349,7 @@ class CustomAggregate { Global CustomAggregate::*mptr) { CustomAggregate* self = static_cast(sqlite3_user_data(ctx)); + CallbackDepthGuard guard(self->db_); Environment* env = self->env_; Isolate* isolate = env->isolate(); auto agg = self->GetAggregate(ctx); @@ -395,12 +396,18 @@ class CustomAggregate { return; } + if (!self->db_->IsOpen()) { + THROW_ERR_INVALID_STATE(env, "database is not open"); + return; + } + agg->value.Reset(isolate, ret); } static inline void xValueBase(sqlite3_context* ctx, bool is_final) { CustomAggregate* self = static_cast(sqlite3_user_data(ctx)); + CallbackDepthGuard guard(self->db_); Environment* env = self->env_; Isolate* isolate = env->isolate(); auto agg = self->GetAggregate(ctx); @@ -426,6 +433,9 @@ class CustomAggregate { .ToLocal(&result)) { self->db_->SetIgnoreNextSQLiteError(true); sqlite3_result_error(ctx, "", 0); + } else if (!self->db_->IsOpen()) { + THROW_ERR_INVALID_STATE(env, "database is not open"); + return; } } else { result = Local::New(isolate, agg->value); @@ -457,6 +467,10 @@ class CustomAggregate { auto fn = start_v.As(); MaybeLocal retval = fn->Call(env_->context(), Null(isolate), 0, nullptr); + if (!db_->IsOpen()) { + THROW_ERR_INVALID_STATE(env_, "database is not open"); + return nullptr; + } if (!retval.ToLocal(&start_v)) { db_->SetIgnoreNextSQLiteError(true); sqlite3_result_error(ctx, "", 0); @@ -669,6 +683,7 @@ void UserDefinedFunction::xFunc(sqlite3_context* ctx, sqlite3_value** argv) { UserDefinedFunction* self = static_cast(sqlite3_user_data(ctx)); + CallbackDepthGuard guard(self->db_); Environment* env = self->env_; Isolate* isolate = env->isolate(); auto recv = Undefined(isolate); @@ -700,6 +715,12 @@ void UserDefinedFunction::xFunc(sqlite3_context* ctx, MaybeLocal retval = fn->Call(env->context(), recv, argc, js_argv.data()); + + if (!self->db_->IsOpen()) { + THROW_ERR_INVALID_STATE(env, "database is not open"); + return; + } + Local result; if (!retval.ToLocal(&result)) { // Ignore the SQLite error because a JavaScript exception is pending. @@ -1433,6 +1454,8 @@ void DatabaseSync::Close(const FunctionCallbackInfo& args) { ASSIGN_OR_RETURN_UNWRAP(&db, args.This()); Environment* env = Environment::GetCurrent(args); THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open"); + THROW_AND_RETURN_ON_BAD_STATE( + env, db->IsInCallback(), "database cannot be closed while in a callback"); db->FinalizeStatements(); db->DeleteSessions(); int r = sqlite3_close_v2(db->connection_); @@ -2381,13 +2404,17 @@ void DatabaseSync::ApplyChangeset(const FunctionCallbackInfo& args) { BaseObjectPtr guard(db); ArrayBufferViewContents buf(args[0]); - int r = sqlite3changeset_apply( - db->connection_, - buf.length(), - const_cast(static_cast(buf.data())), - context.filterCallback ? xFilter : nullptr, - xConflict, - static_cast(&context)); + int r; + { + CallbackDepthGuard guard(db); + r = sqlite3changeset_apply( + db->connection_, + buf.length(), + const_cast(static_cast(buf.data())), + context.filterCallback ? xFilter : nullptr, + xConflict, + static_cast(&context)); + } if (r == SQLITE_OK) { args.GetReturnValue().Set(true); return; @@ -2522,6 +2549,7 @@ int DatabaseSync::AuthorizerCallback(void* user_data, const char* param3, const char* param4) { DatabaseSync* db = static_cast(user_data); + CallbackDepthGuard guard(db); Environment* env = db->env(); Isolate* isolate = env->isolate(); HandleScope handle_scope(isolate); diff --git a/src/node_sqlite.h b/src/node_sqlite.h index 48463b215cb3..9046b022eea4 100644 --- a/src/node_sqlite.h +++ b/src/node_sqlite.h @@ -229,6 +229,10 @@ class DatabaseSync : public BaseObject { void SetIgnoreNextSQLiteError(bool ignore); bool ShouldIgnoreSQLiteError(); + void IncrementCallbackDepth() { ++callback_depth_; } + void DecrementCallbackDepth() { --callback_depth_; } + bool IsInCallback() const { return callback_depth_ > 0; } + SET_MEMORY_INFO_NAME(DatabaseSync) SET_SELF_SIZE(DatabaseSync) @@ -242,6 +246,7 @@ class DatabaseSync : public BaseObject { bool enable_load_extension_; sqlite3* connection_; bool ignore_next_sqlite_error_; + int callback_depth_ = 0; std::set backups_; std::unordered_set sessions_; @@ -401,6 +406,19 @@ class SQLTagStore : public BaseObject { friend class StatementExecutionHelper; }; +class CallbackDepthGuard { + public: + explicit CallbackDepthGuard(DatabaseSync* db) : db_(db) { + db_->IncrementCallbackDepth(); + } + ~CallbackDepthGuard() { db_->DecrementCallbackDepth(); } + CallbackDepthGuard(const CallbackDepthGuard&) = delete; + CallbackDepthGuard& operator=(const CallbackDepthGuard&) = delete; + + private: + DatabaseSync* db_; +}; + class UserDefinedFunction { public: UserDefinedFunction(Environment* env, diff --git a/test/parallel/test-sqlite-udf-close.js b/test/parallel/test-sqlite-udf-close.js new file mode 100644 index 000000000000..86794029b457 --- /dev/null +++ b/test/parallel/test-sqlite-udf-close.js @@ -0,0 +1,39 @@ +'use strict'; + +const { skipIfSQLiteMissing } = require('../common'); +skipIfSQLiteMissing(); +const assert = require('node:assert'); +const { test } = require('node:test'); +const { DatabaseSync } = require('node:sqlite'); + +for (const method of ['all', 'get', 'run', 'iterate']) { + test(`database.close() from a UDF during statement.${method}()`, () => { + const db = new DatabaseSync(':memory:'); + db.exec(` + CREATE TABLE data (value INTEGER); + INSERT INTO data VALUES (1), (2), (3); + `); + + db.function('close_db', (value) => { + db.close(); + return value; + }); + + const statement = db.prepare('SELECT close_db(value) FROM data'); + assert.throws(() => { + if (method === 'iterate') { + for (const row of statement.iterate()) { + assert.ok(row); + } + } else { + statement[method](); + } + }, { + code: 'ERR_INVALID_STATE', + message: 'database cannot be closed while in a callback', + }); + + assert.strictEqual(db.isOpen, true); + db.close(); + }); +} From 2dd7d4c56fa7b6bd3a31c1c64b78ac6f467f29fe Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 4 Aug 2026 13:52:01 +0200 Subject: [PATCH 002/344] tools: add ./tools/nix/pkcs11.nix to nix-changes.yml Signed-off-by: Filip Skokan PR-URL: https://github.com/nodejs/node/pull/64967 Reviewed-By: Antoine du Hamel Reviewed-By: Colin Ihrig --- .github/workflows/nix-changes.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nix-changes.yml b/.github/workflows/nix-changes.yml index ef05b0bfc8ed..84c4337bab19 100644 --- a/.github/workflows/nix-changes.yml +++ b/.github/workflows/nix-changes.yml @@ -78,7 +78,6 @@ jobs: - name: Compute requisites before change shell: bash # See https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#exit-codes-and-error-action-preference, we want the pipefail option. - # TODO(panva): add `// import ./tools/nix/pkcs11.nix {}` once landed run: | git reset HEAD^ --hard nix-store --query --references "$( @@ -88,6 +87,7 @@ jobs: ++ builtins.attrValues ( { inherit (import {}) nixfmt-tree sccache; } // import ./tools/nix/openssl-matrix.nix {} + // import ./tools/nix/pkcs11.nix {} )")" \ | xargs nix-store --realise \ | xargs nix-store --query --requisites \ From 8f4ee7eda56981c092da3719e1f81a160057ece0 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Wed, 29 Jul 2026 15:02:25 +0200 Subject: [PATCH 003/344] tls: drop hand-rolled TLS client hello parser This existed for 'resumeSession', which needed to do an async lookup though SSL_CTX_sess_set_get_cb is sync-only. Nowadays both OpenSSL & BoringSSL have an early ClientHello callback for suspend/resume to handle this properly, so it was redundant, in addition to being complicated and generally a bit fragile & scary. This PR switches to use the modern OpenSSL/BoringSSL mechanisms for this and drops the client hello parser & related infrastructure completely. In addition, there's a new test here, covering a fixed bug: the hello parser silently dropped fragmented hellos, which we now do handle correctly. Signed-off-by: Tim Perry PR-URL: https://github.com/nodejs/node/pull/64827 Reviewed-By: Matteo Collina Reviewed-By: Filip Skokan --- lib/internal/tls/wrap.js | 9 +- node.gyp | 52 ---- src/crypto/README.md | 41 ++- src/crypto/crypto_clienthello-inl.h | 90 ------- src/crypto/crypto_clienthello.cc | 238 ------------------ src/crypto/crypto_clienthello.h | 131 ---------- src/crypto/crypto_context.cc | 8 + src/crypto/crypto_context.h | 6 + src/crypto/crypto_tls.cc | 177 +++++++------ src/crypto/crypto_tls.h | 26 +- test/cctest/test_crypto_clienthello.cc | 133 ---------- test/fuzzers/fuzz_ClientHelloParser.cc | 16 -- .../test-tls-client-hello-fragmented.js | 75 ++++++ .../test-tls-clienthello-sync-write.js | 53 ++++ 14 files changed, 283 insertions(+), 772 deletions(-) delete mode 100644 src/crypto/crypto_clienthello-inl.h delete mode 100644 src/crypto/crypto_clienthello.cc delete mode 100644 src/crypto/crypto_clienthello.h delete mode 100644 test/cctest/test_crypto_clienthello.cc delete mode 100644 test/fuzzers/fuzz_ClientHelloParser.cc create mode 100644 test/parallel/test-tls-client-hello-fragmented.js create mode 100644 test/parallel/test-tls-clienthello-sync-write.js diff --git a/lib/internal/tls/wrap.js b/lib/internal/tls/wrap.js index c1e7fbcae338..51fea0e99614 100644 --- a/lib/internal/tls/wrap.js +++ b/lib/internal/tls/wrap.js @@ -262,8 +262,8 @@ function loadSession(hello) { return owner.destroy(new ERR_SOCKET_CLOSED()); owner._handle.loadSession(session); - // Session is loaded. End the parser to allow handshaking to continue. - owner._handle.endParser(); + // Session is loaded. Let the handshake continue. + owner._handle.clientHelloDone(); } if (hello.sessionId.length <= 0 || @@ -281,8 +281,8 @@ function loadSession(hello) { // Sessions with tickets can be resumed directly from the ticket, no server // session storage is necessary. // Without a call to a resumeSession listener, a session will never be - // loaded, so end the parser to allow handshaking to continue. - owner._handle.endParser(); + // loaded, so let the handshake continue. + owner._handle.clientHelloDone(); } } @@ -970,7 +970,6 @@ TLSSocket.prototype._init = function(socket, wrap) { if (this.server) { if (this.server.listenerCount('resumeSession') > 0 || this.server.listenerCount('newSession') > 0) { - // Also starts the client hello parser as a side effect. ssl.enableSessionCallbacks(); } if (this.server.listenerCount('OCSPRequest') > 0) diff --git a/node.gyp b/node.gyp index e5d1bd4ef1a6..cf5e53d00d7e 100644 --- a/node.gyp +++ b/node.gyp @@ -406,7 +406,6 @@ 'src/crypto/crypto_rsa.cc', 'src/crypto/crypto_spkac.cc', 'src/crypto/crypto_util.cc', - 'src/crypto/crypto_clienthello.cc', 'src/crypto/crypto_dh.cc', 'src/crypto/crypto_hash.cc', 'src/crypto/crypto_keys.cc', @@ -416,7 +415,6 @@ 'src/crypto/crypto_x509.cc', 'src/crypto/crypto_argon2.h', 'src/crypto/crypto_bio.h', - 'src/crypto/crypto_clienthello-inl.h', 'src/crypto/crypto_dh.h', 'src/crypto/crypto_hmac.h', 'src/crypto/crypto_kmac.h', @@ -432,7 +430,6 @@ 'src/crypto/crypto_keygen.h', 'src/crypto/crypto_scrypt.h', 'src/crypto/crypto_tls.h', - 'src/crypto/crypto_clienthello.h', 'src/crypto/crypto_context.h', 'src/crypto/crypto_ec.h', 'src/crypto/crypto_pqc.h', @@ -462,7 +459,6 @@ 'src/tracing/trace_event_legacy.h', ], 'node_cctest_openssl_sources': [ - 'test/cctest/test_crypto_clienthello.cc', 'test/cctest/test_node_crypto.cc', 'test/cctest/test_node_crypto_env.cc', ], @@ -1297,54 +1293,6 @@ }], ], }, # fuzz_env - { # fuzz_ClientHelloParser.cc - 'target_name': 'fuzz_ClientHelloParser', - 'type': 'executable', - 'dependencies': [ - '<(node_lib_target_name)', - ], - 'includes': [ - 'node.gypi' - ], - 'include_dirs': [ - 'src', - 'tools/msvs/genfiles', - 'deps/v8/include', - 'deps/cares/include', - 'deps/uv/include', - 'test/cctest', - ], - 'defines': [ - 'NODE_ARCH="<(target_arch)"', - 'NODE_PLATFORM="<(OS)"', - 'NODE_WANT_INTERNALS=1', - ], - 'sources': [ - 'test/fuzzers/fuzz_ClientHelloParser.cc', - ], - 'conditions': [ - [ 'node_shared_hdr_histogram=="false"', { - 'dependencies': [ - 'deps/histogram/histogram.gyp:histogram', - ], - }], - [ 'node_shared_uvwasi=="false"', { - 'dependencies': [ 'deps/uvwasi/uvwasi.gyp:uvwasi' ], - 'include_dirs': [ 'deps/uvwasi/include' ], - }], - ['OS=="linux" or OS=="openharmony"', { - 'ldflags': [ '-fsanitize=fuzzer' ] - }], - # Ensure that ossfuzz flag has been set and that we are on Linux - [ 'OS not in "linux openharmony" or ossfuzz!="true"', { - 'type': 'none', - }], - # Avoid excessive LTO - ['enable_lto=="true"', { - 'ldflags': [ '-fno-lto' ], - }], - ], - }, # fuzz_ClientHelloParser.cc { # fuzz_strings 'target_name': 'fuzz_strings', 'type': 'executable', diff --git a/src/crypto/README.md b/src/crypto/README.md index cc5093a385ca..ad06cf989276 100644 --- a/src/crypto/README.md +++ b/src/crypto/README.md @@ -30,27 +30,26 @@ throughout the rest of the code. The rest of the files are structured by their function, as detailed in the following table: -| File (\*.h/\*.cc) | Description | -| -------------------- | -------------------------------------------------------------------------- | -| `crypto_aes` | AES Cipher support. | -| `crypto_argon2` | Argon2 key / bit generation implementation. | -| `crypto_cipher` | General Encryption/Decryption utilities. | -| `crypto_clienthello` | TLS/SSL client hello parser implementation. Used during SSL/TLS handshake. | -| `crypto_context` | Implementation of the `SecureContext` object. | -| `crypto_dh` | Diffie-Hellman Key Agreement implementation. | -| `crypto_dsa` | DSA (Digital Signature) Key Generation functions. | -| `crypto_ec` | Elliptic-curve cryptography implementation. | -| `crypto_hash` | Basic hash (e.g. SHA-256) functions. | -| `crypto_hkdf` | HKDF (Key derivation) implementation. | -| `crypto_hmac` | HMAC implementations. | -| `crypto_keys` | Utilities for using and generating secret, private, and public keys. | -| `crypto_pbkdf2` | PBKDF2 key / bit generation implementation. | -| `crypto_rsa` | RSA Key Generation functions. | -| `crypto_scrypt` | Scrypt key / bit generation implementation. | -| `crypto_sig` | General digital signature and verification utilities. | -| `crypto_spkac` | Netscape SPKAC certificate utilities. | -| `crypto_ssl` | Implementation of the `SSLWrap` object. | -| `crypto_timing` | Implementation of the TimingSafeEqual. | +| File (\*.h/\*.cc) | Description | +| ----------------- | -------------------------------------------------------------------- | +| `crypto_aes` | AES Cipher support. | +| `crypto_argon2` | Argon2 key / bit generation implementation. | +| `crypto_cipher` | General Encryption/Decryption utilities. | +| `crypto_context` | Implementation of the `SecureContext` object. | +| `crypto_dh` | Diffie-Hellman Key Agreement implementation. | +| `crypto_dsa` | DSA (Digital Signature) Key Generation functions. | +| `crypto_ec` | Elliptic-curve cryptography implementation. | +| `crypto_hash` | Basic hash (e.g. SHA-256) functions. | +| `crypto_hkdf` | HKDF (Key derivation) implementation. | +| `crypto_hmac` | HMAC implementations. | +| `crypto_keys` | Utilities for using and generating secret, private, and public keys. | +| `crypto_pbkdf2` | PBKDF2 key / bit generation implementation. | +| `crypto_rsa` | RSA Key Generation functions. | +| `crypto_scrypt` | Scrypt key / bit generation implementation. | +| `crypto_sig` | General digital signature and verification utilities. | +| `crypto_spkac` | Netscape SPKAC certificate utilities. | +| `crypto_ssl` | Implementation of the `SSLWrap` object. | +| `crypto_timing` | Implementation of the TimingSafeEqual. | When new crypto protocols are added, they will be added into their own `crypto_` `*.h` and `*.cc` files. diff --git a/src/crypto/crypto_clienthello-inl.h b/src/crypto/crypto_clienthello-inl.h deleted file mode 100644 index 1b8a0c00c307..000000000000 --- a/src/crypto/crypto_clienthello-inl.h +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright Joyent, Inc. and other Node contributors. -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the -// "Software"), to deal in the Software without restriction, including -// without limitation the rights to use, copy, modify, merge, publish, -// distribute, sublicense, and/or sell copies of the Software, and to permit -// persons to whom the Software is furnished to do so, subject to the -// following conditions: -// -// The above copyright notice and this permission notice shall be included -// in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN -// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, -// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR -// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE -// USE OR OTHER DEALINGS IN THE SOFTWARE. - -#ifndef SRC_CRYPTO_CRYPTO_CLIENTHELLO_INL_H_ -#define SRC_CRYPTO_CRYPTO_CLIENTHELLO_INL_H_ - -#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS - -#include "crypto/crypto_clienthello.h" -#include "util.h" - -namespace node { -namespace crypto { -inline ClientHelloParser::ClientHelloParser() - : state_(kEnded), - onhello_cb_(nullptr), - onend_cb_(nullptr), - cb_arg_(nullptr) { - Reset(); -} - -inline void ClientHelloParser::Reset() { - frame_len_ = 0; - body_offset_ = 0; - extension_offset_ = 0; - session_size_ = 0; - session_id_ = nullptr; - tls_ticket_size_ = -1; - tls_ticket_ = nullptr; - servername_size_ = 0; - servername_ = nullptr; -} - -inline void ClientHelloParser::Start(ClientHelloParser::OnHelloCb onhello_cb, - ClientHelloParser::OnEndCb onend_cb, - void* cb_arg) { - if (!IsEnded()) - return; - Reset(); - - CHECK_NOT_NULL(onhello_cb); - - state_ = kWaiting; - onhello_cb_ = onhello_cb; - onend_cb_ = onend_cb; - cb_arg_ = cb_arg; -} - -inline void ClientHelloParser::End() { - if (state_ == kEnded) - return; - state_ = kEnded; - if (onend_cb_ != nullptr) { - onend_cb_(cb_arg_); - onend_cb_ = nullptr; - } -} - -inline bool ClientHelloParser::IsEnded() const { - return state_ == kEnded; -} - -inline bool ClientHelloParser::IsPaused() const { - return state_ == kPaused; -} - -} // namespace crypto -} // namespace node - -#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS - -#endif // SRC_CRYPTO_CRYPTO_CLIENTHELLO_INL_H_ diff --git a/src/crypto/crypto_clienthello.cc b/src/crypto/crypto_clienthello.cc deleted file mode 100644 index 203289ae2911..000000000000 --- a/src/crypto/crypto_clienthello.cc +++ /dev/null @@ -1,238 +0,0 @@ -// Copyright Joyent, Inc. and other Node contributors. -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the -// "Software"), to deal in the Software without restriction, including -// without limitation the rights to use, copy, modify, merge, publish, -// distribute, sublicense, and/or sell copies of the Software, and to permit -// persons to whom the Software is furnished to do so, subject to the -// following conditions: -// -// The above copyright notice and this permission notice shall be included -// in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN -// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, -// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR -// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE -// USE OR OTHER DEALINGS IN THE SOFTWARE. - -#include "crypto/crypto_clienthello.h" // NOLINT(build/include_inline) -#include "crypto/crypto_clienthello-inl.h" - -namespace node { -namespace crypto { -void ClientHelloParser::Parse(const uint8_t* data, size_t avail) { - switch (state_) { - case kWaiting: - if (!ParseRecordHeader(data, avail)) - break; - [[fallthrough]]; - case kTLSHeader: - ParseHeader(data, avail); - break; - case kPaused: - // Just nop - case kEnded: - // Already ended, just ignore it - break; - default: - break; - } -} - - -bool ClientHelloParser::ParseRecordHeader(const uint8_t* data, size_t avail) { - // >= 5 bytes for header parsing - if (avail < 5) - return false; - - if (data[0] == kChangeCipherSpec || - data[0] == kAlert || - data[0] == kHandshake || - data[0] == kApplicationData) { - frame_len_ = (data[3] << 8) + data[4]; - state_ = kTLSHeader; - body_offset_ = 5; - } else { - End(); - return false; - } - - // Sanity check (too big frame, or too small) - // Let OpenSSL handle it - if (frame_len_ >= kMaxTLSFrameLen) { - End(); - return false; - } - - return true; -} - - -void ClientHelloParser::ParseHeader(const uint8_t* data, size_t avail) { - ClientHello hello; - - // We need at least six bytes (one byte for kClientHello, three bytes for the - // length of the handshake message, and two bytes for the protocol version). - // If the client sent a frame that suggests a smaller ClientHello, give up. - if (frame_len_ < 6) return End(); - - // >= 5 + frame size bytes for frame parsing - if (body_offset_ + frame_len_ > avail) - return; - - // Check hello protocol version. Protocol tuples that we know about: - // - // (3,1) TLS v1.0 - // (3,2) TLS v1.1 - // (3,3) TLS v1.2 - // - // Note that TLS v1.3 uses a TLS v1.2 handshake so requires no specific - // support here. - if (data[body_offset_ + 4] != 0x03 || - data[body_offset_ + 5] < 0x01 || - data[body_offset_ + 5] > 0x03) { - return End(); - } - - if (data[body_offset_] == kClientHello) { - if (state_ == kTLSHeader) { - if (!ParseTLSClientHello(data, avail)) - return End(); - } else { - // We couldn't get here, but whatever - return End(); - } - - // Check if we overflowed (do not reply with any private data) - if (session_id_ == nullptr || - session_size_ > 32 || - session_id_ + session_size_ > data + avail) { - return End(); - } - } - - state_ = kPaused; - hello.session_id_ = session_id_; - hello.session_size_ = session_size_; - hello.has_ticket_ = tls_ticket_ != nullptr && tls_ticket_size_ != 0; - hello.servername_ = servername_; - hello.servername_size_ = static_cast(servername_size_); - onhello_cb_(cb_arg_, hello); -} - - -void ClientHelloParser::ParseExtension(const uint16_t type, - const uint8_t* data, - size_t len) { - // NOTE: In case of anything we're just returning back, ignoring the problem. - // That's because we're heavily relying on OpenSSL to solve any problem with - // incoming data. - switch (type) { - case kServerName: - { - if (len < 2) - return; - uint32_t server_names_len = (data[0] << 8) + data[1]; - if (server_names_len + 2 > len) - return; - for (size_t offset = 2; offset < 2 + server_names_len; ) { - if (offset + 3 > len) - return; - uint8_t name_type = data[offset]; - if (name_type != kServernameHostname) - return; - uint16_t name_len = (data[offset + 1] << 8) + data[offset + 2]; - offset += 3; - if (offset + name_len > len) - return; - servername_ = data + offset; - servername_size_ = name_len; - offset += name_len; - } - } - break; - case kTLSSessionTicket: - tls_ticket_size_ = len; - tls_ticket_ = data + len; - break; - default: - // Ignore - break; - } -} - - -bool ClientHelloParser::ParseTLSClientHello(const uint8_t* data, size_t avail) { - const uint8_t* body; - - // Skip frame header, hello header, protocol version and random data - size_t session_offset = body_offset_ + 4 + 2 + 32; - - if (session_offset + 1 >= avail) - return false; - - body = data + session_offset; - session_size_ = *body; - session_id_ = body + 1; - - size_t cipher_offset = session_offset + 1 + session_size_; - - // Session OOB failure - if (cipher_offset + 1 >= avail) - return false; - - uint16_t cipher_len = - (data[cipher_offset] << 8) + data[cipher_offset + 1]; - size_t comp_offset = cipher_offset + 2 + cipher_len; - - // Cipher OOB failure - if (comp_offset >= avail) - return false; - - uint8_t comp_len = data[comp_offset]; - size_t extension_offset = comp_offset + 1 + comp_len; - - // Compression OOB failure - if (extension_offset > avail) - return false; - - // No extensions present - if (extension_offset == avail) - return true; - - size_t ext_off = extension_offset + 2; - - // Parse known extensions - while (ext_off < avail) { - // Extension OOB - if (ext_off + 4 > avail) - return false; - - uint16_t ext_type = (data[ext_off] << 8) + data[ext_off + 1]; - uint16_t ext_len = (data[ext_off + 2] << 8) + data[ext_off + 3]; - ext_off += 4; - - // Extension OOB - if (ext_off + ext_len > avail) - return false; - - ParseExtension(ext_type, - data + ext_off, - ext_len); - - ext_off += ext_len; - } - - // Extensions OOB failure - if (ext_off > avail) - return false; - - return true; -} - -} // namespace crypto -} // namespace node diff --git a/src/crypto/crypto_clienthello.h b/src/crypto/crypto_clienthello.h deleted file mode 100644 index 3af08bc6475e..000000000000 --- a/src/crypto/crypto_clienthello.h +++ /dev/null @@ -1,131 +0,0 @@ -// Copyright Joyent, Inc. and other Node contributors. -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the -// "Software"), to deal in the Software without restriction, including -// without limitation the rights to use, copy, modify, merge, publish, -// distribute, sublicense, and/or sell copies of the Software, and to permit -// persons to whom the Software is furnished to do so, subject to the -// following conditions: -// -// The above copyright notice and this permission notice shall be included -// in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN -// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, -// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR -// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE -// USE OR OTHER DEALINGS IN THE SOFTWARE. - -#ifndef SRC_CRYPTO_CRYPTO_CLIENTHELLO_H_ -#define SRC_CRYPTO_CRYPTO_CLIENTHELLO_H_ - -#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS - -#include // size_t -#include - -namespace node { -namespace crypto { -// Parse the client hello so we can do async session resumption. OpenSSL's -// session resumption uses synchronous callbacks, see SSL_CTX_sess_set_get_cb -// and get_session_cb. -// -// TLS1.3 handshakes masquerade as TLS1.2 session resumption, and to do this, -// they always include a session_id in the ClientHello, making up a bogus value -// if necessary. The parser can't know if its a bogus id, and will cause a -// 'newSession' event to be emitted. This should do no harm, the id won't be -// found, and the handshake will continue. -class ClientHelloParser { - public: - inline ClientHelloParser(); - - class ClientHello { - public: - inline uint8_t session_size() const { return session_size_; } - inline const uint8_t* session_id() const { return session_id_; } - inline bool has_ticket() const { return has_ticket_; } - inline uint8_t servername_size() const { return servername_size_; } - inline const uint8_t* servername() const { return servername_; } - - private: - uint8_t session_size_; - const uint8_t* session_id_; - bool has_ticket_; - uint8_t servername_size_; - const uint8_t* servername_; - - friend class ClientHelloParser; - }; - - typedef void (*OnHelloCb)(void* arg, const ClientHello& hello); - typedef void (*OnEndCb)(void* arg); - - void Parse(const uint8_t* data, size_t avail); - - inline void Reset(); - inline void Start(OnHelloCb onhello_cb, OnEndCb onend_cb, void* cb_arg); - inline void End(); - inline bool IsPaused() const; - inline bool IsEnded() const; - - private: - static const size_t kMaxTLSFrameLen = 16 * 1024 + 5; - static const size_t kMaxSSLExFrameLen = 32 * 1024; - static const uint8_t kServernameHostname = 0; - static const size_t kMinStatusRequestSize = 5; - - enum ParseState { - kWaiting, - kTLSHeader, - kPaused, - kEnded - }; - - enum FrameType { - kChangeCipherSpec = 20, - kAlert = 21, - kHandshake = 22, - kApplicationData = 23, - kOther = 255 - }; - - enum HandshakeType { - kClientHello = 1 - }; - - enum ExtensionType { - kServerName = 0, - kTLSSessionTicket = 35 - }; - - bool ParseRecordHeader(const uint8_t* data, size_t avail); - void ParseHeader(const uint8_t* data, size_t avail); - void ParseExtension(const uint16_t type, - const uint8_t* data, - size_t len); - bool ParseTLSClientHello(const uint8_t* data, size_t avail); - - ParseState state_; - OnHelloCb onhello_cb_; - OnEndCb onend_cb_; - void* cb_arg_; - size_t frame_len_ = 0; - size_t body_offset_ = 0; - size_t extension_offset_ = 0; - uint8_t session_size_ = 0; - const uint8_t* session_id_ = nullptr; - uint16_t servername_size_ = 0; - const uint8_t* servername_ = nullptr; - uint16_t tls_ticket_size_ = -1; - const uint8_t* tls_ticket_ = nullptr; -}; - -} // namespace crypto -} // namespace node - -#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS - -#endif // SRC_CRYPTO_CRYPTO_CLIENTHELLO_H_ diff --git a/src/crypto/crypto_context.cc b/src/crypto/crypto_context.cc index 5447de596f98..d6387a55bd78 100644 --- a/src/crypto/crypto_context.cc +++ b/src/crypto/crypto_context.cc @@ -1689,6 +1689,14 @@ void SecureContext::SetNewSessionCallback(NewSessionCb cb) { SSL_CTX_sess_set_new_cb(ctx_.get(), cb); } +void SecureContext::SetClientHelloCallback(ClientHelloCb cb) { +#ifdef OPENSSL_IS_BORINGSSL + SSL_CTX_set_select_certificate_cb(ctx_.get(), cb); +#else + SSL_CTX_set_client_hello_cb(ctx_.get(), cb, nullptr); +#endif +} + void SecureContext::SetGetSessionCallback(GetSessionCb cb) { SSL_CTX_sess_set_get_cb(ctx_.get(), cb); } diff --git a/src/crypto/crypto_context.h b/src/crypto/crypto_context.h index 95ddea4c262d..c65f553e40fd 100644 --- a/src/crypto/crypto_context.h +++ b/src/crypto/crypto_context.h @@ -35,6 +35,11 @@ class SecureContext final : public BaseObject { using KeylogCb = void (*)(const SSL*, const char*); using NewSessionCb = int (*)(SSL*, SSL_SESSION*); using SelectSNIContextCb = int (*)(SSL*, int*, void*); +#ifdef OPENSSL_IS_BORINGSSL + using ClientHelloCb = ssl_select_cert_result_t (*)(const SSL_CLIENT_HELLO*); +#else + using ClientHelloCb = int (*)(SSL*, int*, void*); +#endif ~SecureContext() override; @@ -74,6 +79,7 @@ class SecureContext final : public BaseObject { ncrypto::SSLPointer CreateSSL(); + void SetClientHelloCallback(ClientHelloCb cb); void SetGetSessionCallback(GetSessionCb cb); void SetKeylogCallback(KeylogCb cb); void SetNewSessionCallback(NewSessionCb cb); diff --git a/src/crypto/crypto_tls.cc b/src/crypto/crypto_tls.cc index ce44ad15fa79..9981704fb34a 100644 --- a/src/crypto/crypto_tls.cc +++ b/src/crypto/crypto_tls.cc @@ -23,7 +23,6 @@ #include #include "async_wrap-inl.h" #include "crypto/crypto_bio.h" -#include "crypto/crypto_clienthello-inl.h" #include "crypto/crypto_common.h" #include "crypto/crypto_context.h" #include "crypto/crypto_util.h" @@ -103,42 +102,43 @@ SSL_SESSION* GetSessionCallback( return w->ReleaseSession(); } -void OnClientHello( - void* arg, - const ClientHelloParser::ClientHello& hello) { - TLSWrap* w = static_cast(arg); - Environment* env = w->env(); - HandleScope handle_scope(env->isolate()); - Context::Scope context_scope(env->context()); +// The TLS library invokes this before version negotiation and before session +// or ticket resumption, which makes async session lookup possible. If required +// the handshake is suspended here and resumed once JS has answered. +#ifdef OPENSSL_IS_BORINGSSL +ssl_select_cert_result_t EarlyClientHelloCallback(const SSL_CLIENT_HELLO* ch) { + TLSWrap* w = static_cast(SSL_get_app_data(ch->ssl)); + if (!w->should_suspend_for_client_hello()) return ssl_select_cert_success; - Local hello_obj = Object::New(env->isolate()); - Local servername = (hello.servername() == nullptr) - ? String::Empty(env->isolate()) - : OneByteString(env->isolate(), - hello.servername(), - hello.servername_size()); - Local buf = - Buffer::Copy( - env, - reinterpret_cast(hello.session_id()), - hello.session_size()).FromMaybe(Local()); + const uint8_t* ext; + size_t ext_len; + bool has_ticket = SSL_early_callback_ctx_extension_get( + ch, TLSEXT_TYPE_session_ticket, &ext, &ext_len) && + ext_len > 0; - if ((buf.IsEmpty() || - hello_obj->Set(env->context(), env->session_id_string(), buf) - .IsNothing()) || - hello_obj->Set(env->context(), env->servername_string(), servername) - .IsNothing() || - hello_obj - ->Set(env->context(), - env->tls_ticket_string(), - Boolean::New(env->isolate(), hello.has_ticket())) - .IsNothing()) { - return; - } + return w->OnEarlyClientHello(ch->session_id, ch->session_id_len, has_ticket) + ? ssl_select_cert_success + : ssl_select_cert_retry; +} +#else +int EarlyClientHelloCallback(SSL* s, int* al, void* arg) { + TLSWrap* w = static_cast(SSL_get_app_data(s)); + if (!w->should_suspend_for_client_hello()) return SSL_CLIENT_HELLO_SUCCESS; + + const unsigned char* session_id; + size_t session_id_len = SSL_client_hello_get0_session_id(s, &session_id); + + const unsigned char* ext; + size_t ext_len; + bool has_ticket = SSL_client_hello_get0_ext( + s, TLSEXT_TYPE_session_ticket, &ext, &ext_len) == 1 && + ext_len > 0; - Local argv[] = { hello_obj }; - w->MakeCallback(env->onclienthello_string(), arraysize(argv), argv); + return w->OnEarlyClientHello(session_id, session_id_len, has_ticket) + ? SSL_CLIENT_HELLO_SUCCESS + : SSL_CLIENT_HELLO_RETRY; } +#endif void KeylogCallback(const SSL* s, const char* line) { TLSWrap* w = static_cast(SSL_get_app_data(s)); @@ -425,6 +425,7 @@ TLSWrap::TLSWrap(Environment* env, ssl_ = sc_->CreateSSL(); CHECK(ssl_); + sc_->SetClientHelloCallback(EarlyClientHelloCallback); sc_->SetGetSessionCallback(GetSessionCallback); sc_->SetNewSessionCallback(NewSessionCallback); @@ -473,6 +474,61 @@ void TLSWrap::NewSessionDoneCb() { Cycle(); } +// N.b. TLS1.3 ClientHellos carry a fake legacy_session_id (middlebox compat), +// and so emit spurious 'resumeSession'/'newSession' events here. +bool TLSWrap::OnEarlyClientHello(const unsigned char* session_id, + size_t session_id_len, + bool has_ticket) { + if (!hello_emitted_) { + hello_emitted_ = true; + Debug(this, "Scheduling onclienthello"); + + // The hello data is only valid inside the library callback, and JS must + // not run while we are on its stack: a handler that synchronously wrote + // to the socket would re-enter SSL mid-handshake. Copy what we need and + // emit from a fresh stack instead. + std::vector id(session_id, session_id + session_id_len); + BaseObjectPtr strong_ref{this}; + env()->SetImmediate( + [this, strong_ref, id = std::move(id), has_ticket](Environment* env) { + if (ssl_) EmitClientHello(id, has_ticket); + }); + } + return hello_answered_; +} + +void TLSWrap::EmitClientHello(const std::vector& session_id, + bool has_ticket) { + Debug(this, "Emitting onclienthello"); + Environment* env = this->env(); + HandleScope handle_scope(env->isolate()); + Context::Scope context_scope(env->context()); + + Local hello_obj = Object::New(env->isolate()); + Local buf = + Buffer::Copy(env, + reinterpret_cast(session_id.data()), + session_id.size()) + .FromMaybe(Local()); + + if ((buf.IsEmpty() || + hello_obj->Set(env->context(), env->session_id_string(), buf) + .IsNothing()) || + hello_obj + ->Set(env->context(), + env->tls_ticket_string(), + Boolean::New(env->isolate(), has_ticket)) + .IsNothing()) { + // Continue the handshake unresumed rather than leaving it suspended. + hello_answered_ = true; + Cycle(); + return; + } + + Local argv[] = {hello_obj}; + MakeCallback(env->onclienthello_string(), arraysize(argv), argv); +} + void TLSWrap::InitSSL() { // Initialize SSL – OpenSSL takes ownership of these. enc_in_ = NodeBIO::New(env()).release(); @@ -641,12 +697,6 @@ void TLSWrap::SSLInfoCallback(const SSL* ssl_, int where, int ret) { void TLSWrap::EncOut() { Debug(this, "Trying to write encrypted output"); - // Ignore cycling data if ClientHello wasn't yet parsed - if (!hello_parser_.IsEnded()) { - Debug(this, "Returning from EncOut(), hello_parser_ active"); - return; - } - // Write in progress if (write_size_ != 0) { Debug(this, "Returning from EncOut(), write currently in progress"); @@ -785,11 +835,6 @@ void TLSWrap::OnStreamAfterWrite(WriteWrap* req_wrap, int status) { void TLSWrap::ClearOut() { Debug(this, "Trying to read cleartext output"); - // Ignore cycling data if ClientHello wasn't yet parsed - if (!hello_parser_.IsEnded()) { - Debug(this, "Returning from ClearOut(), hello_parser_ active"); - return; - } // No reads after EOF if (eof_) { @@ -912,11 +957,6 @@ void TLSWrap::ClearOut() { void TLSWrap::ClearIn() { Debug(this, "Trying to write cleartext input"); - // Ignore cycling data if ClientHello wasn't yet parsed - if (!hello_parser_.IsEnded()) { - Debug(this, "Returning from ClearIn(), hello_parser_ active"); - return; - } if (ssl_ == nullptr) { Debug(this, "Returning from ClearIn(), ssl_ == nullptr"); @@ -1181,20 +1221,7 @@ void TLSWrap::OnStreamRead(ssize_t nread, const uv_buf_t& buf) { // Commit the amount of data actually read into the peeked/allocated buffer // from the underlying stream. - NodeBIO* enc_in = NodeBIO::FromBIO(enc_in_); - enc_in->Commit(nread); - - // Parse ClientHello first, if we need to. It's only parsed if session event - // listeners are used on the server side. "ended" is the initial state, so - // can mean parsing was never started, or that parsing is finished. Either - // way, ended means we can give the buffered data to SSL. - if (!hello_parser_.IsEnded()) { - size_t avail = 0; - uint8_t* data = reinterpret_cast(enc_in->Peek(&avail)); - CHECK_IMPLIES(data == nullptr, avail == 0); - Debug(this, "Passing %zu bytes to the hello parser", avail); - return hello_parser_.Parse(data, avail); - } + NodeBIO::FromBIO(enc_in_)->Commit(nread); // Cycle OpenSSL's state Cycle(); @@ -1253,15 +1280,6 @@ void TLSWrap::EnableSessionCallbacks(const FunctionCallbackInfo& args) { ASSIGN_OR_RETURN_UNWRAP(&wrap, args.This()); CHECK_NOT_NULL(wrap->ssl_); wrap->enable_session_callbacks(); - - // Clients don't use the HelloParser. - if (wrap->is_client()) - return; - - NodeBIO::FromBIO(wrap->enc_in_)->set_initial(kMaxHelloLength); - wrap->hello_parser_.Start(OnClientHello, - OnClientHelloParseEnd, - wrap); } void TLSWrap::EnableKeylogCallback(const FunctionCallbackInfo& args) { @@ -1336,7 +1354,7 @@ void TLSWrap::Destroy() { void TLSWrap::EnableCertCb(const FunctionCallbackInfo& args) { TLSWrap* wrap; ASSIGN_OR_RETURN_UNWRAP(&wrap, args.This()); - wrap->WaitForCertCb(OnClientHelloParseEnd, wrap); + wrap->WaitForCertCb(ResumeAfterCertCb, wrap); } void TLSWrap::WaitForCertCb(CertCb cb, void* arg) { @@ -1344,9 +1362,9 @@ void TLSWrap::WaitForCertCb(CertCb cb, void* arg) { cert_cb_arg_ = arg; } -void TLSWrap::OnClientHelloParseEnd(void* arg) { +void TLSWrap::ResumeAfterCertCb(void* arg) { TLSWrap* c = static_cast(arg); - Debug(c, "OnClientHelloParseEnd()"); + Debug(c, "ResumeAfterCertCb()"); c->Cycle(); } @@ -2033,10 +2051,11 @@ void TLSWrap::ExportKeyingMaterial(const FunctionCallbackInfo& args) { args.GetReturnValue().Set(buffer); } -void TLSWrap::EndParser(const FunctionCallbackInfo& args) { +void TLSWrap::ClientHelloDone(const FunctionCallbackInfo& args) { TLSWrap* w; ASSIGN_OR_RETURN_UNWRAP(&w, args.This()); - w->hello_parser_.End(); + w->hello_answered_ = true; + w->Cycle(); } void TLSWrap::Renegotiate(const FunctionCallbackInfo& args) { @@ -2214,10 +2233,10 @@ void TLSWrap::Initialize( t->Inherit(AsyncWrap::GetConstructorTemplate(env)); SetProtoMethod(isolate, t, "certCbDone", CertCbDone); + SetProtoMethod(isolate, t, "clientHelloDone", ClientHelloDone); SetProtoMethod(isolate, t, "destroySSL", DestroySSL); SetProtoMethod(isolate, t, "enableCertCb", EnableCertCb); SetProtoMethod(isolate, t, "enableALPNCb", EnableALPNCb); - SetProtoMethod(isolate, t, "endParser", EndParser); SetProtoMethod(isolate, t, "enableKeylogCallback", EnableKeylogCallback); SetProtoMethod(isolate, t, "enableSessionCallbacks", EnableSessionCallbacks); SetProtoMethod(isolate, t, "enableTrace", EnableTrace); @@ -2285,10 +2304,10 @@ void TLSWrap::RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(GetWriteQueueSize); registry->Register(CertCbDone); + registry->Register(ClientHelloDone); registry->Register(DestroySSL); registry->Register(EnableCertCb); registry->Register(EnableALPNCb); - registry->Register(EndParser); registry->Register(EnableKeylogCallback); registry->Register(EnableSessionCallbacks); registry->Register(EnableTrace); diff --git a/src/crypto/crypto_tls.h b/src/crypto/crypto_tls.h index 87063b50bb74..61f773b9c609 100644 --- a/src/crypto/crypto_tls.h +++ b/src/crypto/crypto_tls.h @@ -25,7 +25,6 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS #include "crypto/crypto_context.h" -#include "crypto/crypto_clienthello.h" #include "async_wrap.h" #include "stream_wrap.h" @@ -66,6 +65,11 @@ class TLSWrap : public AsyncWrap, inline bool is_cert_cb_running() const { return cert_cb_running_; } inline bool is_waiting_cert_cb() const { return cert_cb_ != nullptr; } inline bool has_session_callbacks() const { return session_callbacks_; } + // We need to suspend the ClientHello only for server session id + // callbacks, and only on the first pass. + inline bool should_suspend_for_client_hello() const { + return is_server() && session_callbacks_ && !hello_answered_; + } inline void set_cert_cb_running(bool on = true) { cert_cb_running_ = on; } inline void set_awaiting_new_session(bool on = true) { awaiting_new_session_ = on; @@ -105,6 +109,12 @@ class TLSWrap : public AsyncWrap, // Called by the done() callback of the 'newSession' event. void NewSessionDoneCb(); + // Schedules 'onclienthello'; returns false to suspend the handshake until + // clientHelloDone(). The emit itself must not run on the library's stack. + bool OnEarlyClientHello(const unsigned char* session_id, + size_t session_id_len, + bool has_ticket); + // Implement MemoryRetainer: void MemoryInfo(MemoryTracker* tracker) const override; SET_MEMORY_INFO_NAME(TLSWrap) @@ -122,9 +132,6 @@ class TLSWrap : public AsyncWrap, static constexpr int kClearOutChunkSize = 16384; - // Maximum number of bytes for hello parser - static constexpr int kMaxHelloLength = 16384; - // Usual ServerHello + Certificate size static constexpr int kInitialClientBufferLength = 4096; @@ -140,6 +147,8 @@ class TLSWrap : public AsyncWrap, } void WaitForCertCb(CertCb cb, void* arg); + void EmitClientHello(const std::vector& session_id, + bool has_ticket); TLSWrap(Environment* env, v8::Local obj, @@ -180,6 +189,7 @@ class TLSWrap : public AsyncWrap, static int SelectSNIContextCallback(SSL* s, int* ad, void* arg); static void CertCbDone(const v8::FunctionCallbackInfo& args); + static void ClientHelloDone(const v8::FunctionCallbackInfo& args); static void DestroySSL(const v8::FunctionCallbackInfo& args); static void EnableCertCb(const v8::FunctionCallbackInfo& args); static void EnableALPNCb(const v8::FunctionCallbackInfo& args); @@ -188,7 +198,6 @@ class TLSWrap : public AsyncWrap, static void EnableSessionCallbacks( const v8::FunctionCallbackInfo& args); static void EnableTrace(const v8::FunctionCallbackInfo& args); - static void EndParser(const v8::FunctionCallbackInfo& args); static void ExportKeyingMaterial( const v8::FunctionCallbackInfo& args); static void GetALPNNegotiatedProto( @@ -215,7 +224,7 @@ class TLSWrap : public AsyncWrap, static void IsSessionReused(const v8::FunctionCallbackInfo& args); static void LoadSession(const v8::FunctionCallbackInfo& args); static void NewSessionDone(const v8::FunctionCallbackInfo& args); - static void OnClientHelloParseEnd(void* arg); + static void ResumeAfterCertCb(void* arg); static void Receive(const v8::FunctionCallbackInfo& args); static void Renegotiate(const v8::FunctionCallbackInfo& args); static void RequestOCSP(const v8::FunctionCallbackInfo& args); @@ -257,7 +266,6 @@ class TLSWrap : public AsyncWrap, Kind kind_; ncrypto::SSLSessionPointer next_sess_; ncrypto::SSLPointer ssl_; - ClientHelloParser hello_parser_; v8::Global ocsp_response_; BaseObjectPtr sni_context_; BaseObjectPtr sc_; @@ -274,6 +282,10 @@ class TLSWrap : public AsyncWrap, bool session_callbacks_ = false; bool awaiting_new_session_ = false; + // 'onclienthello' has been emitted for this connection. + bool hello_emitted_ = false; + // JS has answered it by calling clientHelloDone(). + bool hello_answered_ = false; bool in_dowrite_ = false; bool started_ = false; bool shutdown_ = false; diff --git a/test/cctest/test_crypto_clienthello.cc b/test/cctest/test_crypto_clienthello.cc deleted file mode 100644 index 870857cf9061..000000000000 --- a/test/cctest/test_crypto_clienthello.cc +++ /dev/null @@ -1,133 +0,0 @@ -#include "crypto/crypto_clienthello-inl.h" -#include "gtest/gtest.h" - -// If the test is being compiled with an address sanitizer enabled, it should -// catch the memory violation, so do not use a guard page. -#ifdef __SANITIZE_ADDRESS__ -#define NO_GUARD_PAGE -#elif defined(__has_feature) -#if __has_feature(address_sanitizer) -#define NO_GUARD_PAGE -#endif -#endif - -// If the test is running without an address sanitizer, see if we can use -// mprotect() or VirtualProtect() to cause a segmentation fault when spatial -// safety is violated. -#if !defined(NO_GUARD_PAGE) -#ifdef __linux__ -#include -#include -#if defined(_SC_PAGE_SIZE) && defined(PROT_NONE) && defined(PROT_READ) && \ - defined(PROT_WRITE) -#define USE_MPROTECT -#endif -#elif defined(_WIN32) && defined(_MSC_VER) -#include -#include -#define USE_VIRTUALPROTECT -#endif -#endif - -#if defined(USE_MPROTECT) -size_t GetPageSize() { - int page_size = sysconf(_SC_PAGE_SIZE); - CHECK_GE(page_size, 1); - return page_size; -} -#elif defined(USE_VIRTUALPROTECT) -size_t GetPageSize() { - SYSTEM_INFO system_info; - GetSystemInfo(&system_info); - return system_info.dwPageSize; -} -#endif - -template -class OverrunGuardedBuffer { - public: - OverrunGuardedBuffer() { -#if defined(USE_MPROTECT) || defined(USE_VIRTUALPROTECT) - size_t page = GetPageSize(); - CHECK_GE(page, N); -#endif -#ifdef USE_MPROTECT - // Place the packet right before a guard page, which, when accessed, causes - // a segmentation fault. - alloc_base = static_cast(aligned_alloc(page, 2 * page)); - CHECK_NOT_NULL(alloc_base); - uint8_t* second_page = alloc_base + page; - CHECK_EQ(mprotect(second_page, page, PROT_NONE), 0); - data_base = second_page - N; -#elif defined(USE_VIRTUALPROTECT) - // On Windows, it works almost the same way. - alloc_base = static_cast( - VirtualAlloc(nullptr, 2 * page, MEM_COMMIT, PAGE_READWRITE)); - CHECK_NOT_NULL(alloc_base); - uint8_t* second_page = alloc_base + page; - DWORD old_prot; - CHECK_NE(VirtualProtect(second_page, page, PAGE_NOACCESS, &old_prot), 0); - CHECK_EQ(old_prot, PAGE_READWRITE); - data_base = second_page - N; -#else - // Place the packet in a regular allocated buffer. The bug causes undefined - // behavior, which might crash the process, and when it does not, address - // sanitizers and valgrind will catch it. - alloc_base = static_cast(malloc(N)); - CHECK_NOT_NULL(alloc_base); - data_base = alloc_base; -#endif - } - - OverrunGuardedBuffer(const OverrunGuardedBuffer& other) = delete; - OverrunGuardedBuffer& operator=(const OverrunGuardedBuffer& other) = delete; - - ~OverrunGuardedBuffer() { -#if defined(USE_MPROTECT) || defined(USE_VIRTUALPROTECT) - size_t page = GetPageSize(); -#endif -#ifdef USE_VIRTUALPROTECT - VirtualFree(alloc_base, 2 * page, MEM_RELEASE); -#else -#ifdef USE_MPROTECT - // Revert page protection such that the memory can be free()'d. - uint8_t* second_page = alloc_base + page; - CHECK_EQ(mprotect(second_page, page, PROT_READ | PROT_WRITE), 0); -#endif - free(alloc_base); -#endif - } - - uint8_t* data() { - return data_base; - } - - private: - uint8_t* alloc_base; - uint8_t* data_base; -}; - -// Test that ClientHelloParser::ParseHeader() does not blindly trust the client -// to send a valid frame length and subsequently does not read out-of-bounds. -TEST(NodeCrypto, ClientHelloParserParseHeaderOutOfBoundsRead) { - using node::crypto::ClientHelloParser; - - // This is the simplest packet triggering the bug. - const uint8_t packet[] = {0x16, 0x03, 0x01, 0x00, 0x00}; - OverrunGuardedBuffer buffer; - memcpy(buffer.data(), packet, sizeof(packet)); - - // Let the ClientHelloParser parse the packet. This should not lead to a - // segmentation fault or to undefined behavior. - node::crypto::ClientHelloParser parser; - bool end_cb_called = false; - parser.Start([](void* arg, auto hello) { GTEST_FAIL(); }, - [](void* arg) { - bool* end_cb_called = static_cast(arg); - EXPECT_FALSE(*end_cb_called); - *end_cb_called = true; - }, - &end_cb_called); - parser.Parse(buffer.data(), sizeof(packet)); - EXPECT_TRUE(end_cb_called); -} diff --git a/test/fuzzers/fuzz_ClientHelloParser.cc b/test/fuzzers/fuzz_ClientHelloParser.cc deleted file mode 100644 index 87d7ae5e303e..000000000000 --- a/test/fuzzers/fuzz_ClientHelloParser.cc +++ /dev/null @@ -1,16 +0,0 @@ -/* - * A fuzzer focused on node::crypto::ClientHelloParser. - */ - -#include -#include "crypto/crypto_clienthello-inl.h" - -extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { - node::crypto::ClientHelloParser parser; - bool end_cb_called = false; - parser.Start([](void* arg, auto hello) { }, - [](void* arg) { }, - &end_cb_called); - parser.Parse(data, size); - return 0; -} diff --git a/test/parallel/test-tls-client-hello-fragmented.js b/test/parallel/test-tls-client-hello-fragmented.js new file mode 100644 index 000000000000..690482e73dc5 --- /dev/null +++ b/test/parallel/test-tls-client-hello-fragmented.js @@ -0,0 +1,75 @@ +'use strict'; + +// A ClientHello split across several TLS records must still emit +// 'resumeSession'. + +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const fixtures = require('../common/fixtures'); +const net = require('net'); +const tls = require('tls'); + +const options = { + key: fixtures.readKey('rsa_private.pem'), + cert: fixtures.readKey('rsa_cert.crt'), +}; + +// Capture a real ClientHello record so the replayed bytes are well-formed. +function captureClientHello(callback) { + let hello = Buffer.alloc(0); + const collector = net.createServer((socket) => { + socket.on('data', (chunk) => { + hello = Buffer.concat([hello, chunk]); + if (hello.length < 5 || hello.length < 5 + hello.readUInt16BE(3)) return; + socket.destroy(); + collector.close(() => callback(hello)); + }); + }); + collector.listen(0, common.mustCall(() => { + tls.connect({ port: collector.address().port, rejectUnauthorized: false }) + .on('error', () => {}); + })); +} + +captureClientHello(common.mustCall((hello) => { + assert.strictEqual(hello[0], 22); + + // Split partway through the fixed-size header, before the session ID. + const body = hello.subarray(5); + const split = 20; + assert.ok(body.length > split); + + const record = (payload) => Buffer.concat([ + Buffer.from([22, hello[1], hello[2], + payload.length >> 8, payload.length & 0xff]), + payload, + ]); + + const server = tls.createServer(options); + server.on('tlsClientError', () => {}); // The replay never completes. + + server.listen(0, common.mustCall(() => { + const client = net.connect(server.address().port, common.mustCall(() => { + client.write(record(body.subarray(0, split))); + setTimeout(() => client.write(record(body.subarray(split))), 10); + })); + client.on('error', () => {}); + + // Fail fast: a missing event stalls the handshake rather than erroring. + const guard = setTimeout(() => { + throw new Error('resumeSession was not emitted'); + }, common.platformTimeout(10000)); + + server.on('resumeSession', common.mustCall((id, callback) => { + clearTimeout(guard); + assert.ok(id.length > 0); + callback(null, null); + client.destroy(); + server.close(); + })); + })); +})); diff --git a/test/parallel/test-tls-clienthello-sync-write.js b/test/parallel/test-tls-clienthello-sync-write.js new file mode 100644 index 000000000000..4b7fcc541f0c --- /dev/null +++ b/test/parallel/test-tls-clienthello-sync-write.js @@ -0,0 +1,53 @@ +'use strict'; + +// Writing to a server TLSSocket synchronously from inside a 'resumeSession' +// handler, while the handshake is still waiting on the ClientHello, must not +// break the connection; the data must be delivered once the handshake ends. + +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { EventEmitter } = require('events'); +const fixtures = require('../common/fixtures'); +const net = require('net'); +const tls = require('tls'); + +const secureContext = tls.createSecureContext({ + key: fixtures.readKey('rsa_private.pem'), + cert: fixtures.readKey('rsa_cert.crt'), +}); + +const fakeServer = new EventEmitter(); +fakeServer.getTicketKeys = () => null; + +let serverSocket; +fakeServer.on('resumeSession', common.mustCall((id, callback) => { + serverSocket.write('from-mid-handshake'); + callback(null, null); +})); + +const server = net.createServer(common.mustCall((raw) => { + serverSocket = new tls.TLSSocket(raw, { + isServer: true, + secureContext, + server: fakeServer, + }); + serverSocket.on('error', common.mustNotCall()); +})); + +server.listen(0, common.mustCall(() => { + const client = tls.connect({ + port: server.address().port, + rejectUnauthorized: false, + }, common.mustCall(() => { + client.on('data', common.mustCall((data) => { + assert.strictEqual(data.toString(), 'from-mid-handshake'); + client.end(); + server.close(); + })); + })); + client.on('error', common.mustNotCall()); +})); From 1864175435a0d49277773b671f51e47f04c33667 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Mon, 3 Aug 2026 15:37:03 +0200 Subject: [PATCH 004/344] tls: don't trigger SNICallback or OCSPRequest from the TLS lib stack Both events (backed by oncertcb) could potentially write to the socket synchronously, re-entering SSL mid-handshake and breaking the connection, so we defer them just like the new 'resumeSession' behaviour. Also fixes a small bug in the error path of EmitClientHello, which now bails out more aggressively instead of resuming handshakes in a V8 teardown scenario. Co-authored-by: Filip Skokan Signed-off-by: Tim Perry PR-URL: https://github.com/nodejs/node/pull/64827 Reviewed-By: Matteo Collina Reviewed-By: Filip Skokan --- src/crypto/crypto_tls.cc | 68 +++++++++++++-------- src/crypto/crypto_tls.h | 4 ++ test/parallel/test-tls-certcb-sync-write.js | 49 +++++++++++++++ 3 files changed, 96 insertions(+), 25 deletions(-) create mode 100644 test/parallel/test-tls-certcb-sync-write.js diff --git a/src/crypto/crypto_tls.cc b/src/crypto/crypto_tls.cc index 9981704fb34a..8ef74aee2d0e 100644 --- a/src/crypto/crypto_tls.cc +++ b/src/crypto/crypto_tls.cc @@ -218,32 +218,18 @@ int SSLCertCallback(SSL* s, void* arg) { // handshake will continue after certcb is done. return -1; - Environment* env = w->env(); - HandleScope handle_scope(env->isolate()); - Context::Scope context_scope(env->context()); w->set_cert_cb_running(); - Local info = Object::New(env->isolate()); + // The view points into SSL-owned memory, so copy it before deferring. + std::string servername; + if (auto name = SSLPointer::GetServerName(s)) servername = *name; - auto servername = SSLPointer::GetServerName(s); - Local servername_str = - !servername.has_value() - ? String::Empty(env->isolate()) - : OneByteString(env->isolate(), servername.value()); - - Local ocsp = Boolean::New( - env->isolate(), SSL_get_tlsext_status_type(s) == TLSEXT_STATUSTYPE_ocsp); + w->ScheduleCertCb(std::move(servername), + SSL_get_tlsext_status_type(s) == TLSEXT_STATUSTYPE_ocsp); - if (info->Set(env->context(), env->servername_string(), servername_str) - .IsNothing() || - info->Set(env->context(), env->ocsp_request_string(), ocsp).IsNothing()) { - return 1; - } - - Local argv[] = { info }; - w->MakeCallback(env->oncertcb_string(), arraysize(argv), argv); - - return w->is_cert_cb_running() ? -1 : 1; + // Suspend handshake with SSL_ERROR_WANT_X509_LOOKUP, and handshake will + // continue after certcb is done. + return -1; } int SelectALPNCallback( @@ -519,9 +505,7 @@ void TLSWrap::EmitClientHello(const std::vector& session_id, env->tls_ticket_string(), Boolean::New(env->isolate(), has_ticket)) .IsNothing()) { - // Continue the handshake unresumed rather than leaving it suspended. - hello_answered_ = true; - Cycle(); + // An exception is pending, so don't re-enter SSL or JS to resume. return; } @@ -529,6 +513,40 @@ void TLSWrap::EmitClientHello(const std::vector& session_id, MakeCallback(env->onclienthello_string(), arraysize(argv), argv); } +// As with the ClientHello, JS must not run on the library's stack: 'oncertcb' +// handlers synchronously call back into the handle to resume the handshake. +void TLSWrap::ScheduleCertCb(std::string servername, bool ocsp) { + Debug(this, "Scheduling oncertcb"); + BaseObjectPtr strong_ref{this}; + env()->SetImmediate( + [this, strong_ref, servername = std::move(servername), ocsp]( + Environment* env) { + if (ssl_) EmitCertCb(servername, ocsp); + }); +} + +void TLSWrap::EmitCertCb(const std::string& servername, bool ocsp) { + Debug(this, "Emitting oncertcb"); + Environment* env = this->env(); + HandleScope handle_scope(env->isolate()); + Context::Scope context_scope(env->context()); + + Local info = Object::New(env->isolate()); + if (info->Set(env->context(), + env->servername_string(), + OneByteString(env->isolate(), servername)) + .IsNothing() || + info->Set(env->context(), + env->ocsp_request_string(), + Boolean::New(env->isolate(), ocsp)) + .IsNothing()) { + return; + } + + Local argv[] = {info}; + MakeCallback(env->oncertcb_string(), arraysize(argv), argv); +} + void TLSWrap::InitSSL() { // Initialize SSL – OpenSSL takes ownership of these. enc_in_ = NodeBIO::New(env()).release(); diff --git a/src/crypto/crypto_tls.h b/src/crypto/crypto_tls.h index 61f773b9c609..a5ded3392915 100644 --- a/src/crypto/crypto_tls.h +++ b/src/crypto/crypto_tls.h @@ -115,6 +115,9 @@ class TLSWrap : public AsyncWrap, size_t session_id_len, bool has_ticket); + // Schedules 'oncertcb'. The handshake stays suspended until certCbDone(). + void ScheduleCertCb(std::string servername, bool ocsp); + // Implement MemoryRetainer: void MemoryInfo(MemoryTracker* tracker) const override; SET_MEMORY_INFO_NAME(TLSWrap) @@ -149,6 +152,7 @@ class TLSWrap : public AsyncWrap, void WaitForCertCb(CertCb cb, void* arg); void EmitClientHello(const std::vector& session_id, bool has_ticket); + void EmitCertCb(const std::string& servername, bool ocsp); TLSWrap(Environment* env, v8::Local obj, diff --git a/test/parallel/test-tls-certcb-sync-write.js b/test/parallel/test-tls-certcb-sync-write.js new file mode 100644 index 000000000000..caf591d669f4 --- /dev/null +++ b/test/parallel/test-tls-certcb-sync-write.js @@ -0,0 +1,49 @@ +'use strict'; + +// Writing to a server TLSSocket synchronously from inside an SNICallback, +// while the handshake is still waiting on the certificate callback, must not +// break the connection; the data must be delivered once the handshake ends. + +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const fixtures = require('../common/fixtures'); +const net = require('net'); +const tls = require('tls'); + +const secureContext = tls.createSecureContext({ + key: fixtures.readKey('rsa_private.pem'), + cert: fixtures.readKey('rsa_cert.crt'), +}); + +let serverSocket; +const server = net.createServer(common.mustCall((raw) => { + serverSocket = new tls.TLSSocket(raw, { + isServer: true, + secureContext, + SNICallback: common.mustCall((servername, callback) => { + assert.strictEqual(servername, 'localhost'); + serverSocket.write('from-mid-handshake'); + callback(null, null); + }), + }); + serverSocket.on('error', common.mustNotCall()); +})); + +server.listen(0, common.mustCall(() => { + const client = tls.connect({ + port: server.address().port, + servername: 'localhost', + rejectUnauthorized: false, + }, common.mustCall(() => { + client.on('data', common.mustCall((data) => { + assert.strictEqual(data.toString(), 'from-mid-handshake'); + client.end(); + server.close(); + })); + })); + client.on('error', common.mustNotCall()); +})); From 044402cf0e2499572c428f76f4ce7679ebeccc28 Mon Sep 17 00:00:00 2001 From: Archkon <180910180+Archkon@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:33:13 +0800 Subject: [PATCH 005/344] src: use UTF-8 for task runner filesystem paths Use ConvertPathToUTF8() instead of path::string() when passing filesystem paths to Node and libuv interfaces. This prevents paths containing characters outside the active Windows code page from being corrupted or rejected. Signed-off-by: Archkon <180910180+Archkon@users.noreply.github.com> PR-URL: https://github.com/nodejs/node/pull/64868 Reviewed-By: Yagiz Nizipli Reviewed-By: Stefan Stojanovic --- src/node_task_runner.cc | 25 ++++++++++---------- test/parallel/test-node-run.js | 42 ++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 12 deletions(-) diff --git a/src/node_task_runner.cc b/src/node_task_runner.cc index 22c02e83e12e..2b3e005abf34 100644 --- a/src/node_task_runner.cc +++ b/src/node_task_runner.cc @@ -123,7 +123,7 @@ void ProcessRunner::SetEnvironmentVariables() { // Add NODE_RUN_PACKAGE_JSON_PATH environment variable to the environment to // indicate which package.json is being processed. env_vars_.push_back("NODE_RUN_PACKAGE_JSON_PATH=" + - package_json_path_.string()); + ConvertPathToUTF8(package_json_path_)); env_ = std::unique_ptr(new char*[env_vars_.size() + 1]); options_.env = env_.get(); @@ -206,7 +206,7 @@ void ProcessRunner::OnExit(int64_t exit_status, int term_signal) { void ProcessRunner::Run() { // keeps the string alive until destructor - cwd_ = package_json_path_.parent_path().string(); + cwd_ = ConvertPathToUTF8(package_json_path_.parent_path()); options_.cwd = cwd_.c_str(); if (int r = uv_spawn(loop_, &process_, &options_)) { fprintf(stderr, "Error: %s\n", uv_strerror(r)); @@ -228,14 +228,14 @@ FindPackageJson(const std::filesystem::path& cwd) { // Append "path/node_modules/.bin" to the env var, if it is a directory. auto node_modules_bin = directory_path / "node_modules" / ".bin"; if (std::filesystem::is_directory(node_modules_bin)) { - path_env_var += node_modules_bin.string() + env_var_separator; + path_env_var += ConvertPathToUTF8(node_modules_bin) + env_var_separator; } if (raw_content.empty()) { package_json_path = directory_path / "package.json"; // This is required for Windows because std::filesystem::path::c_str() // returns wchar_t* on Windows, and char* on other platforms. - std::string contents = package_json_path.string(); + std::string contents = ConvertPathToUTF8(package_json_path); USE(ReadFileSync(&raw_content, contents.c_str()) > 0); } } @@ -258,7 +258,7 @@ void RunTask(const std::shared_ptr& result, if (!package_json.has_value()) { fprintf(stderr, "Can't find package.json for directory %s\n", - cwd.string().c_str()); + ConvertPathToUTF8(cwd).c_str()); result->exit_code_ = ExitCode::kGenericUserError; return; } @@ -274,7 +274,7 @@ void RunTask(const std::shared_ptr& result, simdjson::ondemand::object main_object; if (json_parser.iterate(raw_json).get(document)) { - fprintf(stderr, "Can't parse %s\n", path.string().c_str()); + fprintf(stderr, "Can't parse %s\n", ConvertPathToUTF8(path).c_str()); result->exit_code_ = ExitCode::kGenericUserError; return; } @@ -283,9 +283,9 @@ void RunTask(const std::shared_ptr& result, if (root_error == simdjson::error_code::INCORRECT_TYPE) { fprintf(stderr, "Root value unexpected not an object for %s\n\n", - path.string().c_str()); + ConvertPathToUTF8(path).c_str()); } else { - fprintf(stderr, "Can't parse %s\n", path.string().c_str()); + fprintf(stderr, "Can't parse %s\n", ConvertPathToUTF8(path).c_str()); } result->exit_code_ = ExitCode::kGenericUserError; return; @@ -294,8 +294,9 @@ void RunTask(const std::shared_ptr& result, // If package_json object doesn't have "scripts" field, throw an error. simdjson::ondemand::object scripts_object; if (main_object["scripts"].get_object().get(scripts_object)) { - fprintf( - stderr, "Can't find \"scripts\" field in %s\n", path.string().c_str()); + fprintf(stderr, + "Can't find \"scripts\" field in %s\n", + ConvertPathToUTF8(path).c_str()); result->exit_code_ = ExitCode::kGenericUserError; return; } @@ -309,13 +310,13 @@ void RunTask(const std::shared_ptr& result, "Script \"%.*s\" is unexpectedly not a string for %s\n\n", static_cast(command_id.size()), command_id.data(), - path.string().c_str()); + ConvertPathToUTF8(path).c_str()); } else { fprintf(stderr, "Missing script: \"%.*s\" for %s\n\n", static_cast(command_id.size()), command_id.data(), - path.string().c_str()); + ConvertPathToUTF8(path).c_str()); fprintf(stderr, "Available scripts are:\n"); // Reset the object to iterate over it again diff --git a/test/parallel/test-node-run.js b/test/parallel/test-node-run.js index e24117f6b165..7c1f6609f6f1 100644 --- a/test/parallel/test-node-run.js +++ b/test/parallel/test-node-run.js @@ -5,8 +5,11 @@ common.requireNoPackageJSONAbove(); const { it, describe } = require('node:test'); const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); const fixtures = require('../common/fixtures'); +const tmpdir = require('../common/tmpdir'); const envSuffix = common.isWindows ? '-windows' : ''; describe('node --run [command]', () => { @@ -201,6 +204,45 @@ describe('node --run [command]', () => { assert.strictEqual(child.code, 0); }); + it('handles package paths outside the active Windows code page', + { skip: !common.isWindows }, async () => { + tmpdir.refresh(); + + const projectDir = path.join(tmpdir.path, 'node-run-\u{20BB7}'); + const packageJsonPath = path.join(projectDir, 'package.json'); + const nodeModulesBin = path.join(projectDir, 'node_modules', '.bin'); + const checkScript = path.join(projectDir, 'check.js'); + + fs.mkdirSync(nodeModulesBin, { recursive: true }); + fs.writeFileSync(packageJsonPath, JSON.stringify({ + scripts: { + unicode: `"${process.execPath}" check.js`, + }, + })); + fs.writeFileSync(checkScript, ` + 'use strict'; + console.log(JSON.stringify({ + cwd: process.cwd(), + packageJsonPath: process.env.NODE_RUN_PACKAGE_JSON_PATH, + path: process.env.PATH, + })); + `); + + const child = await common.spawnPromisified( + process.execPath, + [ '--run', 'unicode'], + { cwd: projectDir }, + ); + + assert.strictEqual(child.stderr, ''); + assert.strictEqual(child.code, 0); + + const output = JSON.parse(child.stdout); + assert.strictEqual(output.cwd, projectDir); + assert.strictEqual(output.packageJsonPath, packageJsonPath); + assert.strictEqual(output.path.split(path.delimiter)[0], nodeModulesBin); + }); + it('returns error on unparsable file', async () => { const child = await common.spawnPromisified( process.execPath, From 5cac7c2c5566564c5f821a8b9cb7602a2431c308 Mon Sep 17 00:00:00 2001 From: Yilong Li Date: Tue, 4 Aug 2026 21:21:11 +0800 Subject: [PATCH 006/344] ffi: reuse libffi call plans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Precompute a libffi call plan for each fixed signature on x86-64 System V and reuse it from the generic and SharedBuffer invokers. This avoids repeating argument-placement work for every call. Continue to use ffi_call() with libffi older than 3.7, on other ABIs, and when plan allocation fails. Signed-off-by: umuoy1 PR-URL: https://github.com/nodejs/node/pull/64958 Fixes: https://github.com/nodejs/node/issues/64562 Refs: https://github.com/libffi/libffi/commit/3cc6beb7d404e12d3458461d76557b79795816ff Reviewed-By: Matteo Collina Reviewed-By: Gürgün Dayıoğlu Reviewed-By: Paolo Insogna Reviewed-By: Trivikram Kamat --- benchmark/ffi/invoke-function.js | 79 ++++++++++++++++++++++++++++++++ src/ffi/fast.cc | 24 ++++++++-- src/node_ffi.cc | 37 +++++++++++---- src/node_ffi.h | 29 ++++++++++-- 4 files changed, 152 insertions(+), 17 deletions(-) create mode 100644 benchmark/ffi/invoke-function.js diff --git a/benchmark/ffi/invoke-function.js b/benchmark/ffi/invoke-function.js new file mode 100644 index 000000000000..ae8d5b2ef795 --- /dev/null +++ b/benchmark/ffi/invoke-function.js @@ -0,0 +1,79 @@ +'use strict'; + +const assert = require('node:assert'); +const common = require('../common.js'); +const { libraryPath, ensureFixtureLibrary } = require('./common.js'); + +// Measure the invocation (call) path for signatures that bypass V8 Fast API +// and use libffi through FFIFunction::Invoke(). On x86-64 System V with +// libffi >= 3.7, Invoke() reuses a precomputed call plan that avoids repeating +// argument-placement work on every call. This benchmark quantifies the +// per-call benefit. +// +// Signatures chosen to bypass both V8 Fast API and keep native work minimal: +// - call_int_callback (null): 'function' type forces the generic path; null +// pointer triggers the early return in C so native computation is negligible. +// From libffi's perspective this is a register-only plan (2 pointer-sized +// args both fit in GP registers on x86-64 System V). +// - sum_8_i32: 8 GP args exceed the x86-64 Fast API register cap (6), forcing +// the generic path. From libffi's perspective 6 args go in registers and 2 +// spill to the stack, exercising a stack-spilled plan. + +const bench = common.createBenchmark(main, { + n: [1e7], + symbol: ['call_int_callback', 'sum_8_i32'], +}, { + flags: ['--experimental-ffi', '--no-warnings'], +}); + +ensureFixtureLibrary(); + +function main({ n, symbol }) { + const ffi = require('node:ffi'); + + if (symbol === 'call_int_callback') { + // 'function' type bypasses Fast API (IsFastCallEligible rejects it). + // Pass 0n (null function pointer) so the native function returns -1 + // immediately without invoking any callback, keeping per-call overhead + // dominated by the FFI call machinery itself. + const { lib, functions } = ffi.dlopen(libraryPath, { + call_int_callback: { return: 'i32', arguments: ['function', 'i32'] }, + }); + + try { + // Verify the null-pointer early return. + assert.strictEqual(functions.call_int_callback(0n, 7), -1); + + bench.start(); + for (let i = 0; i < n; ++i) + functions.call_int_callback(0n, 21); + bench.end(n); + } finally { + lib.close(); + } + } else { + // 8 integer args exceed the x86-64 SysV GP register cap (6), which makes + // CreateFastFFIMetadata reject the signature. Calls go through the + // SharedBuffer or generic invoker into FFIFunction::Invoke(). + const { lib, functions } = ffi.dlopen(libraryPath, { + sum_8_i32: { + return: 'i32', + arguments: [ + 'i32', 'i32', 'i32', 'i32', + 'i32', 'i32', 'i32', 'i32', + ], + }, + }); + + const fn = functions.sum_8_i32; + + assert.strictEqual(fn(1, 2, 3, 4, 5, 6, 7, 8), 36); + + bench.start(); + for (let i = 0; i < n; ++i) + fn(1, 2, 3, 4, 5, 6, 7, 14); + bench.end(n); + + lib.close(); + } +} diff --git a/src/ffi/fast.cc b/src/ffi/fast.cc index ca9a5715724b..80ee49e08b28 100644 --- a/src/ffi/fast.cc +++ b/src/ffi/fast.cc @@ -192,13 +192,31 @@ bool SignatureNeedsFastBufferInvoke(const FFIFunction& fn) { IsBufferTypeName(fn.arg_type_names[0])); } +namespace { + +std::shared_ptr CloneForFastMetadata( + const std::shared_ptr& fn) { + // Fast metadata only needs the native target and signature. In particular, + // its temporary clone must not borrow the original function's cif or plan. + auto clone = std::make_shared(); + clone->closed = fn->closed; + clone->ptr = fn->ptr; + clone->args = fn->args; + clone->return_type = fn->return_type; + clone->arg_type_names = fn->arg_type_names; + clone->return_type_name = fn->return_type_name; + return clone; +} + +} // namespace + std::shared_ptr CloneWithRawPointerArgNames( const std::shared_ptr& fn) { // The primary Fast API entrypoint receives pointer-compatible values as // BigInts after the JS wrapper has converted strings, nullish values, and // memory-backed objects. A secondary entrypoint handles the monomorphic // memory-backed case without extracting the pointer in JS. - auto clone = std::make_shared(*fn); + auto clone = CloneForFastMetadata(fn); for (std::string& name : clone->arg_type_names) { if (IsBufferTypeName(name)) { name = "pointer"; @@ -209,10 +227,10 @@ std::shared_ptr CloneWithRawPointerArgNames( std::shared_ptr CloneWithFastBufferArgNames( const std::shared_ptr& fn) { - // Reuse the same native target and libffi metadata, but describe the JS + // Reuse the same native target and signature metadata, but describe the JS // argument as `buffer` so CreateFastFFIMetadata() emits a trampoline that // receives a V8 value and calls node_ffi_fast_buffer_data(). - auto clone = std::make_shared(*fn); + auto clone = CloneForFastMetadata(fn); for (std::string& name : clone->arg_type_names) { if (IsPointerTypeName(name)) { name = "buffer"; diff --git a/src/node_ffi.cc b/src/node_ffi.cc index 6cce5e38e8e3..26a8f5a610cb 100644 --- a/src/node_ffi.cc +++ b/src/node_ffi.cc @@ -42,6 +42,17 @@ using v8::Value; namespace ffi { +void FFIFunction::Invoke(void* result, void** values) { +#if defined(NODE_FFI_HAS_FAST_CALL_PLAN) + if (call_plan != nullptr) { + ffi_call_plan_invoke(call_plan.get(), FFI_FN(ptr), result, values); + return; + } +#endif + + ffi_call(&cif, FFI_FN(ptr), result, values); +} + void FFIFunctionInfo::MemoryInfo(MemoryTracker* tracker) const { tracker->TrackField("sb_backing", sb_backing); } @@ -146,14 +157,12 @@ Maybe DynamicLibrary::PrepareFunction( should_cache_symbol = symbols_.find(name) == symbols_.end(); - fn = std::make_shared( - FFIFunction{.closed = false, - .ptr = ptr, - .cif = {}, - .args = args, - .return_type = return_type, - .arg_type_names = std::move(arg_type_names), - .return_type_name = std::move(return_type_name)}); + fn = std::make_shared(); + fn->ptr = ptr; + fn->args = std::move(args); + fn->return_type = return_type; + fn->arg_type_names = std::move(arg_type_names); + fn->return_type_name = std::move(return_type_name); ffi_status status = ffi_prep_cif(&fn->cif, FFI_DEFAULT_ABI, @@ -178,6 +187,14 @@ Maybe DynamicLibrary::PrepareFunction( return {}; } +#if defined(NODE_FFI_HAS_FAST_CALL_PLAN) + // Allocation failure is non-fatal. Invoke() falls back to ffi_call(). + ffi_call_plan* call_plan = ffi_call_plan_alloc(&fn->cif); + if (call_plan != nullptr) { + fn->call_plan.reset(call_plan); + } +#endif + should_cache_function = true; } else { fn = existing->second; @@ -552,7 +569,7 @@ void DynamicLibrary::InvokeFunction(const FunctionCallbackInfo& args) { result = Malloc(GetFFIReturnValueStorageSize(fn->return_type)); } - ffi_call(&fn->cif, FFI_FN(fn->ptr), result, ffi_args.data()); + fn->Invoke(result, ffi_args.data()); // Return result back to Javascript ToJSReturnValue(env, args, fn->return_type, result); @@ -611,7 +628,7 @@ void DynamicLibrary::InvokeFunctionSB(const FunctionCallbackInfo& args) { alignas(8) uint8_t result_storage[kSBResultStorageSize] = {0}; void* result = (fn->return_type != &ffi_type_void) ? result_storage : nullptr; - ffi_call(&fn->cif, FFI_FN(fn->ptr), result, ffi_args.data()); + fn->Invoke(result, ffi_args.data()); if (result != nullptr) { WriteFFIReturnToBuffer(fn->return_type, result, buffer, 0); diff --git a/src/node_ffi.h b/src/node_ffi.h index a55cb74fc619..07bd0163db75 100644 --- a/src/node_ffi.h +++ b/src/node_ffi.h @@ -14,20 +14,41 @@ #include #include +// libffi only accelerates reusable call plans on x86-64 System V. Other +// targets implement the API by calling ffi_call(), which adds no benefit. +#if defined(FFI_VERSION_NUMBER) && FFI_VERSION_NUMBER >= 30700 && \ + defined(__x86_64__) && !defined(__ILP32__) && !defined(X86_WIN64) && \ + !defined(_WIN32) +#define NODE_FFI_HAS_FAST_CALL_PLAN 1 +#endif + namespace node::ffi { class DynamicLibrary; struct FFIFunction; struct FFIFunction { - bool closed; + FFIFunction() = default; + FFIFunction(const FFIFunction&) = delete; + FFIFunction& operator=(const FFIFunction&) = delete; + FFIFunction(FFIFunction&&) = delete; + FFIFunction& operator=(FFIFunction&&) = delete; - void* ptr; - ffi_cif cif; + bool closed = false; + + void* ptr = nullptr; + ffi_cif cif = {}; std::vector args; - ffi_type* return_type; + ffi_type* return_type = nullptr; std::vector arg_type_names; std::string return_type_name; +#if defined(NODE_FFI_HAS_FAST_CALL_PLAN) + // The plan borrows cif, so it must remain uniquely owned by this instance. + std::unique_ptr call_plan{ + nullptr, ffi_call_plan_free}; +#endif + + void Invoke(void* result, void** values); }; class FFIFunctionInfo final : public BaseObject { From 74a3e91bb72185a8c643017a940a92444dfadef4 Mon Sep 17 00:00:00 2001 From: Paul Bouchon Date: Tue, 4 Aug 2026 11:03:13 -0400 Subject: [PATCH 007/344] test: prefer in-memory databases in sqlite tests Several SQLite tests created temporary file databases through a `nextDb()` helper even though they only exercise SQL behavior and never rely on filesystem persistence. Switch those to `:memory:`, which is faster and drops the temporary-file bookkeeping. Tests that depend on an on-disk or shared database keep using files: the constructor, open() and backup() cases, the timeout and cross-worker suites, and the WAL journal-mode PRAGMA. Refs: https://github.com/nodejs/node/issues/64665 Signed-off-by: Paul Bouchon PR-URL: https://github.com/nodejs/node/pull/64701 Reviewed-By: Matteo Collina Reviewed-By: Edy Silva Reviewed-By: Trivikram Kamat Reviewed-By: Colin Ihrig --- test/parallel/test-sqlite-data-types.js | 19 ++-- test/parallel/test-sqlite-named-parameters.js | 17 +--- test/parallel/test-sqlite-statement-sync.js | 87 +++++++++---------- test/parallel/test-sqlite-transactions.js | 13 +-- .../test-sqlite-typed-array-and-data-view.js | 11 +-- test/parallel/test-sqlite.js | 5 +- 6 files changed, 54 insertions(+), 98 deletions(-) diff --git a/test/parallel/test-sqlite-data-types.js b/test/parallel/test-sqlite-data-types.js index 26af15a777d2..5e51f17733c5 100644 --- a/test/parallel/test-sqlite-data-types.js +++ b/test/parallel/test-sqlite-data-types.js @@ -1,22 +1,13 @@ 'use strict'; const { skipIfSQLiteMissing } = require('../common'); skipIfSQLiteMissing(); -const tmpdir = require('../common/tmpdir'); -const { join } = require('node:path'); const { DatabaseSync } = require('node:sqlite'); const { suite, test } = require('node:test'); -let cnt = 0; - -tmpdir.refresh(); - -function nextDb() { - return join(tmpdir.path, `database-${cnt++}.db`); -} suite('data binding and mapping', () => { test('supported data types', (t) => { const u8a = new TextEncoder().encode('a☃b☃c'); - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec(` CREATE TABLE types( @@ -83,7 +74,7 @@ suite('data binding and mapping', () => { }); test('large strings are bound correctly', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec( 'CREATE TABLE data(key INTEGER PRIMARY KEY, text TEXT) STRICT;' @@ -118,7 +109,7 @@ suite('data binding and mapping', () => { }); test('unsupported data types', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec( 'CREATE TABLE types(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' @@ -153,7 +144,7 @@ suite('data binding and mapping', () => { test('throws when binding a BigInt that is too large', (t) => { const max = 9223372036854775807n; // Largest 64-bit signed integer value. - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec( 'CREATE TABLE types(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' @@ -173,7 +164,7 @@ suite('data binding and mapping', () => { }); test('statements are unbound on each call', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec( 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' diff --git a/test/parallel/test-sqlite-named-parameters.js b/test/parallel/test-sqlite-named-parameters.js index db8f46e6b6ce..2fd6fb0da1c3 100644 --- a/test/parallel/test-sqlite-named-parameters.js +++ b/test/parallel/test-sqlite-named-parameters.js @@ -1,21 +1,12 @@ 'use strict'; const { skipIfSQLiteMissing } = require('../common'); skipIfSQLiteMissing(); -const tmpdir = require('../common/tmpdir'); -const { join } = require('node:path'); const { DatabaseSync } = require('node:sqlite'); const { suite, test } = require('node:test'); -let cnt = 0; - -tmpdir.refresh(); - -function nextDb() { - return join(tmpdir.path, `database-${cnt++}.db`); -} suite('named parameters', () => { test('throws on unknown named parameters', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec( 'CREATE TABLE types(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' @@ -32,7 +23,7 @@ suite('named parameters', () => { }); test('bare named parameters are supported', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec( 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' @@ -47,7 +38,7 @@ suite('named parameters', () => { }); test('duplicate bare named parameters are supported', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec( 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' @@ -62,7 +53,7 @@ suite('named parameters', () => { }); test('bare named parameters throw on ambiguous names', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec( 'CREATE TABLE types(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' diff --git a/test/parallel/test-sqlite-statement-sync.js b/test/parallel/test-sqlite-statement-sync.js index b3a1dc434537..c353c8035c5f 100644 --- a/test/parallel/test-sqlite-statement-sync.js +++ b/test/parallel/test-sqlite-statement-sync.js @@ -2,17 +2,8 @@ 'use strict'; const { skipIfSQLiteMissing } = require('../common'); skipIfSQLiteMissing(); -const tmpdir = require('../common/tmpdir'); -const { join } = require('node:path'); const { DatabaseSync, StatementSync } = require('node:sqlite'); const { suite, test } = require('node:test'); -let cnt = 0; - -tmpdir.refresh(); - -function nextDb() { - return join(tmpdir.path, `database-${cnt++}.db`); -} suite('StatementSync() constructor', () => { test('StatementSync cannot be constructed directly', (t) => { @@ -27,7 +18,7 @@ suite('StatementSync() constructor', () => { suite('StatementSync.prototype.get()', () => { test('executes a query and returns undefined on no results', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); let stmt = db.prepare('CREATE TABLE storage(key TEXT, val TEXT)'); t.assert.strictEqual(stmt.get(), undefined); @@ -36,7 +27,7 @@ suite('StatementSync.prototype.get()', () => { }); test('executes a query and returns the first result', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); let stmt = db.prepare('CREATE TABLE storage(key TEXT, val TEXT)'); t.assert.strictEqual(stmt.get(), undefined); @@ -48,7 +39,7 @@ suite('StatementSync.prototype.get()', () => { }); test('executes a query that returns special columns', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const stmt = db.prepare('SELECT 1 as __proto__, 2 as constructor, 3 as toString'); t.assert.deepStrictEqual(stmt.get(), { __proto__: null, ['__proto__']: 1, constructor: 2, toString: 3 }); @@ -80,14 +71,14 @@ suite('StatementSync.prototype.get()', () => { suite('StatementSync.prototype.all()', () => { test('executes a query and returns an empty array on no results', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const stmt = db.prepare('CREATE TABLE storage(key TEXT, val TEXT)'); t.assert.deepStrictEqual(stmt.all(), []); }); test('executes a query and returns all results', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); let stmt = db.prepare('CREATE TABLE storage(key TEXT, val TEXT)'); t.assert.deepStrictEqual(stmt.run(), { changes: 0, lastInsertRowid: 0 }); @@ -133,7 +124,7 @@ suite('StatementSync.prototype.all()', () => { suite('StatementSync.prototype.iterate()', () => { test('executes a query and returns an empty iterator on no results', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const stmt = db.prepare('CREATE TABLE storage(key TEXT, val TEXT)'); const iter = stmt.iterate(); @@ -143,7 +134,7 @@ suite('StatementSync.prototype.iterate()', () => { }); test('executes a query and returns all results', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); let stmt = db.prepare('CREATE TABLE storage(key TEXT, val TEXT)'); t.assert.deepStrictEqual(stmt.run(), { changes: 0, lastInsertRowid: 0 }); @@ -299,7 +290,7 @@ suite('StatementSync.prototype.iterate()', () => { suite('StatementSync.prototype.run()', () => { test('executes a query and returns change metadata', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec(` CREATE TABLE storage(key TEXT, val TEXT); @@ -311,7 +302,7 @@ suite('StatementSync.prototype.run()', () => { }); test('SQLite throws when trying to bind too many parameters', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec( 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' @@ -329,7 +320,7 @@ suite('StatementSync.prototype.run()', () => { }); test('SQLite defaults to NULL for unbound parameters', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec( 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER NOT NULL) STRICT;' @@ -366,7 +357,7 @@ suite('StatementSync.prototype.run()', () => { }); test('SQLite defaults unbound ?NNN parameters', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec( 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER NOT NULL) STRICT;' @@ -385,7 +376,7 @@ suite('StatementSync.prototype.run()', () => { }); test('binds ?NNN params by position', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec( 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER NOT NULL) STRICT;' @@ -398,7 +389,7 @@ suite('StatementSync.prototype.run()', () => { suite('StatementSync.prototype.sourceSQL', () => { test('equals input SQL', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec( 'CREATE TABLE types(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' @@ -412,7 +403,7 @@ suite('StatementSync.prototype.sourceSQL', () => { suite('StatementSync.prototype.expandedSQL', () => { test('equals expanded SQL', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec( 'CREATE TABLE types(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' @@ -431,7 +422,7 @@ suite('StatementSync.prototype.expandedSQL', () => { suite('StatementSync.prototype.setReadBigInts()', () => { test('BigInts support can be toggled', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec(` CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT; @@ -464,7 +455,7 @@ suite('StatementSync.prototype.setReadBigInts()', () => { }); test('throws when input is not a boolean', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec( 'CREATE TABLE types(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' @@ -480,7 +471,7 @@ suite('StatementSync.prototype.setReadBigInts()', () => { }); test('BigInt is required for reading large integers', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const bad = db.prepare(`SELECT ${Number.MAX_SAFE_INTEGER} + 1`); t.assert.throws(() => { @@ -500,7 +491,7 @@ suite('StatementSync.prototype.setReadBigInts()', () => { suite('StatementSync.prototype.setReturnArrays()', () => { test('throws when input is not a boolean', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec( 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' @@ -518,7 +509,7 @@ suite('StatementSync.prototype.setReturnArrays()', () => { suite('StatementSync.prototype.get() with array output', () => { test('returns array row when setReturnArrays is true', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec(` CREATE TABLE data(key INTEGER PRIMARY KEY, val TEXT) STRICT; @@ -538,7 +529,7 @@ suite('StatementSync.prototype.get() with array output', () => { test('returns array rows with BigInts when both flags are set', (t) => { const expected = [1n, 9007199254740992n]; - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec(` CREATE TABLE big_data(id INTEGER, big_num INTEGER); @@ -557,7 +548,7 @@ suite('StatementSync.prototype.get() with array output', () => { suite('StatementSync.prototype.all() with array output', () => { test('returns array rows when setReturnArrays is true', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec(` CREATE TABLE data(key INTEGER PRIMARY KEY, val TEXT) STRICT; @@ -598,7 +589,7 @@ suite('StatementSync.prototype.all() with array output', () => { 9, 'text3', ]; - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec(` CREATE TABLE wide_table( @@ -623,7 +614,7 @@ suite('StatementSync.prototype.all() with array output', () => { suite('StatementSync.prototype.iterate() with array output', () => { test('iterates array rows when setReturnArrays is true', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec(` CREATE TABLE data(key INTEGER PRIMARY KEY, val TEXT) STRICT; @@ -692,7 +683,7 @@ suite('StatementSync.prototype.iterate() with array output', () => { suite('StatementSync.prototype.setAllowBareNamedParameters()', () => { test('bare named parameter support can be toggled', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec( 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' @@ -718,7 +709,7 @@ suite('StatementSync.prototype.setAllowBareNamedParameters()', () => { }); test('throws when input is not a boolean', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec( 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' @@ -736,7 +727,7 @@ suite('StatementSync.prototype.setAllowBareNamedParameters()', () => { suite('options.readBigInts', () => { test('BigInts are returned when input is true', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec(` CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT; @@ -749,7 +740,7 @@ suite('options.readBigInts', () => { }); test('numbers are returned when input is false', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec(` CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT; @@ -762,7 +753,7 @@ suite('options.readBigInts', () => { }); test('throws when input is not a boolean', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec( 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' @@ -777,7 +768,7 @@ suite('options.readBigInts', () => { }); test('setReadBigInts can override prepare option', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec(` CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT; @@ -794,7 +785,7 @@ suite('options.readBigInts', () => { suite('options.returnArrays', () => { test('arrays are returned when input is true', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec(` CREATE TABLE data(key INTEGER PRIMARY KEY, val TEXT) STRICT; @@ -810,7 +801,7 @@ suite('options.returnArrays', () => { }); test('objects are returned when input is false', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec(` CREATE TABLE data(key INTEGER PRIMARY KEY, val TEXT) STRICT; @@ -826,7 +817,7 @@ suite('options.returnArrays', () => { }); test('throws when input is not a boolean', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec( 'CREATE TABLE data(key INTEGER PRIMARY KEY, val TEXT) STRICT;' @@ -841,7 +832,7 @@ suite('options.returnArrays', () => { }); test('setReturnArrays can override prepare option', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec(` CREATE TABLE data(key INTEGER PRIMARY KEY, val TEXT) STRICT; @@ -859,7 +850,7 @@ suite('options.returnArrays', () => { }); test('all() returns arrays when input is true', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec(` CREATE TABLE data(key INTEGER PRIMARY KEY, val TEXT) STRICT; @@ -879,7 +870,7 @@ suite('options.returnArrays', () => { }); test('iterate() returns arrays when input is true', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec(` CREATE TABLE data(key INTEGER PRIMARY KEY, val TEXT) STRICT; @@ -901,7 +892,7 @@ suite('options.returnArrays', () => { suite('options.allowBareNamedParameters', () => { test('bare named parameters are allowed when input is true', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec( 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' @@ -918,7 +909,7 @@ suite('options.allowBareNamedParameters', () => { }); test('bare named parameters throw when input is false', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec( 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' @@ -937,7 +928,7 @@ suite('options.allowBareNamedParameters', () => { }); test('throws when input is not a boolean', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec( 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' @@ -955,7 +946,7 @@ suite('options.allowBareNamedParameters', () => { }); test('setAllowBareNamedParameters can override prepare option', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec( 'CREATE TABLE data(key INTEGER PRIMARY KEY, val INTEGER) STRICT;' diff --git a/test/parallel/test-sqlite-transactions.js b/test/parallel/test-sqlite-transactions.js index 50b47829aca0..963e7126a101 100644 --- a/test/parallel/test-sqlite-transactions.js +++ b/test/parallel/test-sqlite-transactions.js @@ -1,21 +1,12 @@ 'use strict'; const { skipIfSQLiteMissing } = require('../common'); skipIfSQLiteMissing(); -const tmpdir = require('../common/tmpdir'); -const { join } = require('node:path'); const { DatabaseSync } = require('node:sqlite'); const { suite, test } = require('node:test'); -let cnt = 0; - -tmpdir.refresh(); - -function nextDb() { - return join(tmpdir.path, `database-${cnt++}.db`); -} suite('manual transactions', () => { test('a transaction is committed', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec(` CREATE TABLE data( @@ -42,7 +33,7 @@ suite('manual transactions', () => { }); test('a transaction is rolled back', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec(` CREATE TABLE data( diff --git a/test/parallel/test-sqlite-typed-array-and-data-view.js b/test/parallel/test-sqlite-typed-array-and-data-view.js index 71d7b181a3d7..2d5269be09b7 100644 --- a/test/parallel/test-sqlite-typed-array-and-data-view.js +++ b/test/parallel/test-sqlite-typed-array-and-data-view.js @@ -1,17 +1,8 @@ 'use strict'; const { skipIfSQLiteMissing } = require('../common'); skipIfSQLiteMissing(); -const tmpdir = require('../common/tmpdir'); -const { join } = require('node:path'); const { DatabaseSync } = require('node:sqlite'); const { suite, test } = require('node:test'); -let cnt = 0; - -tmpdir.refresh(); - -function nextDb() { - return join(tmpdir.path, `database-${cnt++}.db`); -} const arrayBuffer = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]).buffer; const TypedArrays = [ @@ -32,7 +23,7 @@ const TypedArrays = [ suite('StatementSync with TypedArray/DataView', () => { for (const [displayName, TypedArray] of TypedArrays) { test(displayName, (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); db.exec('CREATE TABLE test (data BLOB)'); // insert diff --git a/test/parallel/test-sqlite.js b/test/parallel/test-sqlite.js index ebbcd27d1345..b7b65f1258f0 100644 --- a/test/parallel/test-sqlite.js +++ b/test/parallel/test-sqlite.js @@ -36,7 +36,7 @@ suite('accessing the node:sqlite module', () => { }); test('ERR_SQLITE_ERROR is thrown for errors originating from SQLite', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); const setup = db.exec(` CREATE TABLE test( @@ -86,6 +86,7 @@ test('sqlite constants are defined', (t) => { }); test('PRAGMAs are supported', (t) => { + // WAL journal mode requires an on-disk database. const db = new DatabaseSync(nextDb()); t.after(() => { db.close(); }); t.assert.deepStrictEqual( @@ -218,7 +219,7 @@ suite('SQL APIs enabled at build time', () => { }); test('dbstat is enabled', (t) => { - const db = new DatabaseSync(nextDb()); + const db = new DatabaseSync(':memory:'); t.after(() => { db.close(); }); db.exec(` CREATE TABLE t1 (key INTEGER PRIMARY KEY); From 312f0c6a7e421a40e6168e7451d078d686c0d274 Mon Sep 17 00:00:00 2001 From: Ryuhei Shima <65934663+islandryu@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:03:32 +0900 Subject: [PATCH 008/344] build: add host toolset to perfetto_sdk Signed-off-by: islandryu PR-URL: https://github.com/nodejs/node/pull/64751 Reviewed-By: Aviv Keller --- deps/perfetto/perfetto.gyp | 1 + 1 file changed, 1 insertion(+) diff --git a/deps/perfetto/perfetto.gyp b/deps/perfetto/perfetto.gyp index 3836f3424cbd..083d0b386dd2 100644 --- a/deps/perfetto/perfetto.gyp +++ b/deps/perfetto/perfetto.gyp @@ -9,6 +9,7 @@ { 'target_name': 'perfetto_sdk', 'type': 'static_library', + 'toolsets': ['host', 'target'], 'include_dirs': [ 'sdk' ], 'direct_dependent_settings': { # Use like `#include "perfetto.h"` From b98294b57d28e3c205bbd41ff4592e39a8ae6e44 Mon Sep 17 00:00:00 2001 From: Junsoo Ha <35479251+ganjanggejang@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:58:10 +0900 Subject: [PATCH 009/344] ffi: support SharedArrayBuffer in getRawPointer Signed-off-by: Junsoo Ha PR-URL: https://github.com/nodejs/node/pull/64864 Reviewed-By: Paolo Insogna Reviewed-By: Trivikram Kamat --- doc/api/ffi.md | 2 +- src/ffi/data.cc | 10 ++++++---- test/ffi/test-ffi-memory.js | 7 +++++++ 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/doc/api/ffi.md b/doc/api/ffi.md index 9df2a9789d98..09b78d64fc68 100644 --- a/doc/api/ffi.md +++ b/doc/api/ffi.md @@ -714,7 +714,7 @@ available storage. This function does not allocate memory on its own. added: v26.1.0 --> -* `source` {Buffer|ArrayBuffer|ArrayBufferView} +* `source` {Buffer|ArrayBuffer|SharedArrayBuffer|ArrayBufferView} * Returns: {bigint} Returns the raw memory address of JavaScript-managed byte storage. diff --git a/src/ffi/data.cc b/src/ffi/data.cc index 726edc5cd2dc..73b575395c8c 100644 --- a/src/ffi/data.cc +++ b/src/ffi/data.cc @@ -739,7 +739,8 @@ void GetRawPointer(const FunctionCallbackInfo& args) { if (args.Length() < 1) { THROW_ERR_INVALID_ARG_TYPE( env, - "The first argument must be a Buffer, ArrayBuffer, or ArrayBufferView"); + "The first argument must be a Buffer, ArrayBuffer, SharedArrayBuffer, " + "or ArrayBufferView"); return; } @@ -758,9 +759,10 @@ void GetRawPointer(const FunctionCallbackInfo& args) { store = args[0].As()->Buffer()->GetBackingStore(); offset = args[0].As()->ByteOffset(); } else { - THROW_ERR_INVALID_ARG_TYPE(env, - "The first argument must be a Buffer, " - "ArrayBuffer, or ArrayBufferView"); + THROW_ERR_INVALID_ARG_TYPE( + env, + "The first argument must be a Buffer, " + "ArrayBuffer, SharedArrayBuffer, or ArrayBufferView"); return; } diff --git a/test/ffi/test-ffi-memory.js b/test/ffi/test-ffi-memory.js index adc5a8539491..f17f56c410f8 100644 --- a/test/ffi/test-ffi-memory.js +++ b/test/ffi/test-ffi-memory.js @@ -125,18 +125,25 @@ test('ffi getRawPointer returns raw addresses for byte sources', () => { const buffer = Buffer.from([1, 2, 3]); const arrayBuffer = new Uint8Array([4, 5, 6, 7]).buffer; const view = new Uint8Array(arrayBuffer, 2); + const sharedArrayBuffer = new SharedArrayBuffer(4); + const sharedView = new Uint8Array(sharedArrayBuffer, 2); const bufferPointer = ffi.getRawPointer(buffer); const arrayBufferPointer = ffi.getRawPointer(arrayBuffer); const viewPointer = ffi.getRawPointer(view); + const sharedArrayBufferPointer = ffi.getRawPointer(sharedArrayBuffer); + const sharedViewPointer = ffi.getRawPointer(sharedView); assert.strictEqual(typeof bufferPointer, 'bigint'); assert.strictEqual(typeof arrayBufferPointer, 'bigint'); assert.strictEqual(typeof viewPointer, 'bigint'); + assert.strictEqual(typeof sharedArrayBufferPointer, 'bigint'); + assert.strictEqual(typeof sharedViewPointer, 'bigint'); assert.strictEqual(bufferPointer, symbols.pointer_to_usize(buffer)); assert.strictEqual(arrayBufferPointer, symbols.pointer_to_usize(arrayBuffer)); assert.strictEqual(viewPointer, arrayBufferPointer + 2n); + assert.strictEqual(sharedViewPointer, sharedArrayBufferPointer + 2n); }); test('ffi exportString and exportBuffer copy data into native memory', () => { From be313012e4918b395c2435a8370750744cf55bb2 Mon Sep 17 00:00:00 2001 From: agape1225 <49804691+agape1225@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:58:27 +0900 Subject: [PATCH 010/344] doc: use ffi.suffix in permission example The `--allow-ffi` example in cli.md hard-coded the Linux-only `.so` extension. node:ffi already exposes `suffix` for exactly this case, and doc/api/ffi.md's own examples use it. Do the same here. Assisted-by: Claude Sonnet 5 Signed-off-by: agape1225 <49804691+agape1225@users.noreply.github.com> PR-URL: https://github.com/nodejs/node/pull/64914 Reviewed-By: James M Snell Reviewed-By: Trivikram Kamat --- doc/api/cli.md | 4 ++-- doc/node.1 | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/api/cli.md b/doc/api/cli.md index 58b9ab0b36b7..1a497808d935 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -208,8 +208,8 @@ starting Node.js. The [`node:ffi`][] module also requires the Example: ```js -const { DynamicLibrary } = require('node:ffi'); -const lib = new DynamicLibrary('mylib.so'); +const { DynamicLibrary, suffix } = require('node:ffi'); +const lib = new DynamicLibrary(`./mylib.${suffix}`); ``` ```console diff --git a/doc/node.1 b/doc/node.1 index e1fb3fe74586..b7e4d0a429a2 100644 --- a/doc/node.1 +++ b/doc/node.1 @@ -126,8 +126,8 @@ starting Node.js. The \fBnode:ffi\fR module also requires the \fB--experimental-ffi\fR flag and is only available in builds with FFI support. Example: .Bd -literal -const { DynamicLibrary } = require('node:ffi'); -const lib = new DynamicLibrary('mylib.so'); +const { DynamicLibrary, suffix } = require('node:ffi'); +const lib = new DynamicLibrary(`./mylib.${suffix}`); .Ed .Bd -literal $ node --permission --experimental-ffi index.js From 4c8d8afa999549df59b25e59374110cd51ee72b7 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Tue, 4 Aug 2026 17:58:42 +0200 Subject: [PATCH 011/344] tools: remove `true` from branch name for auto-update automation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Antoine du Hamel PR-URL: https://github.com/nodejs/node/pull/64961 Reviewed-By: Filip Skokan Reviewed-By: Moshe Atlow Reviewed-By: Marco Ippolito Reviewed-By: James M Snell Reviewed-By: Aviv Keller Reviewed-By: Tierney Cyren Reviewed-By: René --- .github/workflows/tools.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tools.yml b/.github/workflows/tools.yml index 0008b0478f55..4e44737c116b 100644 --- a/.github/workflows/tools.yml +++ b/.github/workflows/tools.yml @@ -336,7 +336,7 @@ jobs: # no-op if the base branch is already up-to-date. with: token: ${{ secrets.GH_USER_TOKEN }} - branch: actions/${{ github.ref_name == 'main' || format('{0}/', github.ref_name) }}tools-update-${{ matrix.id }} # Custom branch *just* for this Action. + branch: actions/${{ github.ref_name != 'main' && format('{0}/', github.ref_name) || '' }}tools-update-${{ matrix.id }} # Custom branch *just* for this Action. delete-branch: true commit-message: ${{ env.COMMIT_MSG }} labels: ${{ matrix.label }} From f0140d7ebd2e98b3bb7b8b63c90ddb024d4254bf Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Tue, 4 Aug 2026 18:34:32 +0200 Subject: [PATCH 012/344] tools: store "default" OpenSSL version in `openssl-matrix.nix` To help with automating keeping in sync with the bundled version. Signed-off-by: Antoine du Hamel PR-URL: https://github.com/nodejs/node/pull/64962 Reviewed-By: Filip Skokan Reviewed-By: Aviv Keller --- tools/dep_updaters/update-nixpkgs-pin.sh | 25 ++++++++++++++++++++++-- tools/nix/openssl-matrix.nix | 5 ++++- tools/nix/pkcs11.nix | 2 +- tools/nix/sharedLibDeps.nix | 2 +- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/tools/dep_updaters/update-nixpkgs-pin.sh b/tools/dep_updaters/update-nixpkgs-pin.sh index eb5fde1526ab..3463d9fc2137 100755 --- a/tools/dep_updaters/update-nixpkgs-pin.sh +++ b/tools/dep_updaters/update-nixpkgs-pin.sh @@ -26,6 +26,14 @@ TMP_FILE=$(mktemp) sed "s/$CURRENT_VERSION_SHA1/$NEW_UPSTREAM_SHA1/;s/$CURRENT_TARBALL_HASH/$NEW_TARBALL_HASH/" "$NIXPKGS_PIN_FILE" > "$TMP_FILE" mv "$TMP_FILE" "$NIXPKGS_PIN_FILE" +# === Update openssl-matrix.nix === +# When bumping the pin, we want to update the openssl-matrix.nix file to keep the list in sync nixpkgs +# i.e. add newly added release lines, remove newly dropped release lines), and make sure the "openssl" +# attribute still refers to the same release line as the bundled version in deps/openssl/. + +OPENSSL_MAJOR=$(awk -F= '/^MAJOR=[0-9]+$/ { print $2; exit }' "$BASE_DIR/deps/openssl/openssl/VERSION.dat") +OPENSSL_MINOR=$(awk -F= '/^MINOR=[0-9]+$/ { print $2; exit }' "$BASE_DIR/deps/openssl/openssl/VERSION.dat") + nix-instantiate -I "nixpkgs=$NIXPKGS_PIN_FILE" --eval --strict --json -E " let pkgs = import {}; @@ -33,15 +41,24 @@ nix-instantiate -I "nixpkgs=$NIXPKGS_PIN_FILE" --eval --strict --json -E " (n: builtins.match \"openssl_[0-9]+(_[0-9]+)?\" n != null) (builtins.attrNames pkgs); extraMatrixAttrs = [ \"boringssl\" ]; + default = builtins.head (builtins.filter (n: + let + inherit (pkgs.lib) versions; + t = builtins.tryEval pkgs.\${n}; + v = if t.success then builtins.tryEval t.value.version else t; + majorVersion = pkgs.lib.optionalString v.success (versions.major v.value); + minorVersion = pkgs.lib.optionalString v.success (versions.minor v.value); + in + majorVersion == ''$OPENSSL_MAJOR'' && minorVersion == ''$OPENSSL_MINOR'') opensslAttrs); attrs = builtins.filter (n: let t = builtins.tryEval pkgs.\${n}; in - t.success && (builtins.tryEval t.value.version).success + n != default && t.success && (builtins.tryEval t.value.version).success ) (opensslAttrs ++ extraMatrixAttrs); in { - inherit attrs; + inherit attrs default; permittedInsecurePackages = builtins.map (attr: pkgs.\${attr}.name) ( builtins.filter (attr: (pkgs.\${attr}.meta.insecure)) attrs ); @@ -53,6 +70,10 @@ nix-instantiate -I "nixpkgs=$NIXPKGS_PIN_FILE" --eval --strict --json -E " }: { + # "default" OpenSSL release line, should be kept in sync with the bundled version: + openssl = pkgs.\(.default); + + # Other OpenSSL variants we want to test for: inherit (pkgs) \(.attrs | sort | join("\n ")) ; diff --git a/tools/nix/openssl-matrix.nix b/tools/nix/openssl-matrix.nix index 36978c5d4efc..8c62cae81196 100644 --- a/tools/nix/openssl-matrix.nix +++ b/tools/nix/openssl-matrix.nix @@ -5,11 +5,14 @@ }: { + # "default" OpenSSL release line, should be kept in sync with the bundled version: + openssl = pkgs.openssl_3_5; + + # Other OpenSSL variants we want to test for: inherit (pkgs) boringssl openssl_1_1 openssl_3 - openssl_3_5 openssl_3_6 openssl_4_0 ; diff --git a/tools/nix/pkcs11.nix b/tools/nix/pkcs11.nix index eb6165b7bd99..748d3d4de0ed 100644 --- a/tools/nix/pkcs11.nix +++ b/tools/nix/pkcs11.nix @@ -10,7 +10,7 @@ # pkcs11-provider is dlopen'd into the libcrypto Node.js itself links, so it # has to be built against that very OpenSSL. SoftHSM links OpenSSL too; # building it against the same one keeps a single libcrypto in the process. - openssl ? (import ./sharedLibDeps.nix { inherit pkgs; }).openssl, + openssl ? (import ./openssl-matrix.nix { inherit pkgs; }).openssl, pin ? "1234", }: diff --git a/tools/nix/sharedLibDeps.nix b/tools/nix/sharedLibDeps.nix index 11ad545587f8..788e9efedeb8 100644 --- a/tools/nix/sharedLibDeps.nix +++ b/tools/nix/sharedLibDeps.nix @@ -48,7 +48,7 @@ ffi = pkgs.libffiReal; }) // (pkgs.lib.optionalAttrs withSSL ({ - openssl = (import ./openssl-matrix.nix { inherit pkgs; }).openssl_3_5; + inherit (import ./openssl-matrix.nix { inherit pkgs; }) openssl; })) // (pkgs.lib.optionalAttrs withTemporal { inherit (pkgs) temporal_capi; From 9c282c4532d5df2750ffe4e30cceb97e6709d0b5 Mon Sep 17 00:00:00 2001 From: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:15:37 -0700 Subject: [PATCH 013/344] ffi: accept pointer BigInts in multi-argument fast calls convertPointerArg ended with an unconditional getRawPointer call for buffer and arraybuffer types, rejecting BigInt addresses that the single-argument fast path and ToFFIArgument both accept. Drop the fallback; hasPointerMemoryArg already converts memory-backed values, and null, undefined, and strings are handled earlier. Signed-off-by: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Assisted-by: claude:opus-5 PR-URL: https://github.com/nodejs/node/pull/64964 Fixes: https://github.com/nodejs/node/issues/64963 Reviewed-By: James M Snell Reviewed-By: Matteo Collina --- lib/internal/ffi/fast-api.js | 5 ++--- test/ffi/test-ffi-fast-buffer.js | 33 ++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/lib/internal/ffi/fast-api.js b/lib/internal/ffi/fast-api.js index 44e4c3a04e0f..486a119a2e07 100644 --- a/lib/internal/ffi/fast-api.js +++ b/lib/internal/ffi/fast-api.js @@ -169,9 +169,8 @@ function convertPointerArg(type, value, stringState, index) { if (hasPointerMemoryArg(type, value)) { return getRawPointer(value); } - if (needsRawPointerConversion(type)) { - return getRawPointer(value); - } + // Pointer-like values (e.g. BigInt addresses) are passed through, matching + // ToFFIArgument in src/ffi/types.cc and the single-argument fast path. return value; } diff --git a/test/ffi/test-ffi-fast-buffer.js b/test/ffi/test-ffi-fast-buffer.js index 97d8a3c9b852..e4399ee8ee47 100644 --- a/test/ffi/test-ffi-fast-buffer.js +++ b/test/ffi/test-ffi-fast-buffer.js @@ -133,3 +133,36 @@ test('optimized buffer signatures preserve pointer-like conversions', () => { lib.close(); } }); + +test('multi-argument buffer signatures accept pointer BigInts', () => { + const { lib, functions } = ffi.dlopen(libraryPath, { + sum_buffer: { arguments: ['buffer', 'u64'], return: 'u64' }, + fill_buffer: { arguments: ['arraybuffer', 'u64', 'u32'], return: 'void' }, + }); + + try { + const bytes = Buffer.from([1, 2, 3, 4]); + const pointer = ffi.getRawPointer(bytes); + const length = BigInt(bytes.length); + + // The two-argument wrapper must treat a raw address like the buffer it + // came from, matching both the single-argument fast path and the slow + // paths in src/ffi/types.cc. + assert.strictEqual(functions.sum_buffer(pointer, length), 10n); + assert.strictEqual(functions.sum_buffer(bytes, length), 10n); + assert.strictEqual(functions.sum_buffer(0n, length), 0n); + assert.strictEqual(functions.sum_buffer(null, length), 0n); + + // The three-argument wrapper must forward the address to real memory + // instead of rejecting it. + functions.fill_buffer(pointer, length, 7); + assert.deepStrictEqual(bytes, Buffer.from([7, 7, 7, 7])); + + // Still accepted once the call has been optimized. + for (let i = 0; i < 100_000; i++) { + assert.strictEqual(functions.sum_buffer(pointer, length), 28n); + } + } finally { + lib.close(); + } +}); From e73f74738d192f0c7bf1dd86afd9fa7bd8e65814 Mon Sep 17 00:00:00 2001 From: Jihwan Date: Wed, 5 Aug 2026 03:32:44 +0900 Subject: [PATCH 014/344] test_runner: fix env option validation Signed-off-by: hanityx PR-URL: https://github.com/nodejs/node/pull/64865 Reviewed-By: Aviv Keller Reviewed-By: Moshe Atlow Reviewed-By: James M Snell Reviewed-By: Chemi Atlow --- lib/internal/test_runner/runner.js | 2 +- test/parallel/test-runner-run.mjs | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/internal/test_runner/runner.js b/lib/internal/test_runner/runner.js index 4bfce346a592..a5a53e44d29a 100644 --- a/lib/internal/test_runner/runner.js +++ b/lib/internal/test_runner/runner.js @@ -910,7 +910,7 @@ function run(options = kEmptyObject) { } if (env != null) { - validateObject(env); + validateObject(env, 'options.env'); if (isolation === 'none') { throw new ERR_INVALID_ARG_VALUE('options.env', env, 'is not supported with isolation=\'none\''); diff --git a/test/parallel/test-runner-run.mjs b/test/parallel/test-runner-run.mjs index b6eb6b6af518..c6b888432cf8 100644 --- a/test/parallel/test-runner-run.mjs +++ b/test/parallel/test-runner-run.mjs @@ -673,6 +673,14 @@ describe('require(\'node:test\').run', { concurrency: true }, () => { })); }); + it('should only allow object in options.env', () => { + [Symbol(), [], () => {}, 0, 1, 0n, 1n, '', '1', true, false] + .forEach((env) => assert.throws(() => run({ files: [], env }), { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.env" property must be of type object\./ + })); + }); + it('should not allow files and globPatterns used together', () => { assert.throws(() => run({ files: ['a.js'], globPatterns: ['*.js'] }), { code: 'ERR_INVALID_ARG_VALUE' From 6deeef1801b55c80b87aaf0199a774529fbdbea7 Mon Sep 17 00:00:00 2001 From: Chengzhong Wu Date: Tue, 4 Aug 2026 15:10:36 -0400 Subject: [PATCH 015/344] build: enable perfetto updater Signed-off-by: Chengzhong Wu PR-URL: https://github.com/nodejs/node/pull/64966 Reviewed-By: James M Snell Reviewed-By: Aviv Keller Reviewed-By: Marco Ippolito --- .github/workflows/tools.yml | 9 +++++++++ tools/dep_updaters/update-perfetto.sh | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tools.yml b/.github/workflows/tools.yml index 4e44737c116b..e80b65315a4a 100644 --- a/.github/workflows/tools.yml +++ b/.github/workflows/tools.yml @@ -35,6 +35,7 @@ on: - nghttp2 - nghttp3 - ngtcp2 + - perfetto - postject - root-certificates - simdjson @@ -237,6 +238,14 @@ jobs: cat temp-output tail -n1 temp-output | grep "NEW_VERSION=" >> "$GITHUB_ENV" || true rm temp-output + - id: perfetto + subsystem: deps + label: dependencies + run: | + ./tools/dep_updaters/update-perfetto.sh > temp-output + cat temp-output + tail -n1 temp-output | grep "NEW_VERSION=" >> "$GITHUB_ENV" || true + rm temp-output - id: postject subsystem: deps,test label: test diff --git a/tools/dep_updaters/update-perfetto.sh b/tools/dep_updaters/update-perfetto.sh index 516d7d6aa63b..493a8f8c57d1 100755 --- a/tools/dep_updaters/update-perfetto.sh +++ b/tools/dep_updaters/update-perfetto.sh @@ -57,7 +57,7 @@ echo "$NEW_VERSION" > perfetto/VERSION curl -sL -o "perfetto/LICENSE" "https://raw.githubusercontent.com/google/perfetto/refs/tags/$PERFETTO_REF/LICENSE" # Remove C API headers. Only keep C++ API headers. -rm perfetto/sdk/perfetto_c.h perfetto/sdk/perfetto_c.cc +rm -f perfetto/sdk/perfetto_c.h perfetto/sdk/perfetto_c.cc echo "Copying existing gyp files" cp "$DEPS_DIR/perfetto/perfetto.gyp" "$WORKSPACE/perfetto" From bc6b630e21c1b174d492d0722bd22b50ad735e9a Mon Sep 17 00:00:00 2001 From: Archkon <180910180+Archkon@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:17:00 +0800 Subject: [PATCH 016/344] buffer: treat detached ArrayBuffers as empty Treat detached ArrayBuffers and Buffer or TypedArray views backed by them as zero-length inputs in buffer.isUtf8() and buffer.isAscii(). Both functions now return true for these inputs, consistent with other empty inputs. Signed-off-by: Archkon <180910180+Archkon@users.noreply.github.com> PR-URL: https://github.com/nodejs/node/pull/64504 Fixes: https://github.com/nodejs/node/issues/64503 Reviewed-By: James M Snell --- doc/api/buffer.md | 14 ++++++- src/node_buffer.cc | 56 +++++----------------------- test/parallel/test-buffer-isascii.js | 23 ++++++++---- test/parallel/test-buffer-isutf8.js | 23 ++++++++---- 4 files changed, 51 insertions(+), 65 deletions(-) diff --git a/doc/api/buffer.md b/doc/api/buffer.md index 64802f07f76a..212e0899e0b8 100644 --- a/doc/api/buffer.md +++ b/doc/api/buffer.md @@ -5317,6 +5317,11 @@ npx codemod@latest @nodejs/buffer-atob-btoa added: - v19.6.0 - v18.15.0 +changes: + - version: REPLACEME + pr-url: https://github.com/nodejs/node/pull/64504 + description: Detached `ArrayBuffer`s and views backed by them are treated + as empty. --> * `input` {Buffer | ArrayBuffer | TypedArray} The input to validate. @@ -5325,7 +5330,7 @@ added: This function returns `true` if `input` contains only valid ASCII-encoded data, including the case in which `input` is empty. -Throws if the `input` is a detached array buffer. +A detached `ArrayBuffer`, or a `TypedArray` backed by one, is treated as empty. ### `buffer.isUtf8(input)` @@ -5333,6 +5338,11 @@ Throws if the `input` is a detached array buffer. added: - v19.4.0 - v18.14.0 +changes: + - version: REPLACEME + pr-url: https://github.com/nodejs/node/pull/64504 + description: Detached `ArrayBuffer`s and views backed by them are treated + as empty. --> * `input` {Buffer | ArrayBuffer | TypedArray} The input to validate. @@ -5341,7 +5351,7 @@ added: This function returns `true` if `input` contains only valid UTF-8-encoded data, including the case in which `input` is empty. -Throws if the `input` is a detached array buffer. +A detached `ArrayBuffer`, or a `TypedArray` backed by one, is treated as empty. ### `buffer.INSPECT_MAX_BYTES` diff --git a/src/node_buffer.cc b/src/node_buffer.cc index 19c28609660d..29aeedd68f48 100644 --- a/src/node_buffer.cc +++ b/src/node_buffer.cc @@ -1360,31 +1360,17 @@ void FastSwap64(Local receiver, static CFunction fast_swap64(CFunction::Make(FastSwap64)); -struct ValidationResult { - bool is_valid; - bool was_detached; -}; - -static ValidationResult ValidateUtf8(Local value) { +static bool ValidateUtf8(Local value) { ArrayBufferViewContents abv(value); - bool was_detached = abv.WasDetached(); - return {!was_detached && simdutf::validate_utf8(abv.data(), abv.length()), - was_detached}; + return abv.length() == 0 || simdutf::validate_utf8(abv.data(), abv.length()); } static void IsUtf8(const FunctionCallbackInfo& args) { - Environment* env = Environment::GetCurrent(args); CHECK_EQ(args.Length(), 1); CHECK(args[0]->IsTypedArray() || args[0]->IsArrayBuffer() || args[0]->IsSharedArrayBuffer()); - const ValidationResult result = ValidateUtf8(args[0]); - if (result.was_detached) { - return node::THROW_ERR_INVALID_STATE( - env, "Cannot validate on a detached buffer"); - } - - args.GetReturnValue().Set(result.is_valid); + args.GetReturnValue().Set(ValidateUtf8(args[0])); } static bool FastIsUtf8(Local receiver, @@ -1393,40 +1379,23 @@ static bool FastIsUtf8(Local receiver, FastApiCallbackOptions& options) { TRACK_V8_FAST_API_CALL("buffer.isUtf8"); HandleScope scope(options.isolate); - - const ValidationResult result = ValidateUtf8(value); - if (result.was_detached) { - node::THROW_ERR_INVALID_STATE(options.isolate, - "Cannot validate on a detached buffer"); - return false; - } - return result.is_valid; + return ValidateUtf8(value); } static CFunction fast_is_utf8(CFunction::Make(FastIsUtf8)); -static ValidationResult ValidateAscii(Local value) { +static bool ValidateAscii(Local value) { ArrayBufferViewContents abv(value); - bool was_detached = abv.WasDetached(); - return { - !was_detached && - !simdutf::validate_ascii_with_errors(abv.data(), abv.length()).error, - was_detached}; + return abv.length() == 0 || + !simdutf::validate_ascii_with_errors(abv.data(), abv.length()).error; } static void IsAscii(const FunctionCallbackInfo& args) { - Environment* env = Environment::GetCurrent(args); CHECK_EQ(args.Length(), 1); CHECK(args[0]->IsTypedArray() || args[0]->IsArrayBuffer() || args[0]->IsSharedArrayBuffer()); - const ValidationResult result = ValidateAscii(args[0]); - if (result.was_detached) { - return node::THROW_ERR_INVALID_STATE( - env, "Cannot validate on a detached buffer"); - } - - args.GetReturnValue().Set(result.is_valid); + args.GetReturnValue().Set(ValidateAscii(args[0])); } static bool FastIsAscii(Local receiver, @@ -1435,14 +1404,7 @@ static bool FastIsAscii(Local receiver, FastApiCallbackOptions& options) { TRACK_V8_FAST_API_CALL("buffer.isAscii"); HandleScope scope(options.isolate); - - const ValidationResult result = ValidateAscii(value); - if (result.was_detached) { - node::THROW_ERR_INVALID_STATE(options.isolate, - "Cannot validate on a detached buffer"); - return false; - } - return result.is_valid; + return ValidateAscii(value); } static CFunction fast_is_ascii(CFunction::Make(FastIsAscii)); diff --git a/test/parallel/test-buffer-isascii.js b/test/parallel/test-buffer-isascii.js index b9468ca13359..48cc96a17d61 100644 --- a/test/parallel/test-buffer-isascii.js +++ b/test/parallel/test-buffer-isascii.js @@ -30,13 +30,20 @@ assert.strictEqual(isAscii(Buffer.from([])), true); }); { - // Test with detached array buffers - const arrayBuffer = new ArrayBuffer(1024); + // Detached array buffers and views are treated as empty. + const arrayBuffer = new ArrayBuffer(1); + const typedArray = new Uint8Array(arrayBuffer); + typedArray[0] = 0xff; + const inputs = [ + arrayBuffer, + typedArray, + Buffer.from(arrayBuffer), + ]; + for (const input of inputs) { + assert.strictEqual(isAscii(input), false); + } structuredClone(arrayBuffer, { transfer: [arrayBuffer] }); - assert.throws( - () => { isAscii(arrayBuffer); }, - { - code: 'ERR_INVALID_STATE' - } - ); + for (const input of inputs) { + assert.strictEqual(isAscii(input), true); + } } diff --git a/test/parallel/test-buffer-isutf8.js b/test/parallel/test-buffer-isutf8.js index 204db3e6a5fe..151fc0baf4ae 100644 --- a/test/parallel/test-buffer-isutf8.js +++ b/test/parallel/test-buffer-isutf8.js @@ -74,13 +74,20 @@ assert.strictEqual(isUtf8(Buffer.from([])), true); }); { - // Test with detached array buffers - const arrayBuffer = new ArrayBuffer(1024); + // Detached array buffers and views are treated as empty. + const arrayBuffer = new ArrayBuffer(1); + const typedArray = new Uint8Array(arrayBuffer); + typedArray[0] = 0xff; + const inputs = [ + arrayBuffer, + typedArray, + Buffer.from(arrayBuffer), + ]; + for (const input of inputs) { + assert.strictEqual(isUtf8(input), false); + } structuredClone(arrayBuffer, { transfer: [arrayBuffer] }); - assert.throws( - () => { isUtf8(arrayBuffer); }, - { - code: 'ERR_INVALID_STATE' - } - ); + for (const input of inputs) { + assert.strictEqual(isUtf8(input), true); + } } From b6fa9c1819b2f6968420be63b1c395d654ce9e46 Mon Sep 17 00:00:00 2001 From: Archkon <180910180+Archkon@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:17:26 +0800 Subject: [PATCH 017/344] tools: sync mk-ca-bundle.pl with curl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synchronize mk-ca-bundle.pl with curl 1.33. This brings in curl's corrected handling of NSS distrust-after metadata. Refs: https://github.com/curl/curl/blob/0ada20387c31c638cfd7f6b4ae7e5cab5b318caf/scripts/mk-ca-bundle.pl Signed-off-by: Archkon <180910180+Archkon@users.noreply.github.com> PR-URL: https://github.com/nodejs/node/pull/64753 Fixes: https://github.com/nodejs/node/issues/64752 Reviewed-By: Tim Perry Reviewed-By: James M Snell Reviewed-By: René --- src/node_root_certs.h | 33 +++ tools/mk-ca-bundle.pl | 529 ++++++++++++++++++++++++++---------------- 2 files changed, 358 insertions(+), 204 deletions(-) diff --git a/src/node_root_certs.h b/src/node_root_certs.h index 48d2fc5cb7d1..517dc8814c6d 100644 --- a/src/node_root_certs.h +++ b/src/node_root_certs.h @@ -118,6 +118,39 @@ "WD9f\n" "-----END CERTIFICATE-----", +/* Izenpe.com */ +"-----BEGIN CERTIFICATE-----\n" +"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\n" +"1V4tC5h9Pmzb/CaIxw==\n" +"-----END CERTIFICATE-----", + /* Go Daddy Root Certificate Authority - G2 */ "-----BEGIN CERTIFICATE-----\n" "MIIDxTCCAq2gAwIBAgIBADANBgkqhkiG9w0BAQsFADCBgzELMAkGA1UEBhMCVVMxEDAOBgNV\n" diff --git a/tools/mk-ca-bundle.pl b/tools/mk-ca-bundle.pl index 4057c808bc60..6763ac52bf20 100755 --- a/tools/mk-ca-bundle.pl +++ b/tools/mk-ca-bundle.pl @@ -1,4 +1,4 @@ -#!/usr/bin/perl -w +#!/usr/bin/env perl # *************************************************************************** # * _ _ ____ _ # * Project ___| | | | _ \| | @@ -6,11 +6,11 @@ # * | (__| |_| | _ <| |___ # * \___|\___/|_| \_\_____| # * -# * Copyright (C) 1998 - 2014, Daniel Stenberg, , et al. +# * Copyright (C) Daniel Stenberg, , et al. # * # * This software is licensed as described in the file COPYING, which # * you should have received as part of this distribution. The terms -# * are also available at http://curl.haxx.se/docs/copyright.html. +# * are also available at https://curl.se/docs/copyright.html. # * # * You may opt to use, copy, modify, merge, publish, distribute and/or sell # * copies of the Software, and permit persons to whom the Software is @@ -19,6 +19,8 @@ # * This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY # * KIND, either express or implied. # * +# * SPDX-License-Identifier: curl +# * # *************************************************************************** # This Perl script creates a fresh ca-bundle.crt file for use with libcurl. # It downloads certdata.txt from Mozilla's source tree (see URL below), @@ -34,156 +36,165 @@ use Getopt::Std; use MIME::Base64; use strict; -use vars qw($opt_h $opt_i $opt_l $opt_p $opt_q $opt_s $opt_t $opt_v $opt_w); +use warnings; +use vars qw($opt_h $opt_i $opt_l $opt_m $opt_p $opt_q $opt_s $opt_t $opt_v $opt_w); use List::Util; use Text::Wrap; # If the OpenSSL commandline is not in search path you can configure it here! my $openssl = 'openssl'; -my $version = '1.25'; +my $version = '1.33'; $opt_w = 72; # default base64 encoded lines length -# default cert types to include in the output (default is to include CAs which may issue SSL server certs) +# default cert types to include in the output (default is to include CAs which +# may issue SSL server certs) my $default_mozilla_trust_purposes = "SERVER_AUTH"; my $default_mozilla_trust_levels = "TRUSTED_DELEGATOR"; $opt_p = $default_mozilla_trust_purposes . ":" . $default_mozilla_trust_levels; my @valid_mozilla_trust_purposes = ( - "DIGITAL_SIGNATURE", - "NON_REPUDIATION", - "KEY_ENCIPHERMENT", - "DATA_ENCIPHERMENT", - "KEY_AGREEMENT", - "KEY_CERT_SIGN", - "CRL_SIGN", - "SERVER_AUTH", - "CLIENT_AUTH", - "CODE_SIGNING", - "EMAIL_PROTECTION", - "IPSEC_END_SYSTEM", - "IPSEC_TUNNEL", - "IPSEC_USER", - "TIME_STAMPING", - "STEP_UP_APPROVED" + "DIGITAL_SIGNATURE", + "NON_REPUDIATION", + "KEY_ENCIPHERMENT", + "DATA_ENCIPHERMENT", + "KEY_AGREEMENT", + "KEY_CERT_SIGN", + "CRL_SIGN", + "SERVER_AUTH", + "CLIENT_AUTH", + "CODE_SIGNING", + "EMAIL_PROTECTION", + "IPSEC_END_SYSTEM", + "IPSEC_TUNNEL", + "IPSEC_USER", + "TIME_STAMPING", + "STEP_UP_APPROVED" ); my @valid_mozilla_trust_levels = ( - "TRUSTED_DELEGATOR", # CAs - "NOT_TRUSTED", # Don't trust these certs. - "MUST_VERIFY_TRUST", # This explicitly tells us that it ISN'T a CA but is otherwise ok. In other words, this should tell the app to ignore any other sources that claim this is a CA. - "TRUSTED" # This cert is trusted, but only for itself and not for delegates (i.e. it is not a CA). + "TRUSTED_DELEGATOR", # CAs + "NOT_TRUSTED", # Do not trust these certs. + "MUST_VERIFY_TRUST", # This explicitly tells us that it IS NOT a CA but is + # otherwise ok. In other words, this should tell the + # app to ignore any other sources that claim this is + # a CA. + "TRUSTED" # This cert is trusted, but only for itself and not + # for delegates (i.e. it is not a CA). ); -my $default_signature_algorithms = $opt_s = "MD5"; +my $default_signature_algorithms = $opt_s = "SHA256"; my @valid_signature_algorithms = ( - "MD5", - "SHA1", - "SHA256", - "SHA384", - "SHA512" + "SHA256", + "SHA384", + "SHA512" ); $0 =~ s@.*(/|\\)@@; $Getopt::Std::STANDARD_HELP_VERSION = 1; -getopts('bd:fhilnp:qs:tuvw:'); - -if ($opt_i) { - print ("=" x 78 . "\n"); - print "Script Version : $version\n"; - print "Perl Version : $]\n"; - print "Operating System Name : $^O\n"; - print "Getopt::Std.pm Version : ${Getopt::Std::VERSION}\n"; - print "MIME::Base64.pm Version : ${MIME::Base64::VERSION}\n"; - print ("=" x 78 . "\n"); +getopts('hilmp:qs:tvw:'); + +if($opt_i) { + print ("=" x 78 . "\n"); + print "Script Version : $version\n"; + print "Perl Version : $]\n"; + print "Operating System Name : $^O\n"; + print "Getopt::Std.pm Version : ${Getopt::Std::VERSION}\n"; + print "MIME::Base64.pm Version : ${MIME::Base64::VERSION}\n"; + print ("=" x 78 . "\n"); } sub HELP_MESSAGE() { - print "Usage:\t${0} [-i] [-l] [-p] [-q] [-s] [-t] [-v] [-w] []\n"; - print "\t-i\tprint version info about used modules\n"; - print "\t-l\tprint license info about certdata.txt\n"; - print wrap("\t","\t\t", "-p\tlist of Mozilla trust purposes and levels for certificates to include in output. Takes the form of a comma separated list of purposes, a colon, and a comma separated list of levels. (default: $default_mozilla_trust_purposes:$default_mozilla_trust_levels)"), "\n"; - print "\t\t Valid purposes are:\n"; - print wrap("\t\t ","\t\t ", join( ", ", "ALL", @valid_mozilla_trust_purposes ) ), "\n"; - print "\t\t Valid levels are:\n"; - print wrap("\t\t ","\t\t ", join( ", ", "ALL", @valid_mozilla_trust_levels ) ), "\n"; - print "\t-q\tbe really quiet (no progress output at all)\n"; - print wrap("\t","\t\t", "-s\tcomma separated list of certificate signatures/hashes to output in plain text mode. (default: $default_signature_algorithms)\n"); - print "\t\t Valid signature algorithms are:\n"; - print wrap("\t\t ","\t\t ", join( ", ", "ALL", @valid_signature_algorithms ) ), "\n"; - print "\t-t\tinclude plain text listing of certificates\n"; - print "\t-v\tbe verbose and print out processed CAs\n"; - print "\t-w \twrap base64 output lines after chars (default: ${opt_w})\n"; - exit; + print "Usage:\t${0} [-i] [-l] [-m] [-p] [-q] [-s] [-t] [-v] [-w] []\n"; + print "\t-i\tprint version info about used modules\n"; + print "\t-l\tprint license info about certdata.txt\n"; + print "\t-m\tinclude meta data in output\n"; + print wrap("\t","\t\t", "-p\tlist of Mozilla trust purposes and levels for certificates to include in output. " . + "Takes the form of a comma separated list of purposes, a colon, and a comma separated list of levels. " . + "(default: $default_mozilla_trust_purposes:$default_mozilla_trust_levels)"), "\n"; + print "\t\t Valid purposes are:\n"; + print wrap("\t\t ","\t\t ", join(", ", "ALL", @valid_mozilla_trust_purposes)), "\n"; + print "\t\t Valid levels are:\n"; + print wrap("\t\t ","\t\t ", join(", ", "ALL", @valid_mozilla_trust_levels)), "\n"; + print "\t-q\tbe really quiet (no progress output at all)\n"; + print wrap("\t","\t\t", "-s\tcomma separated list of certificate signatures/hashes to output in plain text mode. (default: $default_signature_algorithms)\n"); + print "\t\t Valid signature algorithms are:\n"; + print wrap("\t\t ","\t\t ", join(", ", "ALL", @valid_signature_algorithms)), "\n"; + print "\t-t\tinclude plain text listing of certificates\n"; + print "\t-v\tbe verbose and print out processed CAs\n"; + print "\t-w \twrap base64 output lines after chars (default: ${opt_w})\n"; + exit; } sub VERSION_MESSAGE() { - print "${0} version ${version} running Perl ${]} on ${^O}\n"; + print "${0} version ${version} running Perl ${]} on ${^O}\n"; } -HELP_MESSAGE() if ($opt_h); +HELP_MESSAGE() if($opt_h); sub report($@) { - my $output = shift; + my $output = shift; - print STDERR $output . "\n" unless $opt_q; + print STDERR $output . "\n" unless $opt_q; } sub is_in_list($@) { - my $target = shift; + my $target = shift; - return defined(List::Util::first { $target eq $_ } @_); + return defined(List::Util::first { $target eq $_ } @_); } -# Parses $param_string as a case insensitive comma separated list with optional whitespace -# validates that only allowed parameters are supplied +# Parses $param_string as a case insensitive comma separated list with optional +# whitespace validates that only allowed parameters are supplied sub parse_csv_param($$@) { - my $description = shift; - my $param_string = shift; - my @valid_values = @_; - - my @values = map { - s/^\s+//; # strip leading spaces - s/\s+$//; # strip trailing spaces - uc $_ # return the modified string as upper case - } split( ',', $param_string ); - - # Find all values which are not in the list of valid values or "ALL" - my @invalid = grep { !is_in_list($_,"ALL",@valid_values) } @values; - - if ( scalar(@invalid) > 0 ) { - # Tell the user which parameters were invalid and print the standard help message which will exit - print "Error: Invalid ", $description, scalar(@invalid) == 1 ? ": " : "s: ", join( ", ", map { "\"$_\"" } @invalid ), "\n"; - HELP_MESSAGE(); - } + my $description = shift; + my $param_string = shift; + my @valid_values = @_; + + my @values = map { + s/^\s+//; # strip leading spaces + s/\s+$//; # strip trailing spaces + uc $_ # return the modified string as upper case + } split(',', $param_string); + + # Find all values which are not in the list of valid values or "ALL" + my @invalid = grep { !is_in_list($_, "ALL", @valid_values) } @values; + + if(scalar(@invalid) > 0) { + # Tell the user which parameters were invalid and print the standard help + # message which also exits + print "Error: Invalid ", $description, scalar(@invalid) == 1 ? ": " : "s: ", join(", ", map { "\"$_\"" } @invalid), "\n"; + HELP_MESSAGE(); + } - @values = @valid_values if ( is_in_list("ALL",@values) ); + @values = @valid_values if(is_in_list("ALL", @values)); - return @values; + return @values; } -if ( $opt_p !~ m/:/ ) { - print "Error: Mozilla trust identifier list must include both purposes and levels\n"; - HELP_MESSAGE(); +if($opt_p !~ m/:/) { + print "Error: Mozilla trust identifier list must include both purposes and levels\n"; + HELP_MESSAGE(); } -(my $included_mozilla_trust_purposes_string, my $included_mozilla_trust_levels_string) = split( ':', $opt_p ); -my @included_mozilla_trust_purposes = parse_csv_param( "trust purpose", $included_mozilla_trust_purposes_string, @valid_mozilla_trust_purposes ); -my @included_mozilla_trust_levels = parse_csv_param( "trust level", $included_mozilla_trust_levels_string, @valid_mozilla_trust_levels ); +(my $included_mozilla_trust_purposes_string, my $included_mozilla_trust_levels_string) = split(':', $opt_p); +my @included_mozilla_trust_purposes = parse_csv_param("trust purpose", $included_mozilla_trust_purposes_string, @valid_mozilla_trust_purposes); +my @included_mozilla_trust_levels = parse_csv_param("trust level", $included_mozilla_trust_levels_string, @valid_mozilla_trust_levels); -my @included_signature_algorithms = parse_csv_param( "signature algorithm", $opt_s, @valid_signature_algorithms ); +my @included_signature_algorithms = parse_csv_param("signature algorithm", $opt_s, @valid_signature_algorithms); sub should_output_cert(%) { - my %trust_purposes_by_level = @_; + my %trust_purposes_by_level = @_; - foreach my $level (@included_mozilla_trust_levels) { - # for each level we want to output, see if any of our desired purposes are included - return 1 if ( defined( List::Util::first { is_in_list( $_, @included_mozilla_trust_purposes ) } @{$trust_purposes_by_level{$level}} ) ); - } + foreach my $level (@included_mozilla_trust_levels) { + # for each level we want to output, see if any of our desired purposes are + # included + return 1 if(defined(List::Util::first { is_in_list($_, @included_mozilla_trust_purposes) } @{$trust_purposes_by_level{$level}})); + } - return 0; + return 0; } my $crt = $ARGV[0] || dirname(__FILE__) . '/../src/node_root_certs.h'; @@ -191,132 +202,242 @@ (%) my $stdout = $crt eq '-'; -if( $stdout ) { - open(CRT, '> -') or die "Couldn't open STDOUT: $!\n"; +if($stdout) { + open(CRT, '> -') or die "Could not open STDOUT: $!\n"; } else { - open(CRT,">$crt.~") or die "Couldn't open $crt.~: $!\n"; + open(CRT, ">", "$crt.~") or die "Could not open $crt.~: $!\n"; } my $caname; my $certnum = 0; my $skipnum = 0; my $start_of_cert = 0; - -open(TXT,"$txt") or die "Couldn't open $txt: $!\n"; +my $main_block = 0; +my $main_block_name; +my $trust_block = 0; +my $trust_block_name; +my @precert; +my $cka_value; +my $valid = 0; + +open(TXT, $txt) or die "Could not open $txt: $!\n"; print CRT "#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS\n"; -while () { - if (/\*\*\*\*\* BEGIN LICENSE BLOCK \*\*\*\*\*/) { - print CRT; - print if ($opt_l); - while () { - print CRT; - print if ($opt_l); - last if (/\*\*\*\*\* END LICENSE BLOCK \*\*\*\*\*/); +while() { + if(/\*\*\*\*\* BEGIN LICENSE BLOCK \*\*\*\*\*/) { + print CRT; + print if($opt_l); + while() { + print CRT; + print if($opt_l); + last if(/\*\*\*\*\* END LICENSE BLOCK \*\*\*\*\*/); + } + next; } - } - next if /^#|^\s*$/; - chomp; - if (/^CVS_ID\s+\"(.*)\"/) { - print CRT "/* $1 */\n"; - } - - # this is a match for the start of a certificate - if (/^CKA_CLASS CK_OBJECT_CLASS CKO_CERTIFICATE/) { - $start_of_cert = 1 - } - if ($start_of_cert && /^CKA_LABEL UTF8 \"(.*)\"/) { - $caname = $1; - } - my %trust_purposes_by_level; - if ($start_of_cert && /^CKA_VALUE MULTILINE_OCTAL/) { - my $data; - while () { - last if (/^END/); - chomp; - my @octets = split(/\\/); - shift @octets; - for (@octets) { - $data .= chr(oct); - } + # The input file format consists of blocks of Mozilla objects. + # The blocks are separated by blank lines but may be related. + elsif(/^\s*$/) { + $main_block = 0; + $trust_block = 0; + next; } - # scan forwards until the trust part - while () { - last if (/^CKA_CLASS CK_OBJECT_CLASS CKO_NSS_TRUST/); - chomp; + # Each certificate has a main block. + elsif(/^# Certificate "(.*)"/) { + (!$main_block && !$trust_block) or die "Unexpected certificate block"; + $main_block = 1; + $main_block_name = $1; + # Reset all other certificate variables. + $trust_block = 0; + $trust_block_name = ""; + $valid = 0; + $start_of_cert = 0; + $caname = ""; + $cka_value = ""; + undef @precert; + next; } - # now scan the trust part to determine how we should trust this cert - while () { - last if (/^#/); - if (/^CKA_TRUST_([A-Z_]+)\s+CK_TRUST\s+CKT_NSS_([A-Z_]+)\s*$/) { - if ( !is_in_list($1,@valid_mozilla_trust_purposes) ) { - report "Warning: Unrecognized trust purpose for cert: $caname. Trust purpose: $1. Trust Level: $2"; - } elsif ( !is_in_list($2,@valid_mozilla_trust_levels) ) { - report "Warning: Unrecognized trust level for cert: $caname. Trust purpose: $1. Trust Level: $2"; - } else { - push @{$trust_purposes_by_level{$2}}, $1; + # Each certificate's main block is followed by a trust block. + elsif(/^# Trust for (?:Certificate )?"(.*)"/) { + (!$main_block && !$trust_block) or die "Unexpected trust block"; + $trust_block = 1; + $trust_block_name = $1; + if($main_block_name ne $trust_block_name) { + die "cert name \"$main_block_name\" != trust name \"$trust_block_name\""; + } + next; + } + # Ignore other blocks. + # + # There is a documentation comment block, a BEGINDATA block, and a bunch of + # blocks starting with "# Explicitly Distrust ". + # + # The latter is for certificates that have already been removed and are not + # included. Not all explicitly distrusted certificates are ignored at this + # point, only those without an actual certificate. + elsif(!$main_block && !$trust_block) { + next; + } + elsif(/^#/) { + # The commented lines in a main block are plaintext metadata that describes + # the certificate. Issuer, Subject, Fingerprint, etc. + if($main_block) { + push @precert, s{^#}{//}r if not /^#$/; + if(/^# Not Valid After : (.*)/) { + my $stamp = $1; + use Time::Piece; + # Not Valid After : Thu Sep 30 14:01:15 2021 + my $t = Time::Piece->strptime($stamp, "%a %b %d %H:%M:%S %Y"); + my $delta = ($t->epoch - time()); # negative means no longer valid + if($delta < 0) { + $skipnum++; + report "Skipping: $main_block_name is not valid anymore" if($opt_v); + $valid = 0; + } + else { + $valid = 1; + } + } } - } + next; + } + elsif(!$valid) { + next; } - if ( !should_output_cert(%trust_purposes_by_level) ) { - $skipnum ++; - } elsif ($caname =~ /TrustCor/) { - $skipnum ++; - } else { - my $encoded = MIME::Base64::encode_base64($data, ''); - $encoded =~ s/(.{1,${opt_w}})/"$1\\n"\n/g; - my $pem = "\"-----BEGIN CERTIFICATE-----\\n\"\n" - . $encoded - . "\"-----END CERTIFICATE-----\",\n"; - print CRT "\n/* $caname */\n"; - - my $maxStringLength = length($caname); - if ($opt_t) { - foreach my $key (keys %trust_purposes_by_level) { - my $string = $key . ": " . join(", ", @{$trust_purposes_by_level{$key}}); - $maxStringLength = List::Util::max( length($string), $maxStringLength ); - print CRT $string . "\n"; + chomp; + + if($main_block) { + if(/^CKA_CLASS CK_OBJECT_CLASS CKO_CERTIFICATE/) { + !$start_of_cert or die "Duplicate CKO_CERTIFICATE object"; + $start_of_cert = 1; + next; + } + elsif(!$start_of_cert) { + next; + } + elsif(/^CKA_LABEL UTF8 \"(.*)\"/) { + ($caname eq "") or die "Duplicate CKA_LABEL attribute"; + $caname = $1; + if($caname ne $main_block_name) { + die "caname \"$caname\" != cert name \"$main_block_name\""; + } + next; } - } - if (!$opt_t) { - print CRT $pem; - } else { - my $pipe = ""; - foreach my $hash (@included_signature_algorithms) { - $pipe = "|$openssl x509 -" . $hash . " -fingerprint -noout -inform PEM"; - if (!$stdout) { - $pipe .= " >> $crt.~"; - close(CRT) or die "Couldn't close $crt.~: $!"; - } - open(TMP, $pipe) or die "Couldn't open openssl pipe: $!"; - print TMP $pem; - close(TMP) or die "Couldn't close openssl pipe: $!"; - if (!$stdout) { - open(CRT, ">>$crt.~") or die "Couldn't open $crt.~: $!"; - } + elsif(/^CKA_VALUE MULTILINE_OCTAL/) { + ($cka_value eq "") or die "Duplicate CKA_VALUE attribute"; + while() { + last if(/^END/); + chomp; + my @octets = split(/\\/); + shift @octets; + for(@octets) { + $cka_value .= chr(oct); + } + } + next; + } + else { + next; + } + } + + if(!$trust_block || !$start_of_cert || $caname eq "" || $cka_value eq "") { + die "Certificate extraction failed"; + } + + my %trust_purposes_by_level; + + if(/^CKA_CLASS CK_OBJECT_CLASS CKO_NSS_TRUST/) { + # now scan the trust part to determine how we should trust this cert + while() { + if(/^\s*$/) { + $trust_block = 0; + last; + } + if(/^CKA_TRUST_([A-Z_]+)\s+CK_TRUST\s+CKT_NSS_([A-Z_]+)\s*$/) { + if(!is_in_list($1, @valid_mozilla_trust_purposes)) { + report "Warning: Unrecognized trust purpose for cert: $caname. Trust purpose: $1. Trust Level: $2"; + } elsif(!is_in_list($2, @valid_mozilla_trust_levels)) { + report "Warning: Unrecognized trust level for cert: $caname. Trust purpose: $1. Trust Level: $2"; + } else { + push @{$trust_purposes_by_level{$2}}, $1; + } + } } - $pipe = "|$openssl x509 -text -inform PEM"; - if (!$stdout) { - $pipe .= " >> $crt.~"; - close(CRT) or die "Couldn't close $crt.~: $!"; + + # Sanity check that an explicitly distrusted certificate only has trust + # purposes with a trust level of NOT_TRUSTED. + # + # Certificate objects that are explicitly distrusted are in a certificate + # block that starts # Certificate "Explicitly Distrust(ed) ", + # where "Explicitly Distrust(ed) " was prepended to the original cert name. + if($caname =~ /distrust/i || + $main_block_name =~ /distrust/i || + $trust_block_name =~ /distrust/i) { + my @levels = keys %trust_purposes_by_level; + if(scalar(@levels) != 1 || $levels[0] ne "NOT_TRUSTED") { + die "\"$caname\" must have all trust purposes at level NOT_TRUSTED."; + } } - open(TMP, $pipe) or die "Couldn't open openssl pipe: $!"; - print TMP $pem; - close(TMP) or die "Couldn't close openssl pipe: $!"; - if (!$stdout) { - open(CRT, ">>$crt.~") or die "Couldn't open $crt.~: $!"; + + if(!should_output_cert(%trust_purposes_by_level)) { + $skipnum ++; + report "Skipping: $caname lacks acceptable trust level" if($opt_v); + } elsif($caname =~ /TrustCor/) { + $skipnum ++; + } else { + my $encoded = MIME::Base64::encode_base64($cka_value, ''); + $encoded =~ s/(.{1,${opt_w}})/"$1\\n"\n/g; + my $pem = "\"-----BEGIN CERTIFICATE-----\\n\"\n" + . $encoded + . "\"-----END CERTIFICATE-----\",\n"; + print CRT "\n/* $caname */\n"; + if($opt_t) { + foreach my $key (sort keys %trust_purposes_by_level) { + my $string = $key . ": " . join(", ", @{$trust_purposes_by_level{$key}}); + print CRT $string . "\n"; + } + } + if($opt_m) { + print CRT for @precert; + } + if(!$opt_t) { + print CRT $pem; + } else { + my $pipe = ""; + foreach my $hash (@included_signature_algorithms) { + $pipe = "|$openssl x509 -" . $hash . " -fingerprint -noout -inform PEM"; + if(!$stdout) { + $pipe .= " >> $crt.~"; + close(CRT) or die "Could not close $crt.~: $!"; + } + open(TMP, $pipe) or die "Could not open openssl pipe: $!"; + print TMP $pem; + close(TMP) or die "Could not close openssl pipe: $!"; + if(!$stdout) { + open(CRT, ">>", "$crt.~") or die "Could not open $crt.~: $!"; + } + } + $pipe = "|$openssl x509 -text -inform PEM"; + if(!$stdout) { + $pipe .= " >> $crt.~"; + close(CRT) or die "Could not close $crt.~: $!"; + } + open(TMP, $pipe) or die "Could not open openssl pipe: $!"; + print TMP $pem; + close(TMP) or die "Could not close openssl pipe: $!"; + if(!$stdout) { + open(CRT, ">>", "$crt.~") or die "Could not open $crt.~: $!"; + } + } + report "Processed: $caname" if($opt_v); + $certnum++; } - } - report "Parsing: $caname" if ($opt_v); - $certnum ++; - $start_of_cert = 0; } - } } print CRT "#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS\n"; -close(TXT) or die "Couldn't close $txt: $!\n"; -close(CRT) or die "Couldn't close $crt.~: $!\n"; -unless( $stdout ) { +close(TXT) or die "Could not close $txt: $!\n"; +close(CRT) or die "Could not close $crt.~: $!\n"; +unless($stdout) { rename "$crt.~", $crt or die "Failed to rename $crt.~ to $crt: $!\n"; } report "Done ($certnum CA certs processed, $skipnum skipped)."; From 3b8a0bacaac74b1dc6443392ad161fc94804e28e Mon Sep 17 00:00:00 2001 From: Yilong Li Date: Wed, 5 Aug 2026 12:37:14 +0800 Subject: [PATCH 018/344] doc: correct default highWaterMark values File write streams inherit the default byte-stream highWaterMark. The fixed 16 KiB value became stale when that default changed. Also document the Windows-specific byte-stream default. Signed-off-by: umuoy1 PR-URL: https://github.com/nodejs/node/pull/64617 Reviewed-By: Chengzhong Wu --- doc/api/fs.md | 13 +++++++++++-- doc/api/stream.md | 14 ++++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/doc/api/fs.md b/doc/api/fs.md index dea508929fe2..c7ee7c12ada0 100644 --- a/doc/api/fs.md +++ b/doc/api/fs.md @@ -323,6 +323,9 @@ fd.createReadStream({ start: 90, end: 99 }); * `objectMode` {boolean} * Returns: {integer} -Returns the default highWaterMark used by streams. -Defaults to `65536` (64 KiB), or `16` for `objectMode`. +Returns the default highWaterMark used by streams. Defaults to `16` for +`objectMode`. For byte streams, it defaults to `65536` (64 KiB) on non-Windows +platforms and `16384` (16 KiB) on Windows. ### `stream.setDefaultHighWaterMark(objectMode, value)` @@ -3779,7 +3784,7 @@ changes: * `options` {Object} * `highWaterMark` {number} Buffer level when [`stream.write()`][stream-write] starts returning `false`. **Default:** - `65536` (64 KiB), or `16` for `objectMode` streams. + See [`stream.getDefaultHighWaterMark()`][]. * `decodeStrings` {boolean} Whether to encode `string`s passed to [`stream.write()`][stream-write] to `Buffer`s (with the encoding specified in the [`stream.write()`][stream-write] call) before passing @@ -4153,7 +4158,7 @@ changes: * `options` {Object} * `highWaterMark` {number} The maximum [number of bytes][hwm-gotcha] to store in the internal buffer before ceasing to read from the underlying resource. - **Default:** `65536` (64 KiB), or `16` for `objectMode` streams. + **Default:** See [`stream.getDefaultHighWaterMark()`][]. * `encoding` {string} If specified, then buffers will be decoded to strings using the specified encoding. **Default:** `null`. * `objectMode` {boolean} Whether this stream should behave @@ -5094,6 +5099,7 @@ contain multi-byte characters. [`stream.cork()`]: #writablecork [`stream.duplexPair()`]: #streamduplexpairoptions [`stream.finished()`]: #streamfinishedstream-options-callback +[`stream.getDefaultHighWaterMark()`]: #streamgetdefaulthighwatermarkobjectmode [`stream.pipe()`]: #readablepipedestination-options [`stream.pipeline()`]: #streampipelinesource-transforms-destination-callback [`stream.uncork()`]: #writableuncork From a293dbf0e15531745c34c5eadc297d43966ada74 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Tue, 4 Aug 2026 22:36:44 -0700 Subject: [PATCH 019/344] src: update repeated use strings to env Signed-off-by: James M Snell PR-URL: https://github.com/nodejs/node/pull/64760 Reviewed-By: Yagiz Nizipli Reviewed-By: Filip Skokan --- src/crypto/crypto_context.cc | 9 +++------ src/crypto/crypto_ec.cc | 8 ++++---- src/crypto/crypto_keys.cc | 26 ++++++++++---------------- src/crypto/crypto_util.cc | 10 +++------- src/crypto/crypto_util.h | 2 +- src/env_properties.h | 17 ++++++++++++++++- src/node_ffi.cc | 2 +- src/permission/permission.cc | 8 ++++---- 8 files changed, 42 insertions(+), 40 deletions(-) diff --git a/src/crypto/crypto_context.cc b/src/crypto/crypto_context.cc index d6387a55bd78..0f61767cdbbe 100644 --- a/src/crypto/crypto_context.cc +++ b/src/crypto/crypto_context.cc @@ -2255,12 +2255,9 @@ void SecureContext::GetCertificateCompressionAlgorithms( Environment* env = Environment::GetCurrent(args); LocalVector algs(env->isolate()); #ifdef NODE_OPENSSL_HAS_CERT_COMP - if (BIO_f_zlib() != nullptr) - algs.push_back(FIXED_ONE_BYTE_STRING(env->isolate(), "zlib")); - if (BIO_f_brotli() != nullptr) - algs.push_back(FIXED_ONE_BYTE_STRING(env->isolate(), "brotli")); - if (BIO_f_zstd() != nullptr) - algs.push_back(FIXED_ONE_BYTE_STRING(env->isolate(), "zstd")); + if (BIO_f_zlib() != nullptr) algs.push_back(env->zlib_string()); + if (BIO_f_brotli() != nullptr) algs.push_back(env->brotli_string()); + if (BIO_f_zstd() != nullptr) algs.push_back(env->zstd_string()); #endif args.GetReturnValue().Set( Array::New(env->isolate(), algs.data(), algs.size())); diff --git a/src/crypto/crypto_ec.cc b/src/crypto/crypto_ec.cc index 388931c11e79..b08d4b7a9106 100644 --- a/src/crypto/crypto_ec.cc +++ b/src/crypto/crypto_ec.cc @@ -520,16 +520,16 @@ bool ExportJWKEcKey(Environment* env, const int nid = EC_GROUP_get_curve_name(group); switch (nid) { case NID_X9_62_prime256v1: - crv_name = FIXED_ONE_BYTE_STRING(env->isolate(), "P-256"); + crv_name = env->p256_string(); break; case NID_secp256k1: - crv_name = FIXED_ONE_BYTE_STRING(env->isolate(), "secp256k1"); + crv_name = env->secp256k1_string(); break; case NID_secp384r1: - crv_name = FIXED_ONE_BYTE_STRING(env->isolate(), "P-384"); + crv_name = env->p384_string(); break; case NID_secp521r1: - crv_name = FIXED_ONE_BYTE_STRING(env->isolate(), "P-521"); + crv_name = env->p521_string(); break; default: { THROW_ERR_CRYPTO_JWK_UNSUPPORTED_CURVE( diff --git a/src/crypto/crypto_keys.cc b/src/crypto/crypto_keys.cc index d8bdc93deff8..b4e3aa72292f 100644 --- a/src/crypto/crypto_keys.cc +++ b/src/crypto/crypto_keys.cc @@ -1796,8 +1796,7 @@ BaseObjectPtr NativeKeyObject::KeyObjectTransferData::Deserialize( return {}; Local key_ctor; - Local arg = FIXED_ONE_BYTE_STRING(env->isolate(), - "internal/crypto/keys"); + Local arg = env->internal_crypto_keys_string(); if (env->builtin_module_require() ->Call(context, Null(env->isolate()), 1, &arg) .IsEmpty()) { @@ -1875,7 +1874,7 @@ MaybeLocal NativeCryptoKey::Create(Environment* env, if (!KeyObjectHandle::Create(env, data).ToLocal(&handle)) return {}; if (env->crypto_internal_cryptokey_constructor().IsEmpty()) { - Local arg = FIXED_ONE_BYTE_STRING(isolate, "internal/crypto/keys"); + Local arg = env->internal_crypto_keys_string(); if (env->builtin_module_require() ->Call(context, Null(isolate), 1, &arg) .IsEmpty()) { @@ -2017,7 +2016,6 @@ Maybe NativeCryptoKey::FinalizeTransferRead( } CHECK(bundle_v->IsObject()); Local bundle = bundle_v.As(); - Isolate* isolate = env()->isolate(); Local obj = object(); // The partially-initialized object produced by @@ -2025,23 +2023,21 @@ Maybe NativeCryptoKey::FinalizeTransferRead( CHECK(obj->GetInternalField(kAlgorithmField).As()->IsUndefined()); Local algorithm_v; - if (!bundle->Get(context, FIXED_ONE_BYTE_STRING(isolate, "algorithm")) - .ToLocal(&algorithm_v)) { + if (!bundle->Get(context, env()->algorithm_string()).ToLocal(&algorithm_v)) { return Nothing(); } CHECK(algorithm_v->IsObject()); obj->SetInternalField(kAlgorithmField, algorithm_v); Local usages_v; - if (!bundle->Get(context, FIXED_ONE_BYTE_STRING(isolate, "usages")) - .ToLocal(&usages_v)) { + if (!bundle->Get(context, env()->usages_string()).ToLocal(&usages_v)) { return Nothing(); } CHECK(usages_v->IsUint32()); usages_mask_ = usages_v.As()->Value(); Local extractable_v; - if (!bundle->Get(context, FIXED_ONE_BYTE_STRING(isolate, "extractable")) + if (!bundle->Get(context, env()->extractable_string()) .ToLocal(&extractable_v)) { return Nothing(); } @@ -2054,21 +2050,19 @@ Maybe NativeCryptoKey::FinalizeTransferRead( Maybe NativeCryptoKey::CryptoKeyTransferData::FinalizeTransferWrite( Local context, v8::ValueSerializer* serializer) { Isolate* isolate = Isolate::GetCurrent(); + Environment* env = Environment::GetCurrent(isolate); CHECK(!algorithm_.IsEmpty()); Local bundle = Object::New(isolate); Local algorithm_v = PersistentToLocal::Strong(algorithm_); - if (bundle - ->Set( - context, FIXED_ONE_BYTE_STRING(isolate, "algorithm"), algorithm_v) - .IsNothing() || + if (bundle->Set(context, env->algorithm_string(), algorithm_v).IsNothing() || bundle ->Set(context, - FIXED_ONE_BYTE_STRING(isolate, "usages"), + env->usages_string(), Uint32::NewFromUnsigned(isolate, usages_mask_)) .IsNothing() || bundle ->Set(context, - FIXED_ONE_BYTE_STRING(isolate, "extractable"), + env->extractable_string(), v8::Boolean::New(isolate, extractable_)) .IsNothing()) { return Nothing(); @@ -2094,7 +2088,7 @@ BaseObjectPtr NativeCryptoKey::CryptoKeyTransferData::Deserialize( // Make sure internal/crypto/keys has been loaded so that the // CryptoKey constructor is registered with the Environment. Isolate* isolate = env->isolate(); - Local arg = FIXED_ONE_BYTE_STRING(isolate, "internal/crypto/keys"); + Local arg = env->internal_crypto_keys_string(); if (env->builtin_module_require() ->Call(context, Null(isolate), 1, &arg) .IsEmpty()) { diff --git a/src/crypto/crypto_util.cc b/src/crypto/crypto_util.cc index 080f2cf51cec..7c3e4b7ab5fe 100644 --- a/src/crypto/crypto_util.cc +++ b/src/crypto/crypto_util.cc @@ -267,7 +267,7 @@ MaybeLocal cryptoErrorListToException(Environment* env, // If there are no errors, it is likely a bug but we will return // an error anyway. if (errors.empty()) { - return Exception::Error(FIXED_ONE_BYTE_STRING(env->isolate(), "Ok")); + return Exception::Error(env->ok_string()); } // The last error in the list is the one that will be used as the @@ -778,13 +778,9 @@ MaybeLocal CreateWebCryptoJobError(Environment* env, CHECK(domexception_ctor->IsFunction()); Local options = Object::New(isolate); - if (options - ->Set(context, - FIXED_ONE_BYTE_STRING(isolate, "name"), - FIXED_ONE_BYTE_STRING(isolate, "OperationError")) + if (options->Set(context, env->name_string(), env->operationerror_string()) .IsNothing() || - options->Set(context, FIXED_ONE_BYTE_STRING(isolate, "cause"), cause) - .IsNothing()) { + options->Set(context, env->cause_string(), cause).IsNothing()) { return {}; } diff --git a/src/crypto/crypto_util.h b/src/crypto/crypto_util.h index 76afc3dd24a3..308e8fccba59 100644 --- a/src/crypto/crypto_util.h +++ b/src/crypto/crypto_util.h @@ -477,7 +477,7 @@ class CryptoJob : public AsyncWrap, public ThreadPoolWork { { node::errors::TryCatchScope try_catch(env); if (value->IsObject()) { - then_key = FIXED_ONE_BYTE_STRING(env->isolate(), "then"); + then_key = env->then_string(); v8::Local object = value.As(); v8::Maybe has_own_then = object->HasOwnProperty(context, then_key); diff --git a/src/env_properties.h b/src/env_properties.h index 26fab56b2649..513f5875a8d2 100644 --- a/src/env_properties.h +++ b/src/env_properties.h @@ -82,6 +82,7 @@ V(__dirname_string, "__dirname") \ V(ack_string, "ack") \ V(address_string, "address") \ + V(algorithm_string, "algorithm") \ V(aliases_string, "aliases") \ V(allow_bare_named_params_string, "allowBareNamedParameters") \ V(allow_unknown_named_params_string, "allowUnknownNamedParameters") \ @@ -93,6 +94,7 @@ V(backup_string, "backup") \ V(base_string, "base") \ V(base_url_string, "baseURL") \ + V(brotli_string, "brotli") \ V(buffer_string, "buffer") \ V(bytes_parsed_string, "bytesParsed") \ V(bytes_read_string, "bytesRead") \ @@ -100,6 +102,7 @@ V(cached_data_produced_string, "cachedDataProduced") \ V(cached_data_rejected_string, "cachedDataRejected") \ V(cached_data_string, "cachedData") \ + V(cause_string, "cause") \ V(change_string, "change") \ V(changes_string, "changes") \ V(chunks_sent_since_last_write_string, "chunksSentSinceLastWrite") \ @@ -180,6 +183,7 @@ V(exponent_string, "exponent") \ V(exports_string, "exports") \ V(external_stream_string, "_externalStream") \ + V(extractable_string, "extractable") \ V(family_string, "family") \ V(fatal_exception_string, "_fatalException") \ V(fd_string, "fd") \ @@ -215,6 +219,7 @@ V(ignore_string, "ignore") \ V(inherit_string, "inherit") \ V(input_string, "input") \ + V(internal_crypto_keys_string, "internal/crypto/keys") \ V(inverse_string, "inverse") \ V(ipv4_string, "IPv4") \ V(ipv6_string, "IPv6") \ @@ -264,6 +269,7 @@ V(node_string, "node") \ V(object_string, "Object") \ V(ocsp_request_string, "OCSPRequest") \ + V(ok_string, "ok") \ V(oncertcb_string, "oncertcb") \ V(onchange_string, "onchange") \ V(onclienthello_string, "onclienthello") \ @@ -287,10 +293,14 @@ V(onwrite_string, "onwrite") \ V(ongracefulclosecomplete_string, "ongracefulclosecomplete") \ V(openssl_error_stack, "opensslErrorStack") \ + V(operationerror_string, "OperationError") \ V(options_string, "options") \ V(original_string, "original") \ V(output_string, "output") \ V(overlapped_string, "overlapped") \ + V(p256_string, "P-256") \ + V(p384_string, "P-384") \ + V(p521_string, "P-521") \ V(parse_error_string, "Parse Error") \ V(password_string, "password") \ V(path_string, "path") \ @@ -333,6 +343,7 @@ V(return_arrays_string, "returnArrays") \ V(return_string, "return") \ V(salt_length_string, "saltLength") \ + V(secp256k1_string, "secp256k1") \ V(search_string, "search") \ V(servername_string, "servername") \ V(session_id_string, "sessionId") \ @@ -361,6 +372,7 @@ V(syscall_string, "syscall") \ V(table_string, "table") \ V(target_string, "target") \ + V(then_string, "then") \ V(thread_id_string, "threadId") \ V(thread_name_string, "threadName") \ V(tls_group_string, "TLSGroup") \ @@ -379,6 +391,7 @@ V(uid_string, "uid") \ V(unknown_string, "") \ V(url_string, "url") \ + V(usages_string, "usages") \ V(username_string, "username") \ V(value_string, "value") \ V(verify_error_string, "verifyError") \ @@ -388,7 +401,9 @@ V(wrap_string, "wrap") \ V(writable_string, "writable") \ V(write_host_object_string, "_writeHostObject") \ - V(write_queue_size_string, "writeQueueSize") + V(write_queue_size_string, "writeQueueSize") \ + V(zlib_string, "zlib") \ + V(zstd_string, "zstd") #define PER_ISOLATE_TEMPLATE_PROPERTIES(V) \ V(a_record_template, v8::DictionaryTemplate) \ diff --git a/src/node_ffi.cc b/src/node_ffi.cc index 26a8f5a610cb..638ad4feafdf 100644 --- a/src/node_ffi.cc +++ b/src/node_ffi.cc @@ -1210,7 +1210,7 @@ Local DynamicLibrary::GetConstructorTemplate( DynamicLibrary::kInternalFieldCount); tmpl->InstanceTemplate()->SetAccessorProperty( - FIXED_ONE_BYTE_STRING(isolate, "path"), + env->path_string(), FunctionTemplate::New(env->isolate(), DynamicLibrary::GetPath), Local(), attributes); diff --git a/src/permission/permission.cc b/src/permission/permission.cc index cf1174d85fa1..919edd0821fd 100644 --- a/src/permission/permission.cc +++ b/src/permission/permission.cc @@ -265,11 +265,11 @@ bool Permission::is_scope_granted(Environment* env, v8::Object::New(isolate, v8::Null(isolate), nullptr, nullptr, 0); const char* perm_str = PermissionToString(permission); msg->Set(context, - FIXED_ONE_BYTE_STRING(isolate, "permission"), + env->permission_string(), v8::String::NewFromUtf8(isolate, perm_str).ToLocalChecked()) .Check(); msg->Set(context, - FIXED_ONE_BYTE_STRING(isolate, "resource"), + env->resource_string(), v8::String::NewFromUtf8(isolate, res.data(), v8::NewStringType::kNormal, @@ -335,11 +335,11 @@ void Permission::Drop(Environment* env, v8::Object::New(isolate, v8::Null(isolate), nullptr, nullptr, 0); const char* perm_str = PermissionToString(scope); msg->Set(context, - FIXED_ONE_BYTE_STRING(isolate, "permission"), + env->permission_string(), v8::String::NewFromUtf8(isolate, perm_str).ToLocalChecked()) .Check(); msg->Set(context, - FIXED_ONE_BYTE_STRING(isolate, "resource"), + env->resource_string(), v8::String::NewFromUtf8(isolate, param.data(), v8::NewStringType::kNormal, From 1a63416b1abb4ac2e1d026b21687ad4656e35c83 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Wed, 5 Aug 2026 11:19:53 +0100 Subject: [PATCH 020/344] url: handle unparsable serialized URLs in setters Signed-off-by: Matteo Collina PR-URL: https://github.com/nodejs/node/pull/64651 Reviewed-By: James M Snell Reviewed-By: Yagiz Nizipli Reviewed-By: Filip Skokan --- src/node_url.cc | 6 ++++- .../test-whatwg-url-custom-setters.js | 26 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/node_url.cc b/src/node_url.cc index 75aaf468f508..9553942496f8 100644 --- a/src/node_url.cc +++ b/src/node_url.cc @@ -443,8 +443,12 @@ void BindingData::Update(const FunctionCallbackInfo& args) { Utf8Value new_value(isolate, args[2].As()); std::string_view new_value_view = new_value.ToStringView(); + // A serialized URL is not always reparsable: the IDNA encoder can emit a + // host label that the decoder rejects. Fail the update instead of crashing. auto out = ada::parse(input.ToStringView()); - CHECK(out); + if (!out) { + return args.GetReturnValue().Set(false); + } bool result{true}; diff --git a/test/parallel/test-whatwg-url-custom-setters.js b/test/parallel/test-whatwg-url-custom-setters.js index b98bf5d8d3b3..061ccf6f4480 100644 --- a/test/parallel/test-whatwg-url-custom-setters.js +++ b/test/parallel/test-whatwg-url-custom-setters.js @@ -39,6 +39,32 @@ const additionalTestCases = } } +// The parser can produce a serialization it rejects when parsing it back: a +// Unicode host encodes to an `xn--xn--` label that the punycode decoder turns +// down. Setters reparse `href`, so the failure must not take the process down. +// Implementations backed by ICU accept that label, and ada does too as of +// https://github.com/ada-url/idna/pull/72, so this URL round-trips once that +// lands here and the setters below apply as usual. +test(function() { + const url = new URL('http:\u{1F600}xn-'); + const setters = { + hostname: 'example.com', + host: 'example.com:8080', + protocol: 'https:', + pathname: '/path', + search: '?search', + hash: '#hash', + port: '8080', + username: 'username', + password: 'password', + }; + + for (const [property, value] of Object.entries(setters)) { + url[property] = value; + assert_equals(typeof url.href, 'string', `Setting ${property} does not crash`); + } +}, 'URL: setting properties with an unparsable serialized URL'); + { const url = new URL('http://example.com/'); const obj = { From 0cf79931bb6dfad487619453e2ea0d62298573df Mon Sep 17 00:00:00 2001 From: kyungrae2002 <148605113+kyungrae2002@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:20:04 +0900 Subject: [PATCH 021/344] doc: document ArrayBuffer support in pbkd2Sync Signed-off-by: kyungrae PR-URL: https://github.com/nodejs/node/pull/64976 Refs: https://github.com/nodejs/node/pull/35093 Reviewed-By: Filip Skokan Reviewed-By: Tierney Cyren Reviewed-By: Luigi Pinca --- doc/api/crypto.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/doc/api/crypto.md b/doc/api/crypto.md index 1f2f6acd73e5..70292635c835 100644 --- a/doc/api/crypto.md +++ b/doc/api/crypto.md @@ -5229,6 +5229,10 @@ negative performance implications for some applications; see the -* `password` {string|Buffer|TypedArray|DataView} -* `salt` {string|Buffer|TypedArray|DataView} +* `password` {string|ArrayBuffer|Buffer|TypedArray|DataView} +* `salt` {string|ArrayBuffer|Buffer|TypedArray|DataView} * `iterations` {number} * `keylen` {number} * `digest` {string} From 4299cd5897266e89385cb2ab5e6eec482b1764a1 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Wed, 5 Aug 2026 06:57:32 -0700 Subject: [PATCH 022/344] util: add non-throwing MIMEType.parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Similar to `URL.parse(...)`, the `MIMEType.parse(...)` API will return `null` if the input cannot be parsed as opposed to throwing the way the constructor does. Signed-off-by: James M Snell PR-URL: https://github.com/nodejs/node/pull/64965 Reviewed-By: Aviv Keller Reviewed-By: Filip Skokan Reviewed-By: René --- doc/api/util.md | 12 ++++++++++++ lib/internal/data_url.js | 9 ++------- lib/internal/inspector/network.js | 13 +++---------- lib/internal/mime.js | 30 ++++++++++++++++++++++++------ test/parallel/test-mime-api.js | 14 ++++++++++++++ 5 files changed, 55 insertions(+), 23 deletions(-) diff --git a/doc/api/util.md b/doc/api/util.md index bf61e9f89322..e35e78982bb0 100644 --- a/doc/api/util.md +++ b/doc/api/util.md @@ -1896,6 +1896,18 @@ console.log(JSON.stringify(myMIMES)); // Prints: ["image/png", "image/gif"] ``` +### `MIMEType.parse(string)` + + + +* `string` {string} The input MIME to parse +* Returns: {MIMEType|null} + +Attempts to parse the given `string` as a MIMEType. If the string cannot be +parsed, `null` is returned. + ## Class: `util.MIMEParams` - -> Stability: 1 - Experimental. This flag is inherited from V8 and is subject to -> change upstream. - -This flag will expose the gc extension from V8. - -```js -if (globalThis.gc) { - globalThis.gc(); -} -``` - ### `--force-context-aware` -Enable FIPS-compliant crypto at startup. (Requires Node.js to be built -against FIPS-compatible OpenSSL.) +Enable [FIPS mode][] at startup. With OpenSSL 3, a configured provider named +`fips` must be available and initialize successfully. With OpenSSL 1.1.1, +Node.js must be built against a FIPS-capable OpenSSL. ### `--enable-source-maps` @@ -1561,8 +1562,8 @@ Disable loading native addons that are not [context-aware][]. added: v6.0.0 --> -Force FIPS-compliant crypto on startup. (Cannot be disabled from script code.) -(Same requirements as `--enable-fips`.) +Enable [FIPS mode][] at startup and prevent it from being disabled from script +code. The same OpenSSL requirements as [`--enable-fips`][] apply. ### `--force-node-api-uncaught-exceptions-policy` @@ -2257,9 +2258,11 @@ usually only useful for developers debugging Node.js itself. added: v6.9.0 --> -Load an OpenSSL configuration file on startup. Among other uses, this can be -used to enable FIPS-compliant crypto if Node.js is built -against FIPS-enabled OpenSSL. +Load an OpenSSL configuration file on startup. The file can activate an +OpenSSL 3 FIPS provider or configure a FIPS-capable OpenSSL 1.1.1 build. See +[FIPS mode][]. + +This option takes precedence over the `OPENSSL_CONF` environment variable. ### `--openssl-legacy-provider` @@ -4234,9 +4237,8 @@ environment variable is arbitrary. added: v6.11.0 --> -Load an OpenSSL configuration file on startup. Among other uses, this can be -used to enable FIPS-compliant crypto if Node.js is built with -`./configure --openssl-fips`. +Load an OpenSSL configuration file on startup. The file can be used as part of +a [FIPS mode][] configuration. If the [`--openssl-config`][] command-line option is used, the environment variable is ignored. @@ -4443,6 +4445,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12 [ECMAScript module]: esm.md#modules-ecmascript-modules [EventSource Web API]: https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events [ExperimentalWarning: `vm.measureMemory` is an experimental feature]: vm.md#vmmeasurememoryoptions +[FIPS mode]: crypto.md#fips-mode [File System Permissions]: permissions.md#file-system-permissions [Loading ECMAScript modules using `require()`]: modules.md#loading-ecmascript-modules-using-require [Module resolution and loading]: packages.md#module-resolution-and-loading @@ -4472,6 +4475,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12 [`--cpu-prof-dir`]: #--cpu-prof-dir [`--diagnostic-dir`]: #--diagnostic-dirdirectory [`--disable-sigusr1`]: #--disable-sigusr1 +[`--enable-fips`]: #--enable-fips [`--env-file-if-exists`]: #--env-file-if-existsfile [`--env-file`]: #--env-filefile [`--experimental-sea-config`]: single-executable-applications.md#1-generating-single-executable-preparation-blobs diff --git a/doc/api/crypto.md b/doc/api/crypto.md index 70292635c835..6811bfb40d91 100644 --- a/doc/api/crypto.md +++ b/doc/api/crypto.md @@ -4302,11 +4302,8 @@ deprecated: v10.0.0 > Stability: 0 - Deprecated -Property for checking and controlling whether a FIPS compliant crypto provider -is currently in use. Setting to true requires a FIPS build of Node.js. - -This property is deprecated. Please use `crypto.setFips()` and -`crypto.getFips()` instead. +Deprecated property for checking and controlling [FIPS mode][]. Use +[`crypto.getFips()`][] and [`crypto.setFips()`][] instead. ### `crypto.generateKey(type, options, callback)` @@ -4897,9 +4894,14 @@ console.log(aliceSecret === bobSecret); added: v10.0.0 --> -* Returns: {number} `1` if and only if a FIPS compliant crypto provider is - currently in use, `0` otherwise. A future semver-major release may change - the return type of this API to a {boolean}. +* Returns: {number} `1` if FIPS mode is enabled, `0` otherwise. A future + semver-major release may change the return type of this API to a {boolean}. + +With OpenSSL 3, this reports whether the default property query includes +`fips=yes`. It does not establish that a FIPS provider is loaded or validated. +It can return `1` even when a requested cryptographic implementation cannot be +fetched because no loaded provider supplies a match for `fips=yes`. See [FIPS +mode][]. ### `crypto.getHashes()` @@ -6171,10 +6173,33 @@ is a bit field taking one of or a mix of the following flags (defined in added: v10.0.0 --> -* `bool` {boolean} `true` to enable FIPS mode. +* `bool` {boolean} `true` to enable FIPS mode, `false` to disable it. + +Changes [FIPS mode][]. With OpenSSL 3, this only adds or removes `fips=yes` in +the default property query. It does not install, load, initialize, or validate +a FIPS provider. For a usable FIPS configuration, install the provider and +configure OpenSSL to load it when Node.js starts, as described in [FIPS +mode][]. + +If no loaded provider supplies a requested cryptographic implementation +matching `fips=yes`, the call can still succeed and `crypto.getFips()` can still +return `1`, but fetching that implementation fails. Affected `node:crypto` +operations typically fail with `ERR_OSSL_EVP_UNSUPPORTED`. Operations that do +not require a new fetch, including those using previously fetched +implementations or initialized operation contexts, may still succeed. Call this +method during application initialization, before application code uses other +OpenSSL-backed APIs. + +This method only affects subsequent algorithm fetches. Node.js initializes some +OpenSSL state before application code runs. When the property query must be +active from process startup, set `default_properties = fips=yes` in the OpenSSL +configuration or use [`--enable-fips`][] or [`--force-fips`][]. The command-line +flags additionally require a configured provider named `fips` to initialize and +pass its self-test; Node.js fails to start otherwise. -Enables the FIPS compliant crypto provider in a FIPS-enabled Node.js build. -Throws an error if FIPS mode is not available. +Throws an error if OpenSSL cannot change the state. FIPS mode cannot be +disabled when Node.js was started with `--force-fips`. With OpenSSL 1.1.1, +enabling FIPS mode requires a FIPS-capable OpenSSL build. ### `crypto.sign(algorithm, data, key[, callback])` @@ -6609,83 +6634,120 @@ console.log(receivedPlaintext); ### FIPS mode -When using OpenSSL 3, Node.js supports FIPS 140-2 when used with an appropriate -OpenSSL 3 provider, such as the [FIPS provider from OpenSSL 3][] which can be -installed by following the instructions in [OpenSSL's FIPS README file][]. +Node.js exposes the FIPS support provided by the linked OpenSSL library. Node.js +is not itself FIPS validated. Validation belongs to a specific OpenSSL module or +provider and only applies when it is deployed according to its security policy. +Vendor-provided Node.js or OpenSSL builds can require a different configuration; +follow the vendor's documentation for those builds. -For FIPS support in Node.js you will need: +With OpenSSL 1.1.1, Node.js must be built against a FIPS-capable OpenSSL library. + +With OpenSSL 3, FIPS support uses the provider model described in the +[OpenSSL FIPS module guide][]. Using FIPS-approved implementations requires: * A correctly installed OpenSSL 3 FIPS provider. * An OpenSSL 3 [FIPS module configuration file][]. -* An OpenSSL 3 configuration file that references the FIPS module - configuration file. +* The FIPS provider to be loaded into the OpenSSL library context used by + Node.js, normally by activating it in an OpenSSL configuration file when + Node.js starts. +* The default property query to include `fips=yes` when cryptographic + implementations are fetched. This can be set from process startup by the + OpenSSL configuration, [`--enable-fips`][], or [`--force-fips`][], or for + subsequent fetches by `crypto.setFips(true)`. -Node.js will need to be configured with an OpenSSL configuration file that -points to the FIPS provider. An example configuration file looks like this: +An example OpenSSL 3 configuration file looks like this: ```text nodejs_conf = nodejs_init +config_diagnostics = 1 .include //fipsmodule.cnf [nodejs_init] providers = provider_sect +alg_section = algorithm_sect [provider_sect] -default = default_sect # The fips section name should match the section name inside the # included fipsmodule.cnf. fips = fips_sect +base = base_sect -[default_sect] +[base_sect] activate = 1 -``` - -where `fipsmodule.cnf` is the FIPS module configuration file generated from the -FIPS provider installation step: -```bash -openssl fipsinstall +[algorithm_sect] +default_properties = fips=yes ``` -Set the `OPENSSL_CONF` environment variable to point to -your configuration file and `OPENSSL_MODULES` to the location of the FIPS -provider dynamic library. e.g. +The `fipsmodule.cnf` file is generated as part of the FIPS provider installation +and contains module integrity and self-test information. The exact command and +arguments are installation-specific; see [OpenSSL FIPS configuration][] and the +[OpenSSL FIPS module guide][]. The installation uses `openssl fipsinstall`. + +The example activates the provider and enables the `fips=yes` property query +when Node.js starts. To activate the provider at startup but enable the property +query later with `crypto.setFips(true)`, omit `alg_section = algorithm_sect` and +the `[algorithm_sect]` block. The provider must still be loaded; when using this +startup configuration, keep its activation enabled. `crypto.setFips(true)` +should be called before application code uses other OpenSSL-backed APIs. It is +not equivalent to enabling the property query from process startup because +Node.js initializes some OpenSSL state before application code runs. Use the +example as written, [`--enable-fips`][], or [`--force-fips`][] when the property +query must be active from process startup. + +`config_diagnostics` causes configuration errors to prevent startup instead of +being ignored. The `base` provider supplies non-cryptographic supporting +algorithms, such as encoders and decoders, that are commonly needed alongside +the FIPS provider. `default_properties = fips=yes` restricts OpenSSL's default +algorithm selection to implementations that match `fips=yes`. + +Set `OPENSSL_CONF` to the OpenSSL configuration file. For a dynamically loaded +provider, `OPENSSL_MODULES` can set the directory containing the provider module. +For example: ```bash export OPENSSL_CONF=//nodejs.cnf export OPENSSL_MODULES=//ossl-modules ``` -FIPS mode can then be enabled in Node.js either by: - -* Starting Node.js with `--enable-fips` or `--force-fips` command line flags. -* Programmatically calling `crypto.setFips(true)`. - -Optionally FIPS mode can be enabled in Node.js via the OpenSSL configuration -file. e.g. - -```text -nodejs_conf = nodejs_init - -.include //fipsmodule.cnf - -[nodejs_init] -providers = provider_sect -alg_section = algorithm_sect - -[provider_sect] -default = default_sect -# The fips section name should match the section name inside the -# included fipsmodule.cnf. -fips = fips_sect - -[default_sect] -activate = 1 - -[algorithm_sect] -default_properties = fips=yes -``` +The [`--openssl-config`][] command-line option selects the configuration file and +takes precedence over `OPENSSL_CONF`. If neither is set, OpenSSL's default +configuration file is used. + +By default, Node.js reads the `nodejs_conf` section instead of OpenSSL's usual +`openssl_conf` section. Use [`--openssl-shared-config`][] to read `openssl_conf`, +or build Node.js with `./configure --openssl-conf-name=` to change the +default section name. + +On OpenSSL 3, the configuration above enables the `fips=yes` property query at +startup. The following controls are also available: + +* [`--enable-fips`][] and [`--force-fips`][] enable the property query and + additionally require the configured provider named `fips` to initialize and + pass its self-test. Node.js exits if that check fails. `--force-fips` also + prevents FIPS mode from being disabled from script code. +* [`crypto.setFips()`][] changes the FIPS/property-query state. On OpenSSL 3, it + does not install, load, initialize, or validate a provider. Implementations + fetched before the call are not changed. +* [`crypto.getFips()`][] reports the FIPS/property-query state. On OpenSSL 3, a + return value of `1` does not prove that a FIPS provider is loaded or validated. + +With OpenSSL 1.1.1, these controls use the library's FIPS mode support and +require a FIPS-capable OpenSSL build. + +Only algorithms available under the active FIPS settings can be used. With +OpenSSL 3, if no loaded provider supplies a requested cryptographic +implementation matching `fips=yes`, fetching it fails, typically with +`ERR_OSSL_EVP_UNSUPPORTED`. The same error can occur for algorithms that +Node.js supports when FIPS mode is disabled but that are unavailable under the +active FIPS settings. + +OpenSSL documents that the same FIPS provider cannot be used by multiple copies +of `libcrypto` in one process. This can affect native addons that load another +copy of `libcrypto`; OpenSSL's documented workaround is to use a separate copy +of the provider for each `libcrypto` instance. See [OpenSSL FIPS provider +limitations][]. ## Crypto constants @@ -6968,15 +7030,17 @@ See the [list of SSL OP Flags][] for details. [CVE-2021-44532]: https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-44532 [Caveats]: #support-for-weak-or-compromised-algorithms [Crypto constants]: #crypto-constants -[FIPS module configuration file]: https://www.openssl.org/docs/man3.0/man5/fips_config.html -[FIPS provider from OpenSSL 3]: https://www.openssl.org/docs/man3.0/man7/crypto.html#FIPS-provider +[FIPS mode]: #fips-mode +[FIPS module configuration file]: https://docs.openssl.org/3.0/man5/fips_config/ [HTML 5.2]: https://www.w3.org/TR/html52/changes.html#features-removed [JWK]: https://tools.ietf.org/html/rfc7517 [Key usages]: webcrypto.md#cryptokeyusages [NIST SP 800-131A]: https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-131Ar2.pdf [NIST SP 800-132]: https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-132.pdf [NIST SP 800-38D]: https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf -[OpenSSL's FIPS README file]: https://github.com/openssl/openssl/blob/openssl-3.0/README-FIPS.md +[OpenSSL FIPS configuration]: https://docs.openssl.org/3.0/man5/fips_config/ +[OpenSSL FIPS module guide]: https://docs.openssl.org/master/man7/fips_module/ +[OpenSSL FIPS provider limitations]: https://docs.openssl.org/3.6/man7/OSSL_PROVIDER-FIPS/ [OpenSSL's SPKAC implementation]: https://www.openssl.org/docs/man3.0/man1/openssl-spkac.html [Permission Model]: permissions.md#permission-model [RFC 1421]: https://www.rfc-editor.org/rfc/rfc1421.txt @@ -6993,6 +7057,10 @@ See the [list of SSL OP Flags][] for details. [RFC 9562]: https://www.rfc-editor.org/rfc/rfc9562.txt [Web Crypto API documentation]: webcrypto.md [`--allow-openssl-store`]: cli.md#--allow-openssl-store +[`--enable-fips`]: cli.md#--enable-fips +[`--force-fips`]: cli.md#--force-fips +[`--openssl-config`]: cli.md#--openssl-configfile +[`--openssl-shared-config`]: cli.md#--openssl-shared-config [`BN_is_prime_ex`]: https://www.openssl.org/docs/man1.1.1/man3/BN_is_prime_ex.html [`Buffer`]: buffer.md [`DH_generate_key()`]: https://www.openssl.org/docs/man3.0/man3/DH_generate_key.html @@ -7019,6 +7087,7 @@ See the [list of SSL OP Flags][] for details. [`crypto.generateKeyPair()`]: #cryptogeneratekeypairtype-options-callback [`crypto.getCurves()`]: #cryptogetcurves [`crypto.getDiffieHellman()`]: #cryptogetdiffiehellmangroupname +[`crypto.getFips()`]: #cryptogetfips [`crypto.getHashes()`]: #cryptogethashes [`crypto.hash()`]: #cryptohashalgorithm-data-options [`crypto.privateDecrypt()`]: #cryptoprivatedecryptprivatekey-buffer @@ -7027,6 +7096,7 @@ See the [list of SSL OP Flags][] for details. [`crypto.publicEncrypt()`]: #cryptopublicencryptkey-buffer [`crypto.randomBytes()`]: #cryptorandombytessize-callback [`crypto.randomFill()`]: #cryptorandomfillbuffer-offset-size-callback +[`crypto.setFips()`]: #cryptosetfipsbool [`crypto.sign()`]: #cryptosignalgorithm-data-key-callback [`crypto.verify()`]: #cryptoverifyalgorithm-data-key-signature-callback [`crypto.webcrypto.getRandomValues()`]: webcrypto.md#cryptogetrandomvaluestypedarray diff --git a/doc/node.1 b/doc/node.1 index b7e4d0a429a2..d42424224c44 100644 --- a/doc/node.1 +++ b/doc/node.1 @@ -513,8 +513,9 @@ The default is \fBverbatim\fR and \fBdns.setDefaultResultOrder()\fR have higher priority than \fB--dns-result-order\fR. . .It Fl -enable-fips -Enable FIPS-compliant crypto at startup. (Requires Node.js to be built -against FIPS-compatible OpenSSL.) +Enable FIPS mode at startup. With OpenSSL 3, a configured provider named +\fBfips\fR must be available and initialize successfully. With OpenSSL 1.1.1, +Node.js must be built against a FIPS-capable OpenSSL. . .It Fl -enable-source-maps Enable Source Map support for stack traces. @@ -842,8 +843,8 @@ if (globalThis.gc) { Disable loading native addons that are not context-aware. . .It Fl -force-fips -Force FIPS-compliant crypto on startup. (Cannot be disabled from script code.) -(Same requirements as \fB--enable-fips\fR.) +Enable FIPS mode at startup and prevent it from being disabled from script +code. The same OpenSSL requirements as \fB--enable-fips\fR apply. . .It Fl -force-node-api-uncaught-exceptions-policy Enforces \fBuncaughtException\fR event on Node-API asynchronous callbacks. @@ -1146,9 +1147,10 @@ Enable extra debug checks for memory leaks in Node.js internals. This is usually only useful for developers debugging Node.js itself. . .It Fl -openssl-config Ns = Ns Ar file -Load an OpenSSL configuration file on startup. Among other uses, this can be -used to enable FIPS-compliant crypto if Node.js is built -against FIPS-enabled OpenSSL. +Load an OpenSSL configuration file on startup. The file can activate an +OpenSSL 3 FIPS provider or configure a FIPS-capable OpenSSL 1.1.1 build. See +FIPS mode. +This option takes precedence over the \fBOPENSSL_CONF\fR environment variable. . .It Fl -openssl-legacy-provider Enable OpenSSL 3.0 legacy provider. For more information please see @@ -2378,9 +2380,8 @@ propagation. environment variable is arbitrary. . .It Ev OPENSSL_CONF Ar file -Load an OpenSSL configuration file on startup. Among other uses, this can be -used to enable FIPS-compliant crypto if Node.js is built with -\fB./configure --openssl-fips\fR. +Load an OpenSSL configuration file on startup. The file can be used as part of +a FIPS mode configuration. If the \fB--openssl-config\fR command-line option is used, the environment variable is ignored. . From f48927fe5b2c8613ec451a4a3bce979051602c43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=ED=98=9C=EB=AF=B8?= <103042868+hyemimi@users.noreply.github.com> Date: Thu, 6 Aug 2026 05:03:09 +0900 Subject: [PATCH 031/344] esm: fix wasm import name in error message Report the rejected import name instead of the import module when throwing for reserved Wasm import names. Signed-off-by: hyemimi PR-URL: https://github.com/nodejs/node/pull/64950 Reviewed-By: James M Snell Reviewed-By: Guy Bedford --- lib/internal/modules/esm/translators.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/internal/modules/esm/translators.js b/lib/internal/modules/esm/translators.js index c8eb2d857a3d..ad3de25bf6d5 100644 --- a/lib/internal/modules/esm/translators.js +++ b/lib/internal/modules/esm/translators.js @@ -581,7 +581,7 @@ translators.set('wasm', function(url, translateContext) { throw new WebAssembly.LinkError(`Invalid Wasm import "${impt.module}" in ${url}`); } if (impt.name.startsWith('wasm:') || impt.name.startsWith('wasm-js:')) { - throw new WebAssembly.LinkError(`Invalid Wasm import name "${impt.module}" in ${url}`); + throw new WebAssembly.LinkError(`Invalid Wasm import name "${impt.name}" in ${url}`); } importsList.add(impt.module); } From 0450ab6c6a9d5582ada0ee0e495db9f8b326fc6d Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Wed, 5 Aug 2026 22:04:24 +0200 Subject: [PATCH 032/344] doc: update `node.1` to fix linter Signed-off-by: Antoine du Hamel PR-URL: https://github.com/nodejs/node/pull/65053 Refs: https://github.com/nodejs/node/pull/58909 Reviewed-By: Richard Lau Reviewed-By: Yagiz Nizipli Reviewed-By: Filip Skokan --- doc/node.1 | 8 -------- 1 file changed, 8 deletions(-) diff --git a/doc/node.1 b/doc/node.1 index d42424224c44..5d7ce00a6f7b 100644 --- a/doc/node.1 +++ b/doc/node.1 @@ -831,14 +831,6 @@ Enable experimental WebAssembly System Interface (WASI) support. .It Fl -experimental-worker-inspection Enable experimental support for the worker inspection with Chrome DevTools. . -.It Fl -expose-gc -This flag will expose the gc extension from V8. -.Bd -literal -if (globalThis.gc) { - globalThis.gc(); -} -.Ed -. .It Fl -force-context-aware Disable loading native addons that are not context-aware. . From 9167ebd32b6c92e10be01e8f9068aa75a0d1074e Mon Sep 17 00:00:00 2001 From: mike-git374 <217764531+mike-git374@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:43:40 -0600 Subject: [PATCH 033/344] sqlite: bind ArrayBuffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-URL: https://github.com/nodejs/node/pull/62061 Fixes: https://github.com/nodejs/node/issues/61396 Reviewed-By: René Reviewed-By: Edy Silva --- src/node_sqlite.cc | 9 ++- .../test-sqlite-typed-array-and-data-view.js | 69 +++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/node_sqlite.cc b/src/node_sqlite.cc index 038af9812f9e..3ff6c274ddbb 100644 --- a/src/node_sqlite.cc +++ b/src/node_sqlite.cc @@ -235,7 +235,8 @@ void JSValueToSQLiteResult(Isolate* isolate, } else if (value->IsString()) { Utf8Value val(isolate, value.As()); sqlite3_result_text(ctx, *val, val.length(), SQLITE_TRANSIENT); - } else if (value->IsArrayBufferView()) { + } else if (value->IsArrayBufferView() || value->IsArrayBuffer() || + value->IsSharedArrayBuffer()) { ArrayBufferViewContents buf(value); sqlite3_result_blob(ctx, buf.data(), buf.length(), SQLITE_TRANSIENT); } else if (value->IsBigInt()) { @@ -2665,7 +2666,8 @@ bool StatementSync::BindParams(const FunctionCallbackInfo& args) { int anon_idx = 1; int anon_start = 0; - if (args[0]->IsObject() && !args[0]->IsArrayBufferView()) { + if (args[0]->IsObject() && !args[0]->IsArrayBufferView() && + !args[0]->IsArrayBuffer() && !args[0]->IsSharedArrayBuffer()) { Local obj = args[0].As(); Local context = Isolate::GetCurrent()->GetCurrentContext(); Local keys; @@ -2791,7 +2793,8 @@ bool StatementSync::BindValue(const Local& value, const int index) { } } else if (value->IsNull()) { r = sqlite3_bind_null(statement_, index); - } else if (value->IsArrayBufferView()) { + } else if (value->IsArrayBufferView() || value->IsArrayBuffer() || + value->IsSharedArrayBuffer()) { ArrayBufferViewContents buf(value); r = sqlite3_bind_blob64(statement_, index, diff --git a/test/parallel/test-sqlite-typed-array-and-data-view.js b/test/parallel/test-sqlite-typed-array-and-data-view.js index 2d5269be09b7..5236c182e061 100644 --- a/test/parallel/test-sqlite-typed-array-and-data-view.js +++ b/test/parallel/test-sqlite-typed-array-and-data-view.js @@ -5,6 +5,10 @@ const { DatabaseSync } = require('node:sqlite'); const { suite, test } = require('node:test'); const arrayBuffer = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]).buffer; +const sharedArrayBuffer = new SharedArrayBuffer(8); +const typedArrayOnSharedArrayBuffer = new Uint8Array(sharedArrayBuffer); +typedArrayOnSharedArrayBuffer.set([1, 2, 3, 4, 5, 6, 7, 8]); + const TypedArrays = [ ['Int8Array', Int8Array], ['Uint8Array', Uint8Array], @@ -51,3 +55,68 @@ suite('StatementSync with TypedArray/DataView', () => { }); } }); + +suite('StatementSync with ArrayBuffer and SharedArrayBuffer', () => { + const buffers = [ + ['ArrayBuffer', arrayBuffer], + ['SharedArrayBuffer', sharedArrayBuffer], + ]; + + for (const [displayName, buffer] of buffers) { + test(`${displayName} - anonymous binding`, (t) => { + const db = new DatabaseSync(':memory:'); + t.after(() => { db.close(); }); + db.exec('CREATE TABLE test (data BLOB)'); + // insert + { + const stmt = db.prepare('INSERT INTO test VALUES (?)'); + stmt.run(buffer); + } + // select all + { + const stmt = db.prepare('SELECT * FROM test'); + const row = stmt.get(); + t.assert.ok(row.data instanceof Uint8Array); + t.assert.strictEqual(row.data.length, 8); + t.assert.deepStrictEqual(row.data, new Uint8Array(arrayBuffer)); + } + // query + { + const stmt = db.prepare('SELECT * FROM test WHERE data = ?'); + const rows = stmt.all(buffer); + t.assert.strictEqual(rows.length, 1); + t.assert.ok(rows[0].data instanceof Uint8Array); + t.assert.strictEqual(rows[0].data.length, 8); + t.assert.deepStrictEqual(rows[0].data, new Uint8Array(arrayBuffer)); + } + }); + + test(`${displayName} - named binding (object)`, (t) => { + const db = new DatabaseSync(':memory:'); + t.after(() => { db.close(); }); + db.exec('CREATE TABLE test (data BLOB)'); + // insert + { + const stmt = db.prepare('INSERT INTO test VALUES ($data)'); + stmt.run({ '$data': buffer }); + } + // select all + { + const stmt = db.prepare('SELECT * FROM test'); + const row = stmt.get(); + t.assert.ok(row.data instanceof Uint8Array); + t.assert.strictEqual(row.data.length, 8); + t.assert.deepStrictEqual(row.data, new Uint8Array(arrayBuffer)); + } + // query + { + const stmt = db.prepare('SELECT * FROM test WHERE data = $data'); + const rows = stmt.all({ '$data': buffer }); + t.assert.strictEqual(rows.length, 1); + t.assert.ok(rows[0].data instanceof Uint8Array); + t.assert.strictEqual(rows[0].data.length, 8); + t.assert.deepStrictEqual(rows[0].data, new Uint8Array(arrayBuffer)); + } + }); + } +}); From 7aff78d23149d1626ad7f789174d0ac3c78e37c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9?= Date: Wed, 5 Aug 2026 22:04:26 +0100 Subject: [PATCH 034/344] meta: update sccache to 0.17.0 Signed-off-by: Renegade334 PR-URL: https://github.com/nodejs/node/pull/64985 Reviewed-By: Aviv Keller Reviewed-By: Trivikram Kamat Reviewed-By: Colin Ihrig --- .github/workflows/build-tarball.yml | 2 +- .github/workflows/coverage-linux-without-intl.yml | 2 +- .github/workflows/coverage-linux.yml | 2 +- .github/workflows/stress-test.yml | 2 +- .github/workflows/test-internet.yml | 2 +- .github/workflows/test-linux-quic.yml | 2 +- .github/workflows/test-linux.yml | 2 +- .github/workflows/test-macos.yml | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-tarball.yml b/.github/workflows/build-tarball.yml index 1e1febe22c7f..ab0698e11b74 100644 --- a/.github/workflows/build-tarball.yml +++ b/.github/workflows/build-tarball.yml @@ -120,7 +120,7 @@ jobs: if: github.base_ref == 'main' || github.ref_name == 'main' uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 with: - version: v0.16.0 + version: v0.17.0 - name: Download tarball uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: diff --git a/.github/workflows/coverage-linux-without-intl.yml b/.github/workflows/coverage-linux-without-intl.yml index 1519ef6592e8..92c9f3b88217 100644 --- a/.github/workflows/coverage-linux-without-intl.yml +++ b/.github/workflows/coverage-linux-without-intl.yml @@ -66,7 +66,7 @@ jobs: if: github.base_ref == 'main' || github.ref_name == 'main' uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 with: - version: v0.16.0 + version: v0.17.0 - name: Install gcovr run: pip install gcovr==7.2 - name: Configure diff --git a/.github/workflows/coverage-linux.yml b/.github/workflows/coverage-linux.yml index e4a3c334c8cd..e97b759bc6a4 100644 --- a/.github/workflows/coverage-linux.yml +++ b/.github/workflows/coverage-linux.yml @@ -66,7 +66,7 @@ jobs: if: github.base_ref == 'main' || github.ref_name == 'main' uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 with: - version: v0.16.0 + version: v0.17.0 - name: Install gcovr run: pip install gcovr==7.2 - name: Configure diff --git a/.github/workflows/stress-test.yml b/.github/workflows/stress-test.yml index b6fa42137d5e..6f1e75813915 100644 --- a/.github/workflows/stress-test.yml +++ b/.github/workflows/stress-test.yml @@ -78,7 +78,7 @@ jobs: - name: Set up sccache uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 with: - version: v0.16.0 + version: v0.17.0 # This is needed due to https://github.com/nodejs/build/issues/3878 - name: Cleanup if: runner.os == 'macOS' diff --git a/.github/workflows/test-internet.yml b/.github/workflows/test-internet.yml index bcb9ff76372f..7052f014b200 100644 --- a/.github/workflows/test-internet.yml +++ b/.github/workflows/test-internet.yml @@ -63,7 +63,7 @@ jobs: if: github.base_ref == 'main' || github.ref_name == 'main' uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 with: - version: v0.16.0 + version: v0.17.0 - name: Build run: make build-ci -j4 V=1 CONFIG_FLAGS="--error-on-warn" - name: Test Internet diff --git a/.github/workflows/test-linux-quic.yml b/.github/workflows/test-linux-quic.yml index 38d2ef9b8407..e1a05cf91859 100644 --- a/.github/workflows/test-linux-quic.yml +++ b/.github/workflows/test-linux-quic.yml @@ -68,7 +68,7 @@ jobs: if: github.base_ref == 'main' || github.ref_name == 'main' uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 with: - version: v0.16.0 + version: v0.17.0 - name: Build working-directory: node run: make build-ci -j4 V=1 CONFIG_FLAGS="--error-on-warn --v8-enable-temporal-support --experimental-quic" diff --git a/.github/workflows/test-linux.yml b/.github/workflows/test-linux.yml index c38bae0693fd..40762503f685 100644 --- a/.github/workflows/test-linux.yml +++ b/.github/workflows/test-linux.yml @@ -79,7 +79,7 @@ jobs: if: github.base_ref == 'main' || github.ref_name == 'main' uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 with: - version: v0.16.0 + version: v0.17.0 - name: Build working-directory: node run: make build-ci -j4 V=1 CONFIG_FLAGS="--error-on-warn --v8-enable-temporal-support" diff --git a/.github/workflows/test-macos.yml b/.github/workflows/test-macos.yml index 173f6758ad71..e87e83505d6d 100644 --- a/.github/workflows/test-macos.yml +++ b/.github/workflows/test-macos.yml @@ -102,7 +102,7 @@ jobs: if: github.base_ref == 'main' || github.ref_name == 'main' uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 with: - version: v0.16.0 + version: v0.17.0 # The `npm ci` for this step fails a lot as part of the Test step. Run it # now so that we don't have to wait 2 hours for the Build step to pass # first before that failure happens. (And if there's something about From 7c61b08aedfbb90c209a2181e0a85a2afd276c5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Wed, 1 Jul 2026 11:18:34 -0300 Subject: [PATCH 035/344] sqlite: add StatementSync.prototype[Symbol.dispose]() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This extends explicit resource management support to prepared statements, allowing a StatementSync to be deterministically finalized via a `using` declaration, mirroring the existing DatabaseSync and Session dispose methods. Signed-off-by: Guilherme Araújo PR-URL: https://github.com/nodejs/node/pull/64232 Reviewed-By: René --- doc/api/sqlite.md | 9 +++++ src/node_sqlite.cc | 16 ++++++-- src/node_sqlite.h | 2 + test/parallel/test-sqlite-statement-sync.js | 43 +++++++++++++++++++++ 4 files changed, 66 insertions(+), 4 deletions(-) diff --git a/doc/api/sqlite.md b/doc/api/sqlite.md index 483212a8b4ed..25e828c5e389 100644 --- a/doc/api/sqlite.md +++ b/doc/api/sqlite.md @@ -1194,6 +1194,15 @@ added: v22.5.0 The source SQL text of the prepared statement. This property is a wrapper around [`sqlite3_sql()`][]. +### `statement[Symbol.dispose]()` + + + +Finalizes the prepared statement. If the prepared statement is already +finalized, then this is a no-op. + ## Class: `SQLTagStore` + +Finalizes the prepared statement. An exception is thrown if the statement is +already finalized. This method is a wrapper around [`sqlite3_finalize()`][]. + ### `statement.columns()` * `size` {integer} The desired length of the new `Buffer`. +* `alignment` {integer} If given, the memory backing the new `Buffer` will start + at an address that is a multiple of `alignment`. Must be a power of two no + larger than `2 ** 30`. See [Aligned allocations][]. * Returns: {Buffer} Allocates a new `Buffer` of `size` bytes. If `size` is larger than @@ -865,11 +871,14 @@ pool, while `Buffer.allocUnsafe(size).fill(fill)` _will_ use the internal difference is subtle but can be important when an application requires the additional performance that [`Buffer.allocUnsafe()`][] provides. -### Static method: `Buffer.allocUnsafeSlow(size)` +### Static method: `Buffer.allocUnsafeSlow(size[, alignment])` * `size` {integer} The desired length of the new `Buffer`. +* `alignment` {integer} If given, the memory backing the new `Buffer` will start + at an address that is a multiple of `alignment`. Must be a power of two no + larger than `2 ** 30`. See [Aligned allocations][]. * Returns: {Buffer} Allocates a new `Buffer` of `size` bytes. If `size` is larger than @@ -5608,16 +5620,92 @@ While there are clear performance advantages to using [`Buffer.allocUnsafe()`][], extra care _must_ be taken in order to avoid introducing security vulnerabilities into an application. +### Aligned allocations + +Some operating system interfaces require the memory they operate on to be +aligned, and on some hardware alignment is merely faster. The most common +example of the former is unbuffered ("direct") file I/O, which on Linux requires +the buffer address, the file offset and the transfer length to all be multiples +of the logical block size of the underlying device: + +```mjs +import { open } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { Buffer } from 'node:buffer'; + +const blockSize = 4096; + +// The buffer address must be block-aligned for O_DIRECT to accept it. +const buf = Buffer.allocUnsafeSlow(blockSize, blockSize); + +const file = await open('/dev/sda', constants.O_RDONLY | constants.O_DIRECT); +try { + await file.read(buf, 0, blockSize, 0); +} finally { + await file.close(); +} +``` + +```cjs +const fs = require('node:fs'); +const { Buffer } = require('node:buffer'); + +const blockSize = 4096; + +// The buffer address must be block-aligned for O_DIRECT to accept it. +const buf = Buffer.allocUnsafeSlow(blockSize, blockSize); + +const flags = fs.constants.O_RDONLY | fs.constants.O_DIRECT; +fs.open('/dev/sda', flags, (err, fd) => { + if (err) throw err; + fs.read(fd, buf, 0, blockSize, 0, (err) => { + fs.close(fd, () => {}); + if (err) throw err; + }); +}); +``` + +Alignment can also be worth requesting purely for performance, even when no +interface demands it. Aligning a hot `Buffer` to the cache line size (64 bytes on +most contemporary CPUs) keeps it from straddling one more cache line than it +needs to, so that a small structure is fetched with one cache miss instead of +two, and page-aligned (4096 bytes) allocations similarly help interfaces that map +or pin memory. These are micro-optimizations: measure before reaching for them, +since the extra bytes are not free. + +Because the address of a `Buffer`'s memory cannot be chosen directly, extra bytes +have to be allocated or skipped to reach an aligned address. +[`Buffer.allocUnsafeSlow()`][] over-allocates up to `alignment - 1` bytes and +positions the returned `Buffer` at the first suitably aligned byte within them. +[`Buffer.allocUnsafe()`][] instead pads its offset into the shared internal pool, +whose start is always aligned to 64 bytes, and only falls back to an allocation +of its own when `alignment` is larger than that. Either way, +[`buf.byteOffset`][] is usually not 0 and [`buf.buffer`][] is larger than `size`, +so code that reaches past the `Buffer` into its underlying `ArrayBuffer` must +take the offset into account, as it must for pooled `Buffer`s. + +The alignment is a property of the returned `Buffer` and is preserved for its +whole lifetime, but it is not inherited by other views: [`buf.subarray`][], +[`buf.slice()`][] and `structuredClone()` may all produce unaligned `Buffer`s. + +Alignment also does not survive being captured in a startup snapshot: memory does +not keep its address across serialization, so a `Buffer` allocated while +[`--build-snapshot`][] is in effect is not aligned in the deserialized process. +Allocate inside a [`v8.startupSnapshot.setDeserializeMainFunction()`][] callback, +or after startup, if the alignment has to hold at run time. + [ASCII]: https://en.wikipedia.org/wiki/ASCII +[Aligned allocations]: #aligned-allocations [Base64]: https://en.wikipedia.org/wiki/Base64 [ISO-8859-1]: https://en.wikipedia.org/wiki/ISO-8859-1 [RFC 4648, Section 5]: https://tools.ietf.org/html/rfc4648#section-5 [UTF-16]: https://en.wikipedia.org/wiki/UTF-16 [UTF-8]: https://en.wikipedia.org/wiki/UTF-8 [WHATWG Encoding Standard]: https://encoding.spec.whatwg.org/ +[`--build-snapshot`]: cli.md#--build-snapshot [`Buffer.alloc()`]: #static-method-bufferallocsize-fill-encoding -[`Buffer.allocUnsafe()`]: #static-method-bufferallocunsafesize -[`Buffer.allocUnsafeSlow()`]: #static-method-bufferallocunsafeslowsize +[`Buffer.allocUnsafe()`]: #static-method-bufferallocunsafesize-alignment +[`Buffer.allocUnsafeSlow()`]: #static-method-bufferallocunsafeslowsize-alignment [`Buffer.concat()`]: #static-method-bufferconcatlist-totallength [`Buffer.copyBytesFrom()`]: #static-method-buffercopybytesfromview-offset-length [`Buffer.from(array)`]: #static-method-bufferfromarray @@ -5639,6 +5727,7 @@ introducing security vulnerabilities into an application. [`TypedArray.prototype.subarray()`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/subarray [`blob.stream()`]: #blobstream [`buf.buffer`]: #bufbuffer +[`buf.byteOffset`]: #bufbyteoffset [`buf.compare()`]: #bufcomparetarget-targetstart-targetend-sourcestart-sourceend [`buf.entries()`]: #bufentries [`buf.fill()`]: #buffillvalue-offset-end-encoding @@ -5653,6 +5742,7 @@ introducing security vulnerabilities into an application. [`buffer.constants.MAX_STRING_LENGTH`]: #bufferconstantsmax_string_length [`buffer.kMaxLength`]: #bufferkmaxlength [`util.inspect()`]: util.md#utilinspectobject-options +[`v8.startupSnapshot.setDeserializeMainFunction()`]: v8.md#v8startupsnapshotsetdeserializemainfunctioncallback-data [`v8::Uint8Array::kMaxLength`]: https://v8.github.io/api/head/classv8_1_1Uint8Array.html#a7677e3d0c9c92e4d40bef7212f5980c6 [base64url]: https://tools.ietf.org/html/rfc4648#section-5 [endianness]: https://en.wikipedia.org/wiki/Endianness diff --git a/doc/api/deprecations.md b/doc/api/deprecations.md index 334b94f6bb8f..9b18c4ed486f 100644 --- a/doc/api/deprecations.md +++ b/doc/api/deprecations.md @@ -4624,7 +4624,7 @@ will throw an error in a future version. [`--pending-deprecation`]: cli.md#--pending-deprecation [`--throw-deprecation`]: cli.md#--throw-deprecation [`--unhandled-rejections`]: cli.md#--unhandled-rejectionsmode -[`Buffer.allocUnsafeSlow(size)`]: buffer.md#static-method-bufferallocunsafeslowsize +[`Buffer.allocUnsafeSlow(size)`]: buffer.md#static-method-bufferallocunsafeslowsize-alignment [`Buffer.from(array)`]: buffer.md#static-method-bufferfromarray [`Buffer.from(buffer)`]: buffer.md#static-method-bufferfrombuffer [`Buffer.isBuffer()`]: buffer.md#static-method-bufferisbufferobj @@ -4770,7 +4770,7 @@ will throw an error in a future version. [`writable.writableLength`]: stream.md#writablewritablelength [`zlib.bytesWritten`]: zlib.md#zlibbyteswritten [alloc]: buffer.md#static-method-bufferallocsize-fill-encoding -[alloc_unsafe_size]: buffer.md#static-method-bufferallocunsafesize +[alloc_unsafe_size]: buffer.md#static-method-bufferallocunsafesize-alignment [caveats of asynchronous customization hooks]: module.md#caveats-of-asynchronous-customization-hooks [from_arraybuffer]: buffer.md#static-method-bufferfromarraybuffer-byteoffset-length [from_string_encoding]: buffer.md#static-method-bufferfromstring-encoding diff --git a/doc/api/worker_threads.md b/doc/api/worker_threads.md index e137722674b3..d812bce8fd05 100644 --- a/doc/api/worker_threads.md +++ b/doc/api/worker_threads.md @@ -2234,7 +2234,7 @@ thread spawned will spawn another until the application crashes. [`--max-old-space-size`]: cli.md#--max-old-space-sizesize-in-mib [`--max-semi-space-size`]: cli.md#--max-semi-space-sizesize-in-mib [`AsyncResource`]: async_hooks.md#class-asyncresource -[`Buffer.allocUnsafe()`]: buffer.md#static-method-bufferallocunsafesize +[`Buffer.allocUnsafe()`]: buffer.md#static-method-bufferallocunsafesize-alignment [`ERR_MISSING_MESSAGE_PORT_IN_TRANSFER_LIST`]: errors.md#err_missing_message_port_in_transfer_list [`ERR_WORKER_MESSAGING_ERRORED`]: errors.md#err_worker_messaging_errored [`ERR_WORKER_MESSAGING_FAILED`]: errors.md#err_worker_messaging_failed diff --git a/lib/buffer.js b/lib/buffer.js index cc213f12dacf..19574064ad12 100644 --- a/lib/buffer.js +++ b/lib/buffer.js @@ -140,6 +140,7 @@ const { markAsUntransferable, addBufferPrototypeMethods, createUnsafeBuffer, + createUnsafeAlignedBuffer, asciiWrite, latin1Write, utf8Write, @@ -171,13 +172,27 @@ const constants = ObjectDefineProperties({}, { }, }); +// The largest alignment accepted by `Buffer.allocUnsafeSlow()`. Any plausible +// I/O alignment requirement (logical block size, memory page size, huge page +// size) is well below this. +const kMaxAlignment = 2 ** 30; + +// Slices handed out of the pool are 8 byte aligned relative to the start of the +// pool, so aligning the pool itself to a cache line keeps them from straddling +// one more cache line than their size requires. +const kPoolAlignment = 64; + Buffer.poolSize = 64 * 1024; -let poolSize, poolOffset, allocPool, allocBuffer; +// `poolOffset` is relative to `poolBase`, which is where the pool starts inside +// `allocPool`. The pool is over-allocated to be able to align it, so `poolBase` +// is not necessarily 0. +let poolSize, poolOffset, poolBase, allocPool, allocBuffer; function createPool() { poolSize = Buffer.poolSize; - allocBuffer = createUnsafeBuffer(poolSize); + allocBuffer = createUnsafeAlignedBuffer(poolSize, kPoolAlignment); allocPool = allocBuffer.buffer; + poolBase = TypedArrayPrototypeGetByteOffset(allocBuffer); markAsUntransferable(allocPool); poolOffset = 0; } @@ -444,25 +459,58 @@ Buffer.alloc = function alloc(size, fill, encoding) { /** * Equivalent to Buffer(num), by default creates a non-zero-filled Buffer * instance. If `--zero-fill-buffers` is set, will zero-fill the buffer. + * + * If `alignment` is given, the memory backing the returned buffer starts at an + * address that is a multiple of `alignment`. See `Buffer.allocUnsafeSlow()`. + * @param {number} size + * @param {number} [alignment] A power of two, at most 2 ** 30 * @returns {FastBuffer} */ -Buffer.allocUnsafe = function allocUnsafe(size) { +Buffer.allocUnsafe = function allocUnsafe(size, alignment) { validateNumber(size, 'size', 0, kMaxLength); - return allocate(size); + if (alignment === undefined) { + return allocate(size); + } + validateAlignment(size, alignment); + return allocateAligned(size, alignment); }; /** * By default creates a non-zero-filled Buffer instance that is not allocated * off the pre-initialized pool. If `--zero-fill-buffers` is set, will zero-fill * the buffer. + * + * If `alignment` is given, the memory backing the returned buffer starts at an + * address that is a multiple of `alignment`, which is required by e.g. reads + * and writes on file descriptors opened with `O_DIRECT`. Note that up to + * `alignment - 1` extra bytes are allocated to satisfy the request, and that + * the returned buffer's `byteOffset` is therefore usually non-zero. * @param {number} size - * @returns {FastBuffer|undefined} + * @param {number} [alignment] A power of two, at most 2 ** 30 + * @returns {FastBuffer} */ -Buffer.allocUnsafeSlow = function allocUnsafeSlow(size) { +Buffer.allocUnsafeSlow = function allocUnsafeSlow(size, alignment) { validateNumber(size, 'size', 0, kMaxLength); - return createUnsafeBuffer(size); + if (alignment === undefined) { + return createUnsafeBuffer(size); + } + validateAlignment(size, alignment); + return createUnsafeAlignedBuffer(size, alignment); }; +function validateAlignment(size, alignment) { + validateInteger(alignment, 'alignment', 1, kMaxAlignment); + if ((alignment & (alignment - 1)) !== 0) { + throw new ERR_INVALID_ARG_VALUE( + 'alignment', alignment, 'must be a power of two'); + } + // Satisfying the alignment costs up to `alignment - 1` extra bytes. + if (size > kMaxLength - (alignment - 1)) { + throw new ERR_OUT_OF_RANGE( + 'size', `<= ${kMaxLength - (alignment - 1)}`, size); + } +} + function allocate(size) { if (size <= 0) { return new FastBuffer(); @@ -470,7 +518,7 @@ function allocate(size) { if (size < (Buffer.poolSize >>> 1)) { if (size > (poolSize - poolOffset)) createPool(); - const b = new FastBuffer(allocPool, poolOffset, size); + const b = new FastBuffer(allocPool, poolBase + poolOffset, size); poolOffset += size; alignPool(); return b; @@ -478,6 +526,25 @@ function allocate(size) { return createUnsafeBuffer(size); } +function allocateAligned(size, alignment) { + if (size <= 0) { + return new FastBuffer(); + } + // The pool starts at a `kPoolAlignment` aligned address, so any alignment up + // to that can be satisfied by padding the offset into the pool. Stricter + // alignments need an allocation of their own. + if (alignment > kPoolAlignment || size >= (Buffer.poolSize >>> 1)) { + return createUnsafeAlignedBuffer(size, alignment); + } + poolOffset = (poolOffset + alignment - 1) & ~(alignment - 1); + if (size > (poolSize - poolOffset)) + createPool(); + const b = new FastBuffer(allocPool, poolBase + poolOffset, size); + poolOffset += size; + alignPool(); + return b; +} + function fromStringFast(string, ops) { const maxLength = Buffer.poolSize >>> 1; @@ -498,7 +565,7 @@ function fromStringFast(string, ops) { createPool(); const actual = ops.write(allocBuffer, string, poolOffset, length); - const b = new FastBuffer(allocPool, poolOffset, actual); + const b = new FastBuffer(allocPool, poolBase + poolOffset, actual); poolOffset += actual; alignPool(); @@ -560,7 +627,7 @@ function fromArrayLike(obj) { if (length < (Buffer.poolSize >>> 1)) { if (length > (poolSize - poolOffset)) createPool(); - const b = new FastBuffer(allocPool, poolOffset, length); + const b = new FastBuffer(allocPool, poolBase + poolOffset, length); TypedArrayPrototypeSet(b, obj, 0); poolOffset += length; alignPool(); diff --git a/lib/internal/buffer.js b/lib/internal/buffer.js index d23f5d0ab6ab..5029f60e7ba0 100644 --- a/lib/internal/buffer.js +++ b/lib/internal/buffer.js @@ -33,6 +33,7 @@ const { hexWrite, ucs2Write, utf8WriteStatic, + arrayBufferAlignedOffset, createUnsafeArrayBuffer, setDetachKey, } = internalBinding('buffer'); @@ -1104,12 +1105,28 @@ function createUnsafeBuffer(size) { return new FastBuffer(createUnsafeArrayBuffer(size)); } +// Returns an uninitialized buffer of `size` bytes whose first byte is located at +// a memory address that is a multiple of `alignment`. `alignment` must be a +// power of two, and `size + alignment - 1` must not exceed the maximum buffer +// length. Since the address of a backing store cannot be chosen, `alignment - 1` +// extra bytes are allocated and skipped, which leaves the returned buffer with a +// non-zero `byteOffset` into a larger ArrayBuffer. +function createUnsafeAlignedBuffer(size, alignment) { + if (size === 0) { + return new FastBuffer(); + } + + const ab = createUnsafeArrayBuffer(size + alignment - 1); + return new FastBuffer(ab, arrayBufferAlignedOffset(ab, alignment), size); +} + module.exports = { FastBuffer, addBufferPrototypeMethods, markAsUntransferable, isMarkedAsUntransferable, createUnsafeBuffer, + createUnsafeAlignedBuffer, readUInt16BE, readUInt32BE, asciiWrite, diff --git a/src/node_buffer.cc b/src/node_buffer.cc index 29aeedd68f48..5be36d41666f 100644 --- a/src/node_buffer.cc +++ b/src/node_buffer.cc @@ -1615,6 +1615,32 @@ inline size_t CheckNumberToSize(Local number) { return size; } +// Allocates an ArrayBuffer of `size` bytes. Its contents are left +// uninitialized, unless zero-filling is required. +MaybeLocal AllocateUnsafeArrayBuffer(Environment* env, + size_t size) { + Isolate* isolate = env->isolate(); + + // 0-length, or zero-fill flag is set, or building snapshot + if (size == 0 || per_process::cli_options->zero_fill_all_buffers || + env->isolate_data()->is_building_snapshot()) { + return ArrayBuffer::New(isolate, size); + } + + std::unique_ptr store = ArrayBuffer::NewBackingStore( + isolate, + size, + BackingStoreInitializationMode::kUninitialized, + v8::BackingStoreOnFailureMode::kReturnNull); + + if (!store) [[unlikely]] { + THROW_ERR_MEMORY_ALLOCATION_FAILED(env); + return MaybeLocal(); + } + + return ArrayBuffer::New(isolate, std::move(store)); +} + void CreateUnsafeArrayBuffer(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); if (args.Length() != 1) { @@ -1624,30 +1650,49 @@ void CreateUnsafeArrayBuffer(const FunctionCallbackInfo& args) { size_t size = CheckNumberToSize(args[0]); - Isolate* isolate = env->isolate(); - Local buf; + if (AllocateUnsafeArrayBuffer(env, size).ToLocal(&buf)) { + args.GetReturnValue().Set(buf); + } +} - // 0-length, or zero-fill flag is set, or building snapshot - if (size == 0 || per_process::cli_options->zero_fill_all_buffers || - env->isolate_data()->is_building_snapshot()) { - buf = ArrayBuffer::New(isolate, size); - } else { - std::unique_ptr store = ArrayBuffer::NewBackingStore( - isolate, - size, - BackingStoreInitializationMode::kUninitialized, - v8::BackingStoreOnFailureMode::kReturnNull); +// arrayBufferAlignedOffset(arrayBuffer, alignment) +// +// Returns the offset of the first byte of `arrayBuffer` that is located at a +// memory address which is a multiple of `alignment`. V8 does not let us choose +// the address of a backing store, so an aligned view is obtained by +// over-allocating `alignment - 1` bytes and skipping to that offset. The +// backing store of a non-resizable ArrayBuffer never moves, so the offset stays +// aligned for the lifetime of the ArrayBuffer. +void ArrayBufferAlignedOffset(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + CHECK_EQ(args.Length(), 2); + CHECK(args[0]->IsArrayBuffer()); + Local ab = args[0].As(); - if (!store) [[unlikely]] { - THROW_ERR_MEMORY_ALLOCATION_FAILED(env); - return; - } + size_t alignment = CheckNumberToSize(args[1]); - buf = ArrayBuffer::New(isolate, std::move(store)); + // Validated in JS land. + CHECK_GT(alignment, 0); + CHECK_EQ(alignment & (alignment - 1), 0); + + // A backing store does not keep its address across snapshot serialization, so + // an offset computed here would not be aligned after deserialization anyway + // -- and worse, baking one in would make the snapshot depend on where this + // process happened to allocate, i.e. no longer reproducible. Report no + // padding instead. The buffer pool recreates itself in a deserialize + // callback, so it is properly aligned once the deserialized process runs. + if (env->isolate_data()->is_building_snapshot()) { + args.GetReturnValue().Set(0.0); + return; } - args.GetReturnValue().Set(buf); + uintptr_t start = reinterpret_cast(ab->Data()); + size_t offset = (alignment - (start & (alignment - 1))) & (alignment - 1); + CHECK_EQ((start + offset) & (alignment - 1), 0); + CHECK_LE(offset, ab->ByteLength()); + + args.GetReturnValue().Set(static_cast(offset)); } template @@ -1779,6 +1824,8 @@ void Initialize(Local target, SetMethod(context, target, "copyArrayBuffer", CopyArrayBuffer); SetMethodNoSideEffect( context, target, "createUnsafeArrayBuffer", CreateUnsafeArrayBuffer); + SetMethodNoSideEffect( + context, target, "arrayBufferAlignedOffset", ArrayBufferAlignedOffset); SetFastMethod(context, target, "swap16", Swap16, &fast_swap16); SetFastMethod(context, target, "swap32", Swap32, &fast_swap32); @@ -1887,6 +1934,7 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(CopyArrayBuffer); registry->Register(CreateUnsafeArrayBuffer); + registry->Register(ArrayBufferAlignedOffset); registry->Register(Atob); registry->Register(Btoa); diff --git a/test/parallel/test-buffer-alloc-alignment.js b/test/parallel/test-buffer-alloc-alignment.js new file mode 100644 index 000000000000..a1a3890ddc20 --- /dev/null +++ b/test/parallel/test-buffer-alloc-alignment.js @@ -0,0 +1,158 @@ +// Flags: --expose-internals +'use strict'; +require('../common'); +const assert = require('assert'); +const { Buffer, constants } = require('buffer'); +const { internalBinding } = require('internal/test/binding'); +const { arrayBufferAlignedOffset } = internalBinding('buffer'); + +// Buffer.allocUnsafe(size, alignment) and Buffer.allocUnsafeSlow(size, +// alignment) return a buffer whose memory starts at an address that is a +// multiple of `alignment`. + +// Addresses are not observable from JS, so alignment cannot be checked against +// `byteOffset` alone: the padding an allocation needs depends on where its +// backing store happens to land. `arrayBufferAlignedOffset(ab, alignment)` +// returns an offset into `ab` that is known to be aligned, so any other offset +// is aligned exactly when it is congruent to that one. +function assertAligned(buf, alignment) { + const aligned = arrayBufferAlignedOffset(buf.buffer, alignment); + // `aligned` is in [0, alignment), so adding `alignment` keeps this positive. + const skew = (buf.byteOffset - aligned + alignment) % alignment; + assert.strictEqual(skew, 0, + `byteOffset ${buf.byteOffset} is not ${alignment} byte ` + + `aligned (aligned offsets are ${aligned} mod ${alignment})`); +} + +const alignments = [1, 2, 4, 8, 16, 64, 512, 4096, 65536]; +const sizes = [0, 1, 7, 64, 65, 512, 4096, 100000]; + +for (const alloc of [Buffer.allocUnsafe, Buffer.allocUnsafeSlow]) { + for (const alignment of alignments) { + for (const size of sizes) { + const buf = alloc(size, alignment); + assert.strictEqual(buf.length, size); + if (size > 0) { + assertAligned(buf, alignment); + } + // The view must fit inside the (over-allocated) ArrayBuffer. + assert.ok(buf.byteOffset + size <= buf.buffer.byteLength); + // The whole buffer must be writable through the aligned view. + buf.fill(0x61); + if (size > 0) { + assert.strictEqual(buf[0], 0x61); + assert.strictEqual(buf[size - 1], 0x61); + } + } + } + + // A zero length buffer is returned for size 0, whatever the alignment. + assert.strictEqual(alloc(0, 4096).length, 0); + + // An aligned buffer is a normal Buffer. + { + const buf = alloc(32, 4096); + assert.ok(Buffer.isBuffer(buf)); + buf.write('hello'); + assert.strictEqual(buf.toString('latin1', 0, 5), 'hello'); + assert.strictEqual(buf.subarray(1, 3).length, 2); + } + + // Consecutive allocations do not overlap. + { + const a = alloc(64, 64).fill(0x01); + const b = alloc(64, 64).fill(0x02); + assert.strictEqual(a[0], 0x01); + assert.strictEqual(a[63], 0x01); + assert.strictEqual(b[0], 0x02); + assert.strictEqual(b[63], 0x02); + } + + // Invalid alignments. + for (const alignment of [0, -1, -4096, 3, 5, 100, 1000, 2 ** 30 + 1]) { + assert.throws(() => alloc(10, alignment), { + name: /^(RangeError|TypeError)$/, + }); + } + + for (const alignment of [1.5, NaN, Infinity]) { + assert.throws(() => alloc(10, alignment), { code: 'ERR_OUT_OF_RANGE' }); + } + + for (const alignment of [null, '64', 64n, {}, [], true]) { + assert.throws(() => alloc(10, alignment), { + code: 'ERR_INVALID_ARG_TYPE', + }); + } + + // Alignments that are not a power of two report the reason. + assert.throws(() => alloc(10, 3), { + code: 'ERR_INVALID_ARG_VALUE', + message: /must be a power of two/, + }); + + // `size` plus the padding must still fit within the maximum buffer length. + assert.throws(() => alloc(constants.MAX_LENGTH, 4096), { + code: 'ERR_OUT_OF_RANGE', + }); + + // `size` itself is validated before `alignment`. + assert.throws(() => alloc(-1, 64), { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => alloc(constants.MAX_LENGTH + 1, 64), { + code: 'ERR_OUT_OF_RANGE', + }); +} + +// Omitting the alignment keeps the previous behaviour. +{ + const buf = Buffer.allocUnsafeSlow(100); + assert.strictEqual(buf.length, 100); + assert.strictEqual(buf.byteOffset, 0); + assert.strictEqual(buf.buffer.byteLength, 100); +} + +// Buffer.allocUnsafeSlow() is never pooled, even when aligned. +{ + const a = Buffer.allocUnsafeSlow(64, 64); + const b = Buffer.allocUnsafeSlow(64, 64); + assert.notStrictEqual(a.buffer, b.buffer); +} + +// Buffer.allocUnsafe() serves alignments up to the pool alignment from the pool, +// and allocates on its own beyond that. +{ + const bufs = []; + for (let i = 0; i < 8; i++) { + bufs.push(Buffer.allocUnsafe(64, 64)); + } + // Pooled, so consecutive allocations share an ArrayBuffer. A pool may be + // exhausted in between, hence checking that any two neighbours share one. + assert.ok(bufs.some((buf, i) => i > 0 && buf.buffer === bufs[i - 1].buffer)); + for (const buf of bufs) { + assertAligned(buf, 64); + } + + // Stricter than the pool alignment, so this gets its own ArrayBuffer. + const own = Buffer.allocUnsafe(64, 128); + assert.ok(bufs.every((buf) => buf.buffer !== own.buffer)); +} + +// Aligned pooled allocations do not disturb unaligned ones. Interleave the two +// and make sure every buffer keeps its own contents. +{ + const bufs = []; + for (let i = 0; i < 256; i++) { + const buf = i % 2 === 0 ? + Buffer.allocUnsafe(24) : + Buffer.allocUnsafe(24, 16); + if (i % 2 === 1) { + assertAligned(buf, 16); + } + buf.fill(i % 256); + bufs.push(buf); + } + for (let i = 0; i < bufs.length; i++) { + assert.strictEqual(bufs[i][0], i % 256); + assert.strictEqual(bufs[i][23], i % 256); + } +} From e9327d1422beed5a5d98b769631cc8602d22841c Mon Sep 17 00:00:00 2001 From: Lazizbek Ergashev Date: Thu, 6 Aug 2026 16:13:04 +0500 Subject: [PATCH 040/344] dns: fix crash on setServers with port 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Lazizbek Ergashev PR-URL: https://github.com/nodejs/node/pull/65009 Fixes: https://github.com/nodejs/node/issues/65006 Reviewed-By: René Reviewed-By: Matteo Collina Reviewed-By: Tim Perry --- src/cares_wrap.cc | 8 ++++---- test/parallel/test-dns.js | 4 ++++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/cares_wrap.cc b/src/cares_wrap.cc index 7b7faa4be594..7d9323c83ee6 100644 --- a/src/cares_wrap.cc +++ b/src/cares_wrap.cc @@ -2159,13 +2159,13 @@ void SetServers(const FunctionCallbackInfo& args) { if (!elm->Get(env->context(), 1).ToLocal(&ipValue)) return; if (!elm->Get(env->context(), 2).ToLocal(&portValue)) return; - CHECK(familyValue->Int32Value(env->context()).FromJust()); + CHECK(familyValue->IsInt32()); CHECK(ipValue->IsString()); - CHECK(portValue->Int32Value(env->context()).FromJust()); + CHECK(portValue->IsInt32()); - int fam = familyValue->Int32Value(env->context()).FromJust(); + int32_t fam = familyValue.As()->Value(); node::Utf8Value ip(env->isolate(), ipValue); - int port = portValue->Int32Value(env->context()).FromJust(); + int32_t port = portValue.As()->Value(); ares_addr_port_node* cur = &servers[i]; diff --git a/test/parallel/test-dns.js b/test/parallel/test-dns.js index d7c2efcbd16e..d6056d459a1b 100644 --- a/test/parallel/test-dns.js +++ b/test/parallel/test-dns.js @@ -136,6 +136,10 @@ const portsExpected = [ dns.setServers(ports); assert.deepStrictEqual(dns.getServers(), portsExpected); +// Port 0 means "use the default port" for c-ares. +dns.setServers(['4.4.4.4:0', '[2001:4860:4860::8888]:0']); +assert.deepStrictEqual(dns.getServers(), ['4.4.4.4', '2001:4860:4860::8888']); + dns.setServers([]); assert.deepStrictEqual(dns.getServers(), []); From 3d7d2774937c43e61ac40483972ee47d66ea39cf Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 2 Aug 2026 20:55:44 -0700 Subject: [PATCH 041/344] net: improve performance of net.BlockList * fix duplicate address insertion in SocketAddressBlockList * fix BlockList rule listing order to match apply * add minor bound check in BlockList * improve performance of BlockList apply * eliminating shared_ptr * check fast api path * add clear method to BlockList * general storage improvements to BlockList * use shared locks for BlockList reads * add bulk address adding to BlockList * add BlockList benchmark * add remove range/subnet to BlockList * add cidr notation parsing to BlockList * add additional apis to BlockList * add private subnet presets to BlockList Signed-off-by: James M Snell Assisted-by: OpenCode/Opus PR-URL: https://github.com/nodejs/node/pull/64974 Reviewed-By: Tim Perry Reviewed-By: Yagiz Nizipli Reviewed-By: Benjamin Gruenbaum Reviewed-By: Ethan Arrowood --- benchmark/net/net-blocklist.js | 146 ++++++ doc/api/net.md | 188 +++++++- lib/internal/blocklist.js | 211 ++++++++- src/node_sockaddr.cc | 497 +++++++++++++++++--- src/node_sockaddr.h | 120 +++-- test/cctest/test_sockaddr.cc | 412 +++++++++++++++- test/parallel/test-blocklist-fast-api.js | 42 ++ test/parallel/test-blocklist.js | 572 ++++++++++++++++++++++- 8 files changed, 2061 insertions(+), 127 deletions(-) create mode 100644 benchmark/net/net-blocklist.js create mode 100644 test/parallel/test-blocklist-fast-api.js diff --git a/benchmark/net/net-blocklist.js b/benchmark/net/net-blocklist.js new file mode 100644 index 000000000000..9c293682ff61 --- /dev/null +++ b/benchmark/net/net-blocklist.js @@ -0,0 +1,146 @@ +'use strict'; + +const common = require('../common.js'); +const { BlockList, SocketAddress } = require('net'); + +const hasAddAddresses = typeof BlockList.prototype.addAddresses === 'function'; + +const operations = ['check', 'checkWithSocketAddress', 'addAddress']; +if (hasAddAddresses) { + operations.push('addAddresses'); +} + +const bench = common.createBenchmark(main, { + n: [1e6], + ruleCount: [10, 100, 1000, 10000], + ruleType: ['address', 'subnet', 'mixed'], + checkResult: ['hit', 'miss'], + operation: operations, +}, { + combinationFilter({ operation, ruleCount, ruleType }) { + // addAddress and addAddresses only need address rules, not subnets. + if ((operation === 'addAddress' || operation === 'addAddresses') && + ruleType !== 'address') { + return false; + } + return true; + }, +}); + +function generateIPv4(index) { + return `${(index >>> 24) & 0xff}.${(index >>> 16) & 0xff}.` + + `${(index >>> 8) & 0xff}.${index & 0xff}`; +} + +function buildBlockList(ruleCount, ruleType) { + const blockList = new BlockList(); + + if (ruleType === 'address' || ruleType === 'mixed') { + const addressCount = ruleType === 'mixed' ? + Math.floor(ruleCount / 2) : ruleCount; + const addresses = []; + for (let i = 0; i < addressCount; i++) { + // Start from 10.0.0.1 to avoid 0.0.0.0 + addresses.push(generateIPv4(0x0a000001 + i)); + } + if (hasAddAddresses) { + blockList.addAddresses(addresses); + } else { + for (const addr of addresses) { + blockList.addAddress(addr); + } + } + } + + if (ruleType === 'subnet' || ruleType === 'mixed') { + const subnetCount = ruleType === 'mixed' ? + Math.floor(ruleCount / 2) : ruleCount; + for (let i = 0; i < subnetCount; i++) { + // Use distinct /24 subnets: 172.i.j.0/24 + const second = (i >>> 8) & 0xff; + const third = i & 0xff; + blockList.addSubnet(`172.${second}.${third}.0`, 24); + } + } + + return blockList; +} + +function main({ n, ruleCount, ruleType, checkResult, operation }) { + if (operation === 'check') { + benchCheck(n, ruleCount, ruleType, checkResult); + } else if (operation === 'checkWithSocketAddress') { + benchCheckWithSocketAddress(n, ruleCount, ruleType, checkResult); + } else if (operation === 'addAddress') { + benchAddAddress(n, ruleCount); + } else if (operation === 'addAddresses') { + benchAddAddresses(n, ruleCount); + } +} + +// Benchmark check() with string addresses (the common JS API path). +function benchCheck(n, ruleCount, ruleType, checkResult) { + const blockList = buildBlockList(ruleCount, ruleType); + + // For 'hit', use an address that's in the list. + // For 'miss', use an address that's not in the list. + const address = checkResult === 'hit' ? '10.0.0.1' : '192.168.255.255'; + + bench.start(); + for (let i = 0; i < n; i++) { + blockList.check(address); + } + bench.end(n); +} + +// Benchmark check() with pre-created SocketAddress objects +// (avoids measuring SocketAddress construction overhead). +function benchCheckWithSocketAddress(n, ruleCount, ruleType, checkResult) { + const blockList = buildBlockList(ruleCount, ruleType); + + const address = checkResult === 'hit' ? '10.0.0.1' : '192.168.255.255'; + const sa = new SocketAddress({ address }); + + bench.start(); + for (let i = 0; i < n; i++) { + blockList.check(sa); + } + bench.end(n); +} + +// Benchmark single addAddress() calls (one lock acquire per call). +function benchAddAddress(n, ruleCount) { + // Scale n down for large rule counts to keep runtime reasonable. + const iterations = Math.min(n, ruleCount * 100); + + const addresses = []; + for (let i = 0; i < ruleCount; i++) { + addresses.push(generateIPv4(0x0a000001 + i)); + } + + bench.start(); + for (let i = 0; i < iterations; i++) { + const blockList = new BlockList(); + for (let j = 0; j < addresses.length; j++) { + blockList.addAddress(addresses[j]); + } + } + bench.end(iterations); +} + +// Benchmark batch addAddresses() (one lock acquire per batch). +function benchAddAddresses(n, ruleCount) { + const iterations = Math.min(n, ruleCount * 100); + + const addresses = []; + for (let i = 0; i < ruleCount; i++) { + addresses.push(generateIPv4(0x0a000001 + i)); + } + + bench.start(); + for (let i = 0; i < iterations; i++) { + const blockList = new BlockList(); + blockList.addAddresses(addresses); + } + bench.end(iterations); +} diff --git a/doc/api/net.md b/doc/api/net.md index 5c893e6e2023..01277f22651a 100644 --- a/doc/api/net.md +++ b/doc/api/net.md @@ -96,6 +96,47 @@ added: Adds a rule to block the given IP address. +### `blockList.addAddresses(addresses[, type])` + + + +* `addresses` {string\[]|net.SocketAddress\[]} An array of IPv4 or IPv6 + addresses. +* `type` {string} Either `'ipv4'` or `'ipv6'`. **Default:** `'ipv4'`. + +Adds multiple address rules to the block list in a single operation. +This is more efficient than calling `blockList.addAddress()` repeatedly +when adding a large number of individual addresses, as the addresses +are inserted under a single internal lock acquisition. + +### `blockList.addCIDR(cidr)` + + + +* `cidr` {string} An IPv4 or IPv6 subnet in CIDR notation (e.g. + `'10.0.0.0/8'` or `'2001:db8::/32'`). + +Adds a subnet rule using CIDR notation. The address family is automatically +detected from the address (IPv6 if the address contains `':'`, IPv4 +otherwise). This is equivalent to calling `blockList.addSubnet()` with +the parsed network address, prefix length, and family. + +### `blockList.addCIDRs(cidrs)` + + + +* `cidrs` {string\[]} An array of IPv4 or IPv6 subnets in CIDR notation. + +Adds multiple subnet rules using CIDR notation in a single call. The address +family for each entry is automatically detected. This is equivalent to +calling `blockList.addCIDR()` for each element of the array. + ### `blockList.addRange(start, end[, type])` -* Type: {string\[]} - -The list of rules added to the blocklist. - -### `BlockList.isBlockList(value)` - - - -* `value` {any} Any JS value -* Returns `true` if the `value` is a `net.BlockList`. +Clears all rules from the `BlockList`. ### `blockList.fromJSON(value)` @@ -205,6 +231,130 @@ blockList.fromJSON(JSON.stringify(data)); * `value` Blocklist.rules +### `BlockList.isBlockList(value)` + + + +* `value` {any} Any JS value +* Returns `true` if the `value` is a `net.BlockList`. + +### `BlockList.PRIVATE_RANGES` + + + +* Type: {string\[]} + +A frozen array of CIDR strings representing private, loopback, and link-local +IP address ranges. This can be passed to `blockList.addCIDRs()` to quickly +populate a blocklist with all non-routable address ranges. + +The included ranges are: + +* `10.0.0.0/8` — RFC 1918 private IPv4 +* `172.16.0.0/12` — RFC 1918 private IPv4 +* `192.168.0.0/16` — RFC 1918 private IPv4 +* `127.0.0.0/8` — IPv4 loopback +* `::1/128` — IPv6 loopback +* `169.254.0.0/16` — IPv4 link-local +* `fe80::/10` — IPv6 link-local +* `fc00::/7` — IPv6 unique local (ULA) + +```js +const blockList = new net.BlockList(); +blockList.addCIDRs(net.BlockList.PRIVATE_RANGES); + +console.log(blockList.check('10.0.0.1')); // Prints: true +console.log(blockList.check('127.0.0.1')); // Prints: true +console.log(blockList.check('8.8.8.8')); // Prints: false +``` + +### `blockList.removeAddress(address[, type])` + + + +* `address` {string|net.SocketAddress} An IPv4 or IPv6 address. +* `type` {string} Either `'ipv4'` or `'ipv6'`. **Default:** `'ipv4'`. + +Removes a rule that was previously added with `blockList.addAddress()`. The +address must match exactly the value used when the rule was added. If the +specified address does not exist, this is a no-op. + +### `blockList.removeCIDR(cidr)` + + + +* `cidr` {string} An IPv4 or IPv6 subnet in CIDR notation (e.g. + `'10.0.0.0/8'` or `'2001:db8::/32'`). + +Removes a subnet rule using CIDR notation. The address family is automatically +detected from the address. This is equivalent to calling +`blockList.removeSubnet()` with the parsed network address, prefix length, +and family. If the specified subnet does not exist, this is a no-op. + +### `blockList.removeRange(start, end[, type])` + + + +* `start` {string|net.SocketAddress} The starting IPv4 or IPv6 address in the + range. +* `end` {string|net.SocketAddress} The ending IPv4 or IPv6 address in the range. +* `type` {string} Either `'ipv4'` or `'ipv6'`. **Default:** `'ipv4'`. + +Removes a rule that was previously added with `blockList.addRange()`. The `start` +and `end` addresses must match exactly the values used when the rule was added. +If the specified range does not exist, this is a no-op. + +### `blockList.removeSubnet(net, prefix[, type])` + + + +* `net` {string|net.SocketAddress} The network IPv4 or IPv6 address. +* `prefix` {number} The number of CIDR prefix bits. For IPv4, this + must be a value between `0` and `32`. For IPv6, this must be between + `0` and `128`. +* `type` {string} Either `'ipv4'` or `'ipv6'`. **Default:** `'ipv4'`. + +Removes a rule that was previously added with `blockList.addSubnet()`. The +network address and prefix must match exactly the values used when the rule was +added. If the specified subnet does not exist, this is a no-op. + +### `blockList.rules` + + + +* Type: {string\[]} + +The list of rules added to the blocklist. + +### `blockList.size` + + + +* Type: {number} + +The number of rules in the blocklist. This is equivalent to +`blockList.rules.length` but does not allocate the rules array. + ### `blockList.toJSON()` > Stability: 1.2 - Release candidate diff --git a/lib/internal/blocklist.js b/lib/internal/blocklist.js index fd0e7667377b..f290c7ada405 100644 --- a/lib/internal/blocklist.js +++ b/lib/internal/blocklist.js @@ -4,13 +4,21 @@ const { ArrayIsArray, Boolean, JSONParse, + NumberIsNaN, NumberParseInt, + ObjectFreeze, ObjectSetPrototypeOf, + StringPrototypeIncludes, + StringPrototypeLastIndexOf, + StringPrototypeSlice, + StringPrototypeToLowerCase, Symbol, } = primordials; const { BlockList: BlockListHandle, + AF_INET, + AF_INET6, } = internalBinding('block_list'); const { @@ -40,6 +48,22 @@ const { const { validateInt32, validateString } = require('internal/validators'); +function parseCIDR(cidr) { + validateString(cidr, 'cidr'); + const slash = StringPrototypeLastIndexOf(cidr, '/'); + if (slash === -1) { + throw new ERR_INVALID_ARG_VALUE('cidr', cidr, 'must contain a prefix length (e.g. "10.0.0.0/8")'); + } + const address = StringPrototypeSlice(cidr, 0, slash); + const prefixStr = StringPrototypeSlice(cidr, slash + 1); + const prefix = NumberParseInt(prefixStr, 10); + if (NumberIsNaN(prefix) || `${prefix}` !== prefixStr) { + throw new ERR_INVALID_ARG_VALUE('cidr', cidr, 'prefix length must be a valid integer'); + } + const family = StringPrototypeIncludes(address, ':') ? 'ipv6' : 'ipv4'; + return { address, prefix, family }; +} + class BlockList { constructor() { markTransferMode(this, true, false); @@ -56,6 +80,21 @@ class BlockList { return value?.[kHandle] !== undefined; } + static PRIVATE_RANGES = ObjectFreeze([ + // RFC 1918 - Private IPv4 + '10.0.0.0/8', + '172.16.0.0/12', + '192.168.0.0/16', + // Loopback + '127.0.0.0/8', + '::1/128', + // Link-local + '169.254.0.0/16', + 'fe80::/10', + // Unique local (ULA) + 'fc00::/7', + ]); + [kInspect](depth, options) { if (depth < 0) return this; @@ -70,6 +109,10 @@ class BlockList { }, opts)}`; } + /** + * @param {string|SocketAddress} address + * @param {string} [family] + */ addAddress(address, family = 'ipv4') { if (!SocketAddress.isSocketAddress(address)) { validateString(address, 'address'); @@ -82,6 +125,33 @@ class BlockList { this[kHandle].addAddress(address[kSocketAddressHandle]); } + /** + * + * @param {(string|SocketAddress)[]} addresses + * @param {string} [family] + */ + addAddresses(addresses, family = 'ipv4') { + if (!ArrayIsArray(addresses)) { + throw new ERR_INVALID_ARG_TYPE('addresses', 'Array', addresses); + } + validateString(family, 'family'); + const handles = []; + for (let i = 0; i < addresses.length; i++) { + let address = addresses[i]; + if (!SocketAddress.isSocketAddress(address)) { + validateString(address, `addresses[${i}]`); + address = new SocketAddress({ address, family }); + } + handles.push(address[kSocketAddressHandle]); + } + this[kHandle].addAddresses(handles); + } + + /** + * @param {string|SocketAddress} start + * @param {string|SocketAddress} end + * @param {string} [family] + */ addRange(start, end, family = 'ipv4') { if (!SocketAddress.isSocketAddress(start)) { validateString(start, 'start'); @@ -106,6 +176,11 @@ class BlockList { throw new ERR_INVALID_ARG_VALUE('start', start, 'must come before end'); } + /** + * @param {string|SocketAddress} network + * @param {number} prefix + * @param {string} [family] + */ addSubnet(network, prefix, family = 'ipv4') { if (!SocketAddress.isSocketAddress(network)) { validateString(network, 'network'); @@ -128,23 +203,136 @@ class BlockList { this[kHandle].addSubnet(network[kSocketAddressHandle], prefix); } + /** + * @param {string} cidr + */ + addCIDR(cidr) { + const { address, prefix, family } = parseCIDR(cidr); + this.addSubnet(address, prefix, family); + } + + /** + * @param {string[]} cidrs + */ + addCIDRs(cidrs) { + if (!ArrayIsArray(cidrs)) { + throw new ERR_INVALID_ARG_TYPE('cidrs', 'Array', cidrs); + } + // Validate and parse all entries first so that an exception mid-array + // does not leave the blocklist half-modified. + const parsed = []; + for (let i = 0; i < cidrs.length; i++) { + validateString(cidrs[i], `cidrs[${i}]`); + parsed.push(parseCIDR(cidrs[i])); + } + for (let i = 0; i < parsed.length; i++) { + const { address, prefix, family } = parsed[i]; + this.addSubnet(address, prefix, family); + } + } + + /** + * @param {string|SocketAddress} address + * @param {string} [family] + */ + removeAddress(address, family = 'ipv4') { + if (!SocketAddress.isSocketAddress(address)) { + validateString(address, 'address'); + validateString(family, 'family'); + address = new SocketAddress({ + address, + family, + }); + } + this[kHandle].removeAddress(address[kSocketAddressHandle]); + } + + /** + * @param {string|SocketAddress} start + * @param {string|SocketAddress} end + * @param {string} [family] + */ + removeRange(start, end, family = 'ipv4') { + if (!SocketAddress.isSocketAddress(start)) { + validateString(start, 'start'); + validateString(family, 'family'); + start = new SocketAddress({ + address: start, + family, + }); + } + if (!SocketAddress.isSocketAddress(end)) { + validateString(end, 'end'); + validateString(family, 'family'); + end = new SocketAddress({ + address: end, + family, + }); + } + this[kHandle].removeRange( + start[kSocketAddressHandle], + end[kSocketAddressHandle]); + } + + /** + * @param {string|SocketAddress} network + * @param {number} prefix + * @param {string} [family] + */ + removeSubnet(network, prefix, family = 'ipv4') { + if (!SocketAddress.isSocketAddress(network)) { + validateString(network, 'network'); + validateString(family, 'family'); + network = new SocketAddress({ + address: network, + family, + }); + } + switch (network.family) { + case 'ipv4': + validateInt32(prefix, 'prefix', 0, 32); + break; + case 'ipv6': + validateInt32(prefix, 'prefix', 0, 128); + break; + } + prefix += 0; + this[kHandle].removeSubnet(network[kSocketAddressHandle], prefix); + } + + /** + * @param {string} cidr + */ + removeCIDR(cidr) { + const { address, prefix, family } = parseCIDR(cidr); + this.removeSubnet(address, prefix, family); + } + + /** + * @param {string|SocketAddress} address + * @param {string} [family] + * @returns {boolean} + */ check(address, family = 'ipv4') { if (!SocketAddress.isSocketAddress(address)) { validateString(address, 'address'); validateString(family, 'family'); - try { - address = new SocketAddress({ - address, - family, - }); - } catch { - // Ignore the error. If it's not a valid address, return false. - return false; - } + // 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; + return this[kHandle].checkString(address, af); } return Boolean(this[kHandle].check(address[kSocketAddressHandle])); } + /** + * Removes all rules from the block list. + */ + clear() { + this[kHandle].clear(); + } + /* * @param {string[]} data * @example @@ -269,6 +457,11 @@ class BlockList { get rules() { return this[kHandle].getRules(); } + + get size() { + return this[kHandle].getSize(); + } + [kClone]() { const handle = this[kHandle]; return { diff --git a/src/node_sockaddr.cc b/src/node_sockaddr.cc index 9348f0ac8e4d..e12fb86a3a95 100644 --- a/src/node_sockaddr.cc +++ b/src/node_sockaddr.cc @@ -3,6 +3,7 @@ #include "env-inl.h" #include "memory_tracker-inl.h" #include "nbytes.h" +#include "node_debug.h" #include "node_errors.h" #include "node_hash.h" #include "node_sockaddr-inl.h" // NOLINT(build/include_inline) @@ -15,6 +16,7 @@ namespace node { using v8::Array; +using v8::CFunction; using v8::Context; using v8::FunctionCallbackInfo; using v8::FunctionTemplate; @@ -399,106 +401,321 @@ SocketAddressBlockList::SocketAddressBlockList( std::shared_ptr parent) : parent_(parent) {} -void SocketAddressBlockList::AddSocketAddress( - const std::shared_ptr& address) { - Mutex::ScopedLock lock(mutex_); - std::unique_ptr rule = std::make_unique(address); - rules_.emplace_front(std::move(rule)); - address_rules_[*address.get()] = rules_.begin(); +// --- SubnetTrie implementation --- + +namespace { +inline int GetBit(const uint8_t* bytes, int bit_index) { + return (bytes[bit_index >> 3] >> (7 - (bit_index & 7))) & 1; +} + +inline const uint8_t* GetAddressBytes(const SocketAddress& addr, int* bits) { + if (addr.family() == AF_INET) { + const auto* in = reinterpret_cast(addr.data()); + *bits = 32; + return reinterpret_cast(&in->sin_addr); + } + const auto* in6 = reinterpret_cast(addr.data()); + *bits = 128; + return reinterpret_cast(&in6->sin6_addr); +} +} // namespace + +void SocketAddressBlockList::SubnetTrie::Insert(const uint8_t* address_bytes, + int prefix_length) { + if (root_ == nullptr) { + root_ = std::make_unique(); + } + + Node* node = root_.get(); + for (int i = 0; i < prefix_length; i++) { + if (node->terminal) { + // A broader prefix already covers this subnet. No-op. + return; + } + int bit = GetBit(address_bytes, i); + if (node->children[bit] == nullptr) { + node->children[bit] = std::make_unique(); + } + node = node->children[bit].get(); + } + + if (!node->terminal) { + node->terminal = true; + count_++; + // Prune children — this prefix subsumes all longer prefixes below it. + node->children[0].reset(); + node->children[1].reset(); + } } -void SocketAddressBlockList::RemoveSocketAddress( - const std::shared_ptr& address) { - Mutex::ScopedLock lock(mutex_); - auto it = address_rules_.find(*address.get()); - if (it != std::end(address_rules_)) { - rules_.erase(it->second); - address_rules_.erase(it); +bool SocketAddressBlockList::SubnetTrie::Lookup(const uint8_t* address_bytes, + int address_bits) const { + if (root_ == nullptr) return false; + + const Node* node = root_.get(); + // A terminal root means prefix /0 — matches everything. + if (node->terminal) return true; + + for (int i = 0; i < address_bits; i++) { + int bit = GetBit(address_bytes, i); + node = node->children[bit].get(); + if (node == nullptr) return false; + if (node->terminal) return true; + } + return false; +} + +void SocketAddressBlockList::SubnetTrie::Clear() { + root_.reset(); + count_ = 0; +} + +void SocketAddressBlockList::AddSocketAddressImpl( + const SocketAddress& address) { + if (address_rules_.count(address) == 0) { + address_count_++; + } + address_rules_[address] = address; + // Insert the cross-family counterpart so that both IPv4 and + // IPv4-mapped IPv6 lookups resolve in O(1). + if (address.family() == AF_INET) { + // Map 1.2.3.4 -> ::ffff:1.2.3.4 + std::string mapped = "::ffff:" + address.address(); + SocketAddress ipv6; + if (SocketAddress::New(AF_INET6, mapped.c_str(), address.port(), &ipv6)) { + address_rules_[ipv6] = address; + } + } else if (address.family() == AF_INET6) { + // Check if this is an IPv4-mapped IPv6 address (::ffff:x.x.x.x) + // and insert the IPv4 counterpart if so. + const sockaddr_in6* in6 = + reinterpret_cast(address.data()); + const uint8_t* bytes = reinterpret_cast(&in6->sin6_addr); + constexpr uint8_t ipv4_mapped_prefix[] = { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff}; + if (memcmp(bytes, ipv4_mapped_prefix, sizeof(ipv4_mapped_prefix)) == 0) { + sockaddr_in ipv4_addr{}; + ipv4_addr.sin_family = AF_INET; + ipv4_addr.sin_port = in6->sin6_port; + memcpy(&ipv4_addr.sin_addr, bytes + sizeof(ipv4_mapped_prefix), 4); + SocketAddress ipv4(reinterpret_cast(&ipv4_addr)); + address_rules_[ipv4] = address; + } + } +} + +void SocketAddressBlockList::AddSocketAddress(const SocketAddress& address) { + RwLock::ScopedLock lock(mutex_); + AddSocketAddressImpl(address); +} + +void SocketAddressBlockList::AddSocketAddresses(const SocketAddress* addresses, + size_t count) { + RwLock::ScopedLock lock(mutex_); + for (size_t i = 0; i < count; i++) { + AddSocketAddressImpl(addresses[i]); + } +} + +void SocketAddressBlockList::RemoveSocketAddress(const SocketAddress& address) { + RwLock::ScopedLock lock(mutex_); + if (address_rules_.erase(address)) { + address_count_--; + } + // Also remove the cross-family counterpart. + if (address.family() == AF_INET) { + std::string mapped = "::ffff:" + address.address(); + SocketAddress ipv6; + if (SocketAddress::New(AF_INET6, mapped.c_str(), address.port(), &ipv6)) { + address_rules_.erase(ipv6); + } + } else if (address.family() == AF_INET6) { + const sockaddr_in6* in6 = + reinterpret_cast(address.data()); + const uint8_t* bytes = reinterpret_cast(&in6->sin6_addr); + constexpr uint8_t ipv4_mapped_prefix[] = { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff}; + if (memcmp(bytes, ipv4_mapped_prefix, sizeof(ipv4_mapped_prefix)) == 0) { + sockaddr_in ipv4_addr{}; + ipv4_addr.sin_family = AF_INET; + ipv4_addr.sin_port = in6->sin6_port; + memcpy(&ipv4_addr.sin_addr, bytes + sizeof(ipv4_mapped_prefix), 4); + SocketAddress ipv4(reinterpret_cast(&ipv4_addr)); + address_rules_.erase(ipv4); + } } } -void SocketAddressBlockList::AddSocketAddressRange( - const std::shared_ptr& start, - const std::shared_ptr& end) { - Mutex::ScopedLock lock(mutex_); +void SocketAddressBlockList::AddSocketAddressRange(const SocketAddress& start, + const SocketAddress& end) { + DCHECK(!(start > end)); + RwLock::ScopedLock lock(mutex_); std::unique_ptr rule = std::make_unique(start, end); rules_.emplace_front(std::move(rule)); } -void SocketAddressBlockList::AddSocketAddressMask( - const std::shared_ptr& network, int prefix) { - Mutex::ScopedLock lock(mutex_); - std::unique_ptr rule = - std::make_unique(network, prefix); - rules_.emplace_front(std::move(rule)); +void SocketAddressBlockList::AddSocketAddressMask(const SocketAddress& network, + int prefix) { + RwLock::ScopedLock lock(mutex_); + int bits; + const uint8_t* bytes = GetAddressBytes(network, &bits); + + if (network.family() == AF_INET) { + ipv4_subnets_.Insert(bytes, prefix); + // Also insert into IPv6 trie as ::ffff:x.x.x.x with prefix+96. + uint8_t mapped[16] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff}; + memcpy(mapped + 12, bytes, 4); + ipv6_subnets_.Insert(mapped, prefix + 96); + } else { + ipv6_subnets_.Insert(bytes, prefix); + // Check if this is a ::ffff:x.x.x.x/N subnet — if so, also insert + // the IPv4 portion into the IPv4 trie. + constexpr uint8_t v4mapped[] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff}; + if (prefix >= 96 && memcmp(bytes, v4mapped, 12) == 0) { + ipv4_subnets_.Insert(bytes + 12, prefix - 96); + } + } + + // Keep metadata for ListRules serialization. + subnet_rules_.emplace_front( + std::make_unique(network, prefix)); +} + +void SocketAddressBlockList::RemoveSocketAddressRange( + const SocketAddress& start, const SocketAddress& end) { + RwLock::ScopedLock lock(mutex_); + // rules_ contains only SocketAddressRangeRule instances (subnet rules + // are stored separately in subnet_rules_). + for (auto it = rules_.begin(); it != rules_.end(); ++it) { + auto* range = static_cast(it->get()); + if (range->start == start && range->end == end) { + rules_.erase(it); + return; + } + } +} + +void SocketAddressBlockList::RemoveSocketAddressMask( + const SocketAddress& network, int prefix) { + RwLock::ScopedLock lock(mutex_); + + // Remove from subnet_rules_ metadata list. + bool found = false; + for (auto it = subnet_rules_.begin(); it != subnet_rules_.end(); ++it) { + if ((*it)->network == network && (*it)->prefix == prefix) { + subnet_rules_.erase(it); + found = true; + break; + } + } + if (!found) return; + + // Rebuild both tries from the remaining subnet_rules_. This handles the + // case where a broader prefix had subsumed narrower ones in the trie -- + // simply removing the broader prefix from the trie would not restore the + // narrower entries that were pruned on insert. Rebuilding is O(n) in the + // number of subnet rules but removal is not a hot path. + ipv4_subnets_.Clear(); + ipv6_subnets_.Clear(); + for (const auto& rule : subnet_rules_) { + int bits; + const uint8_t* b = GetAddressBytes(rule->network, &bits); + if (rule->network.family() == AF_INET) { + ipv4_subnets_.Insert(b, rule->prefix); + uint8_t mapped[16] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff}; + memcpy(mapped + 12, b, 4); + ipv6_subnets_.Insert(mapped, rule->prefix + 96); + } else { + ipv6_subnets_.Insert(b, rule->prefix); + constexpr uint8_t v4mapped[] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff}; + if (rule->prefix >= 96 && memcmp(b, v4mapped, 12) == 0) { + ipv4_subnets_.Insert(b + 12, rule->prefix - 96); + } + } + } } bool SocketAddressBlockList::Apply(const SocketAddress& address) { - Mutex::ScopedLock lock(mutex_); + RwLock::ScopedReadLock lock(mutex_); + // O(1) lookup for exact address matches. The address_rules_ map + // uses IpHash/IpEqual (port-insensitive, family-sensitive). + if (address_rules_.count(address)) return true; + + // O(prefix_length) lookup for subnet/mask rules via radix trie. + int bits; + const uint8_t* bytes = GetAddressBytes(address, &bits); + if (address.family() == AF_INET) { + if (ipv4_subnets_.Lookup(bytes, bits)) return true; + // Also check IPv6 trie for ::ffff:x.x.x.x subnets. + uint8_t mapped[16] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff}; + memcpy(mapped + 12, bytes, 4); + if (ipv6_subnets_.Lookup(mapped, 128)) return true; + } else { + if (ipv6_subnets_.Lookup(bytes, bits)) return true; + // Check if this is ::ffff:x.x.x.x — also check IPv4 trie. + constexpr uint8_t v4mapped[] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff}; + if (memcmp(bytes, v4mapped, 12) == 0) { + if (ipv4_subnets_.Lookup(bytes + 12, 32)) return true; + } + } + + // Linear scan for range rules only. Subnet rules are in the trie. for (const auto& rule : rules_) { if (rule->Apply(address)) return true; } return parent_ ? parent_->Apply(address) : false; } -SocketAddressBlockList::SocketAddressRule::SocketAddressRule( - const std::shared_ptr& address_) - : address(address_) {} +void SocketAddressBlockList::Clear() { + RwLock::ScopedLock lock(mutex_); + rules_.clear(); + address_rules_.clear(); + address_count_ = 0; + ipv4_subnets_.Clear(); + ipv6_subnets_.Clear(); + subnet_rules_.clear(); +} SocketAddressBlockList::SocketAddressRangeRule::SocketAddressRangeRule( - const std::shared_ptr& start_, - const std::shared_ptr& end_) + const SocketAddress& start_, const SocketAddress& end_) : start(start_), end(end_) {} SocketAddressBlockList::SocketAddressMaskRule::SocketAddressMaskRule( - const std::shared_ptr& network_, int prefix_) + const SocketAddress& network_, int prefix_) : network(network_), prefix(prefix_) {} -bool SocketAddressBlockList::SocketAddressRule::Apply( - const SocketAddress& address) { - return this->address->is_match(address); -} - -std::string SocketAddressBlockList::SocketAddressRule::ToString() { - std::string ret = "Address: "; - ret += address->family() == AF_INET ? "IPv4" : "IPv6"; - ret += " "; - ret += address->address(); - return ret; -} - bool SocketAddressBlockList::SocketAddressRangeRule::Apply( const SocketAddress& address) { - return address >= *start.get() && address <= *end.get(); + return address >= start && address <= end; } std::string SocketAddressBlockList::SocketAddressRangeRule::ToString() { std::string ret = "Range: "; - ret += start->family() == AF_INET ? "IPv4" : "IPv6"; + ret += start.family() == AF_INET ? "IPv4" : "IPv6"; ret += " "; - ret += start->address(); + ret += start.address(); ret += "-"; - ret += end->address(); + ret += end.address(); return ret; } bool SocketAddressBlockList::SocketAddressMaskRule::Apply( const SocketAddress& address) { - return address.is_in_network(*network.get(), prefix); + return address.is_in_network(network, prefix); } std::string SocketAddressBlockList::SocketAddressMaskRule::ToString() { std::string ret = "Subnet: "; - ret += network->family() == AF_INET ? "IPv4" : "IPv6"; + ret += network.family() == AF_INET ? "IPv4" : "IPv6"; ret += " "; - ret += network->address(); + ret += network.address(); ret += "/" + std::to_string(prefix); return ret; } MaybeLocal SocketAddressBlockList::ListRules(Environment* env) { - Mutex::ScopedLock lock(mutex_); + RwLock::ScopedReadLock lock(mutex_); LocalVector rules(env->isolate()); if (!ListRules(env, &rules)) return MaybeLocal(); return Array::New(env->isolate(), rules.data(), rules.size()); @@ -506,22 +723,42 @@ MaybeLocal SocketAddressBlockList::ListRules(Environment* env) { bool SocketAddressBlockList::ListRules(Environment* env, LocalVector* rules) { - if (parent_ && !parent_->ListRules(env, rules)) return false; + // List local rules first, then parent rules, matching the + // evaluation order in Apply(). + // + // address_rules_ may contain cross-family duplicates (e.g. both + // 1.1.1.1 and ::ffff:1.1.1.1 map to the same original address). + // Track which originals have been listed to avoid duplicates. + SocketAddress::Map seen; + for (const auto& [_, address] : address_rules_) { + if (seen.count(address)) continue; + seen[address] = true; + std::string str = "Address: "; + str += address.family() == AF_INET ? "IPv4" : "IPv6"; + str += " "; + str += address.address(); + Local v; + if (!ToV8Value(env->context(), str).ToLocal(&v)) return false; + rules->push_back(v); + } + for (const auto& rule : subnet_rules_) { + Local str; + if (!rule->ToV8String(env).ToLocal(&str)) return false; + rules->push_back(str); + } for (const auto& rule : rules_) { Local str; if (!rule->ToV8String(env).ToLocal(&str)) return false; rules->push_back(str); } - return true; + return !parent_ || parent_->ListRules(env, rules); } void SocketAddressBlockList::MemoryInfo(node::MemoryTracker* tracker) const { tracker->TrackField("rules", rules_); -} - -void SocketAddressBlockList::SocketAddressRule::MemoryInfo( - node::MemoryTracker* tracker) const { - tracker->TrackField("address", address); + tracker->TrackFieldWithSize("address_rules", + address_rules_.size() * sizeof(SocketAddress)); + tracker->TrackField("subnet_rules", subnet_rules_); } void SocketAddressBlockList::SocketAddressRangeRule::MemoryInfo( @@ -590,8 +827,34 @@ void SocketAddressBlockListWrap::AddAddress( SocketAddressBase* addr; ASSIGN_OR_RETURN_UNWRAP(&addr, args[0]); - wrap->blocklist_->AddSocketAddress(addr->address()); + wrap->blocklist_->AddSocketAddress(*addr->address()); + + args.GetReturnValue().Set(true); +} + +void SocketAddressBlockListWrap::AddAddresses( + const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + SocketAddressBlockListWrap* wrap; + ASSIGN_OR_RETURN_UNWRAP(&wrap, args.This()); + + CHECK(args[0]->IsArray()); + Local arr = args[0].As(); + uint32_t len = arr->Length(); + std::vector addresses; + addresses.reserve(len); + + for (uint32_t i = 0; i < len; i++) { + Local item; + if (!arr->Get(env->context(), i).ToLocal(&item)) return; + CHECK(SocketAddressBase::HasInstance(env, item)); + SocketAddressBase* addr; + ASSIGN_OR_RETURN_UNWRAP(&addr, item.As()); + addresses.push_back(*addr->address()); + } + + wrap->blocklist_->AddSocketAddresses(addresses.data(), addresses.size()); args.GetReturnValue().Set(true); } @@ -610,11 +873,11 @@ void SocketAddressBlockListWrap::AddRange( ASSIGN_OR_RETURN_UNWRAP(&end_addr, args[1]); // Starting address must come before the end address - if (*start_addr->address().get() > *end_addr->address().get()) + if (*start_addr->address() > *end_addr->address()) return args.GetReturnValue().Set(false); - wrap->blocklist_->AddSocketAddressRange(start_addr->address(), - end_addr->address()); + wrap->blocklist_->AddSocketAddressRange(*start_addr->address(), + *end_addr->address()); args.GetReturnValue().Set(true); } @@ -640,11 +903,62 @@ void SocketAddressBlockListWrap::AddSubnet( CHECK_IMPLIES(addr->address()->family() == AF_INET6, prefix <= 128); CHECK_GE(prefix, 0); - wrap->blocklist_->AddSocketAddressMask(addr->address(), prefix); + wrap->blocklist_->AddSocketAddressMask(*addr->address(), prefix); args.GetReturnValue().Set(true); } +void SocketAddressBlockListWrap::RemoveAddress( + const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + SocketAddressBlockListWrap* wrap; + ASSIGN_OR_RETURN_UNWRAP(&wrap, args.This()); + + CHECK(SocketAddressBase::HasInstance(env, args[0])); + SocketAddressBase* addr; + ASSIGN_OR_RETURN_UNWRAP(&addr, args[0]); + + wrap->blocklist_->RemoveSocketAddress(*addr->address()); +} + +void SocketAddressBlockListWrap::RemoveRange( + const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + SocketAddressBlockListWrap* wrap; + ASSIGN_OR_RETURN_UNWRAP(&wrap, args.This()); + + CHECK(SocketAddressBase::HasInstance(env, args[0])); + CHECK(SocketAddressBase::HasInstance(env, args[1])); + + SocketAddressBase* start_addr; + SocketAddressBase* end_addr; + ASSIGN_OR_RETURN_UNWRAP(&start_addr, args[0]); + ASSIGN_OR_RETURN_UNWRAP(&end_addr, args[1]); + + wrap->blocklist_->RemoveSocketAddressRange(*start_addr->address(), + *end_addr->address()); +} + +void SocketAddressBlockListWrap::RemoveSubnet( + const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + SocketAddressBlockListWrap* wrap; + ASSIGN_OR_RETURN_UNWRAP(&wrap, args.This()); + + CHECK(SocketAddressBase::HasInstance(env, args[0])); + CHECK(args[1]->IsInt32()); + + SocketAddressBase* addr; + ASSIGN_OR_RETURN_UNWRAP(&addr, args[0]); + + int32_t prefix; + if (!args[1]->Int32Value(env->context()).To(&prefix)) { + return; + } + + wrap->blocklist_->RemoveSocketAddressMask(*addr->address(), prefix); +} + void SocketAddressBlockListWrap::Check( const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); @@ -658,6 +972,39 @@ void SocketAddressBlockListWrap::Check( args.GetReturnValue().Set(wrap->blocklist_->Apply(*addr->address())); } +bool SocketAddressBlockListWrap::FastCheck(Local receiver, + Local addr_obj) { + TRACK_V8_FAST_API_CALL("blocklist.check"); + SocketAddressBlockListWrap* wrap = + FromJSObject(receiver); + SocketAddressBase* addr = FromJSObject(addr_obj); + return wrap->blocklist_->Apply(*addr->address()); +} + +CFunction SocketAddressBlockListWrap::fast_check_( + CFunction::Make(&SocketAddressBlockListWrap::FastCheck)); + +void SocketAddressBlockListWrap::CheckString( + const FunctionCallbackInfo& args) { + SocketAddressBlockListWrap* wrap; + ASSIGN_OR_RETURN_UNWRAP(&wrap, args.This()); + + CHECK(args[0]->IsString()); + CHECK(args[1]->IsInt32()); + + Utf8Value address(args.GetIsolate(), args[0]); + int32_t family = args[1].As()->Value(); + + SocketAddress addr; + if (!SocketAddress::New(family, *address, 0, &addr)) { + // Invalid address string — return false (not blocked). + args.GetReturnValue().Set(false); + return; + } + + args.GetReturnValue().Set(wrap->blocklist_->Apply(addr)); +} + void SocketAddressBlockListWrap::GetRules( const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); @@ -668,6 +1015,20 @@ void SocketAddressBlockListWrap::GetRules( args.GetReturnValue().Set(rules); } +void SocketAddressBlockListWrap::GetSize( + const FunctionCallbackInfo& args) { + SocketAddressBlockListWrap* wrap; + ASSIGN_OR_RETURN_UNWRAP(&wrap, args.This()); + args.GetReturnValue().Set(static_cast(wrap->blocklist_->size())); +} + +void SocketAddressBlockListWrap::Clear( + const FunctionCallbackInfo& args) { + SocketAddressBlockListWrap* wrap; + ASSIGN_OR_RETURN_UNWRAP(&wrap, args.This()); + wrap->blocklist_->Clear(); +} + void SocketAddressBlockListWrap::MemoryInfo(MemoryTracker* tracker) const { blocklist_->MemoryInfo(tracker); } @@ -691,10 +1052,18 @@ Local SocketAddressBlockListWrap::GetConstructorTemplate( tmpl->SetClassName(FIXED_ONE_BYTE_STRING(env->isolate(), "BlockList")); tmpl->InstanceTemplate()->SetInternalFieldCount(kInternalFieldCount); SetProtoMethod(isolate, tmpl, "addAddress", AddAddress); + SetProtoMethod(isolate, tmpl, "addAddresses", AddAddresses); SetProtoMethod(isolate, tmpl, "addRange", AddRange); SetProtoMethod(isolate, tmpl, "addSubnet", AddSubnet); - SetProtoMethod(isolate, tmpl, "check", Check); + SetProtoMethod(isolate, tmpl, "removeAddress", RemoveAddress); + SetProtoMethod(isolate, tmpl, "removeRange", RemoveRange); + SetProtoMethod(isolate, tmpl, "removeSubnet", RemoveSubnet); + SetFastMethod( + isolate, tmpl->PrototypeTemplate(), "check", Check, &fast_check_); + SetProtoMethod(isolate, tmpl, "checkString", CheckString); SetProtoMethod(isolate, tmpl, "getRules", GetRules); + SetProtoMethodNoSideEffect(isolate, tmpl, "getSize", GetSize); + SetProtoMethod(isolate, tmpl, "clear", Clear); env->set_blocklist_constructor_template(tmpl); } return tmpl; diff --git a/src/node_sockaddr.h b/src/node_sockaddr.h index 05bb127b012f..55354138fa84 100644 --- a/src/node_sockaddr.h +++ b/src/node_sockaddr.h @@ -248,19 +248,29 @@ class SocketAddressBlockList : public MemoryRetainer { std::shared_ptr parent = {}); ~SocketAddressBlockList() = default; - void AddSocketAddress(const std::shared_ptr& address); + void AddSocketAddress(const SocketAddress& address); - void RemoveSocketAddress(const std::shared_ptr& address); + void AddSocketAddresses(const SocketAddress* addresses, size_t count); - void AddSocketAddressRange(const std::shared_ptr& start, - const std::shared_ptr& end); + void RemoveSocketAddress(const SocketAddress& address); - void AddSocketAddressMask(const std::shared_ptr& address, - int prefix); + void AddSocketAddressRange(const SocketAddress& start, + const SocketAddress& end); + + void RemoveSocketAddressRange(const SocketAddress& start, + const SocketAddress& end); + + void AddSocketAddressMask(const SocketAddress& address, int prefix); + + void RemoveSocketAddressMask(const SocketAddress& address, int prefix); bool Apply(const SocketAddress& address); - size_t size() const { return rules_.size(); } + void Clear(); + + size_t size() const { + return address_count_ + rules_.size() + subnet_rules_.size(); + } v8::MaybeLocal ListRules(Environment* env); @@ -270,25 +280,12 @@ class SocketAddressBlockList : public MemoryRetainer { virtual std::string ToString() = 0; }; - struct SocketAddressRule final : Rule { - std::shared_ptr address; - - explicit SocketAddressRule(const std::shared_ptr& address); - - bool Apply(const SocketAddress& address) override; - std::string ToString() override; - - void MemoryInfo(node::MemoryTracker* tracker) const override; - SET_MEMORY_INFO_NAME(SocketAddressRule) - SET_SELF_SIZE(SocketAddressRule) - }; - struct SocketAddressRangeRule final : Rule { - std::shared_ptr start; - std::shared_ptr end; + SocketAddress start; + SocketAddress end; - SocketAddressRangeRule(const std::shared_ptr& start, - const std::shared_ptr& end); + SocketAddressRangeRule(const SocketAddress& start, + const SocketAddress& end); bool Apply(const SocketAddress& address) override; std::string ToString() override; @@ -299,11 +296,10 @@ class SocketAddressBlockList : public MemoryRetainer { }; struct SocketAddressMaskRule final : Rule { - std::shared_ptr network; + SocketAddress network; int prefix; - SocketAddressMaskRule(const std::shared_ptr& address, - int prefix); + SocketAddressMaskRule(const SocketAddress& address, int prefix); bool Apply(const SocketAddress& address) override; std::string ToString() override; @@ -317,14 +313,68 @@ class SocketAddressBlockList : public MemoryRetainer { SET_MEMORY_INFO_NAME(SocketAddressBlockList) SET_SELF_SIZE(SocketAddressBlockList) + // A compressed radix trie for O(prefix_length) subnet lookups. + // Each node has two children (bit 0, bit 1). A node marked + // terminal means all addresses matching the prefix up to that + // depth are blocked. On insert, if a new prefix is shorter than + // or equal to an existing one, the subtree is pruned (the shorter + // prefix subsumes all longer ones). On lookup, we walk the bits + // of the address and return true as soon as we hit a terminal node. + class SubnetTrie { + public: + SubnetTrie() = default; + ~SubnetTrie() = default; + + // Insert a subnet (network address bytes, prefix length in bits). + // If a broader prefix already exists, the insert is a no-op. + // If this prefix is broader than existing children, they are pruned. + void Insert(const uint8_t* address_bytes, int prefix_length); + + // Returns true if the given address falls within any inserted subnet. + bool Lookup(const uint8_t* address_bytes, int address_bits) const; + + // Remove all entries. + void Clear(); + + bool empty() const { return root_ == nullptr; } + + size_t size() const { return count_; } + + private: + struct Node { + std::unique_ptr children[2]; + bool terminal = false; + }; + + std::unique_ptr root_; + size_t count_ = 0; + }; + private: + // Lock-free implementation used by both AddSocketAddress and + // AddSocketAddresses. Caller must hold the write lock. + void AddSocketAddressImpl(const SocketAddress& address); bool ListRules(Environment* env, v8::LocalVector* vec); std::shared_ptr parent_; + // Range rules only. Scanned linearly by Apply(). std::list> rules_; - SocketAddress::Map>::iterator> address_rules_; - - Mutex mutex_; + // Exact address rules. Keyed by IP only (port-insensitive) so that + // Apply() can perform O(1) lookups regardless of the port on the + // checked address. Not included in rules_ to avoid redundant scanning. + SocketAddress::IpMap address_rules_; + // User-visible address count (not inflated by cross-family dual-insert). + size_t address_count_ = 0; + // Subnet/mask rules stored in radix tries for O(prefix_length) lookup. + // Separate tries for IPv4 (max 32-bit depth) and IPv6 (max 128-bit). + SubnetTrie ipv4_subnets_; + SubnetTrie ipv6_subnets_; + // Subnet metadata kept for ListRules serialization only. + std::list> subnet_rules_; + + // RwLock allows concurrent Apply() calls (shared/read lock) while + // mutations (Add*/Remove*/Clear) take an exclusive/write lock. + mutable RwLock mutex_; }; class SocketAddressBlockListWrap : public BaseObject { @@ -343,10 +393,19 @@ class SocketAddressBlockListWrap : public BaseObject { static void New(const v8::FunctionCallbackInfo& args); static void AddAddress(const v8::FunctionCallbackInfo& args); + static void AddAddresses(const v8::FunctionCallbackInfo& args); static void AddRange(const v8::FunctionCallbackInfo& args); static void AddSubnet(const v8::FunctionCallbackInfo& args); + static void RemoveAddress(const v8::FunctionCallbackInfo& args); + static void RemoveRange(const v8::FunctionCallbackInfo& args); + static void RemoveSubnet(const v8::FunctionCallbackInfo& args); static void Check(const v8::FunctionCallbackInfo& args); + static bool FastCheck(v8::Local receiver, + v8::Local addr_obj); + static void CheckString(const v8::FunctionCallbackInfo& args); static void GetRules(const v8::FunctionCallbackInfo& args); + static void GetSize(const v8::FunctionCallbackInfo& args); + static void Clear(const v8::FunctionCallbackInfo& args); SocketAddressBlockListWrap(Environment* env, v8::Local wrap, @@ -390,6 +449,7 @@ class SocketAddressBlockListWrap : public BaseObject { private: std::shared_ptr blocklist_; + static v8::CFunction fast_check_; }; } // namespace node diff --git a/test/cctest/test_sockaddr.cc b/test/cctest/test_sockaddr.cc index a4feefd6f4b3..adb15f9f84cf 100644 --- a/test/cctest/test_sockaddr.cc +++ b/test/cctest/test_sockaddr.cc @@ -272,6 +272,127 @@ TEST(SocketAddress, Comparison) { CHECK(addr2 >= addr5); } +TEST(SocketAddress, NewAutoFamily) { + // SocketAddress::New(host, port) without explicit family. + // Tries AF_INET first, then AF_INET6. + SocketAddress addr; + + // IPv4 address should succeed. + CHECK(SocketAddress::New("192.168.1.1", 8080, &addr)); + CHECK_EQ(addr.family(), AF_INET); + CHECK_EQ(addr.address(), "192.168.1.1"); + CHECK_EQ(addr.port(), 8080); + + // IPv6 address should succeed (fails AF_INET, falls through to AF_INET6). + CHECK(SocketAddress::New("::1", 443, &addr)); + CHECK_EQ(addr.family(), AF_INET6); + CHECK_EQ(addr.address(), "::1"); + CHECK_EQ(addr.port(), 443); + + // Invalid address should fail. + CHECK(!SocketAddress::New("not_an_address", 0, &addr)); +} + +TEST(SocketAddress, HashIPv6) { + sockaddr_storage s1, s2, s3; + SocketAddress::ToSockAddr(AF_INET6, "::1", 443, &s1); + SocketAddress::ToSockAddr(AF_INET6, "::1", 443, &s2); + SocketAddress::ToSockAddr(AF_INET6, "::2", 443, &s3); + + SocketAddress a1(reinterpret_cast(&s1)); + SocketAddress a2(reinterpret_cast(&s2)); + SocketAddress a3(reinterpret_cast(&s3)); + + // Same address and port: hash must be equal. + CHECK_EQ(SocketAddress::Hash()(a1), SocketAddress::Hash()(a2)); + + // Different address: hash should (very likely) differ. + CHECK_NE(SocketAddress::Hash()(a1), SocketAddress::Hash()(a3)); +} + +TEST(SocketAddress, IsMatchCrossFamily) { + sockaddr_storage s1, s2, s3, s4; + SocketAddress::ToSockAddr(AF_INET, "10.0.0.1", 0, &s1); + SocketAddress::ToSockAddr(AF_INET6, "::ffff:10.0.0.1", 0, &s2); + SocketAddress::ToSockAddr(AF_INET6, "::1", 0, &s3); + SocketAddress::ToSockAddr(AF_INET, "10.0.0.2", 0, &s4); + + SocketAddress ipv4(reinterpret_cast(&s1)); + SocketAddress mapped(reinterpret_cast(&s2)); + SocketAddress ipv6(reinterpret_cast(&s3)); + SocketAddress other(reinterpret_cast(&s4)); + + // IPv4 matches its IPv4-mapped IPv6 counterpart. + CHECK(ipv4.is_match(mapped)); + CHECK(mapped.is_match(ipv4)); + + // IPv4 does not match a non-mapped IPv6 address. + CHECK(!ipv4.is_match(ipv6)); + CHECK(!ipv6.is_match(ipv4)); + + // Same family, different address. + CHECK(!ipv4.is_match(other)); + + // Self-match. + CHECK(ipv4.is_match(ipv4)); + CHECK(ipv6.is_match(ipv6)); +} + +TEST(SocketAddress, InNetworkIPv4) { + sockaddr_storage s1, s2, s3; + SocketAddress::ToSockAddr(AF_INET, "192.168.1.100", 0, &s1); + SocketAddress::ToSockAddr(AF_INET, "192.168.0.0", 0, &s2); + SocketAddress::ToSockAddr(AF_INET, "10.0.0.1", 0, &s3); + + SocketAddress addr(reinterpret_cast(&s1)); + SocketAddress net(reinterpret_cast(&s2)); + SocketAddress outside(reinterpret_cast(&s3)); + + CHECK(addr.is_in_network(net, 16)); + CHECK(!outside.is_in_network(net, 16)); + CHECK(!addr.is_in_network(net, 24)); // 192.168.1.x != 192.168.0.x +} + +TEST(SocketAddress, InNetworkIPv6) { + sockaddr_storage s1, s2, s3; + SocketAddress::ToSockAddr(AF_INET6, "2001:db8::1", 0, &s1); + SocketAddress::ToSockAddr(AF_INET6, "2001:db8::", 0, &s2); + SocketAddress::ToSockAddr(AF_INET6, "2001:db9::1", 0, &s3); + + SocketAddress addr(reinterpret_cast(&s1)); + SocketAddress net(reinterpret_cast(&s2)); + SocketAddress outside(reinterpret_cast(&s3)); + + CHECK(addr.is_in_network(net, 32)); + CHECK(!outside.is_in_network(net, 32)); + + // /128 prefix == exact match. + CHECK(addr.is_in_network(addr, 128)); + CHECK(!outside.is_in_network(addr, 128)); +} + +TEST(SocketAddress, InNetworkCrossFamily) { + sockaddr_storage s1, s2, s3; + SocketAddress::ToSockAddr(AF_INET, "10.0.0.1", 0, &s1); + SocketAddress::ToSockAddr(AF_INET6, "::ffff:10.0.0.0", 0, &s2); + SocketAddress::ToSockAddr(AF_INET6, "::ffff:10.0.0.1", 0, &s3); + + SocketAddress ipv4(reinterpret_cast(&s1)); + SocketAddress net6(reinterpret_cast(&s2)); + SocketAddress mapped(reinterpret_cast(&s3)); + + // IPv4 address in an IPv4-mapped IPv6 subnet. + CHECK(ipv4.is_in_network(net6, 120)); // prefix 120 = /24 on the IPv4 part + CHECK(mapped.is_in_network(net6, 120)); + + // IPv6 address in IPv4 network. + sockaddr_storage s4; + SocketAddress::ToSockAddr(AF_INET, "10.0.0.0", 0, &s4); + SocketAddress net4(reinterpret_cast(&s4)); + + CHECK(mapped.is_in_network(net4, 24)); +} + TEST(SocketAddressBlockList, Simple) { SocketAddressBlockList bl; @@ -283,14 +404,299 @@ TEST(SocketAddressBlockList, Simple) { std::shared_ptr addr2 = std::make_shared( reinterpret_cast(&storage[1])); - bl.AddSocketAddress(addr1); - bl.AddSocketAddress(addr2); + bl.AddSocketAddress(*addr1); + bl.AddSocketAddress(*addr2); CHECK(bl.Apply(*addr1)); CHECK(bl.Apply(*addr2)); - bl.RemoveSocketAddress(addr1); + bl.RemoveSocketAddress(*addr1); CHECK(!bl.Apply(*addr1)); CHECK(bl.Apply(*addr2)); } + +TEST(SocketAddressBlockList, CrossFamilyAddress) { + SocketAddressBlockList bl; + + sockaddr_storage s1, s2, s3; + SocketAddress::ToSockAddr(AF_INET, "10.0.0.1", 0, &s1); + SocketAddress::ToSockAddr(AF_INET6, "::ffff:10.0.0.1", 0, &s2); + SocketAddress::ToSockAddr(AF_INET6, "::1", 0, &s3); + + SocketAddress ipv4(reinterpret_cast(&s1)); + SocketAddress mapped(reinterpret_cast(&s2)); + SocketAddress other(reinterpret_cast(&s3)); + + // Adding IPv4 should also match the IPv4-mapped IPv6 form. + bl.AddSocketAddress(ipv4); + CHECK(bl.Apply(ipv4)); + CHECK(bl.Apply(mapped)); + CHECK(!bl.Apply(other)); + + // Remove should clean up cross-family counterpart. + bl.RemoveSocketAddress(ipv4); + CHECK(!bl.Apply(ipv4)); + CHECK(!bl.Apply(mapped)); +} + +TEST(SocketAddressBlockList, CrossFamilyAddressIPv6) { + SocketAddressBlockList bl; + + sockaddr_storage s1, s2; + SocketAddress::ToSockAddr(AF_INET6, "::ffff:192.168.1.1", 0, &s1); + SocketAddress::ToSockAddr(AF_INET, "192.168.1.1", 0, &s2); + + SocketAddress mapped(reinterpret_cast(&s1)); + SocketAddress ipv4(reinterpret_cast(&s2)); + + // Adding an IPv4-mapped IPv6 address should also match the IPv4 form. + bl.AddSocketAddress(mapped); + CHECK(bl.Apply(mapped)); + CHECK(bl.Apply(ipv4)); + + // Remove the IPv6 form should clean up the IPv4 counterpart. + bl.RemoveSocketAddress(mapped); + CHECK(!bl.Apply(mapped)); + CHECK(!bl.Apply(ipv4)); +} + +TEST(SocketAddressBlockList, BatchAddresses) { + SocketAddressBlockList bl; + + sockaddr_storage storage[3]; + SocketAddress::ToSockAddr(AF_INET, "1.1.1.1", 0, &storage[0]); + SocketAddress::ToSockAddr(AF_INET, "2.2.2.2", 0, &storage[1]); + SocketAddress::ToSockAddr(AF_INET, "3.3.3.3", 0, &storage[2]); + + SocketAddress addrs[3] = { + SocketAddress(reinterpret_cast(&storage[0])), + SocketAddress(reinterpret_cast(&storage[1])), + SocketAddress(reinterpret_cast(&storage[2])), + }; + + bl.AddSocketAddresses(addrs, 3); + + CHECK(bl.Apply(addrs[0])); + CHECK(bl.Apply(addrs[1])); + CHECK(bl.Apply(addrs[2])); + + sockaddr_storage s4; + SocketAddress::ToSockAddr(AF_INET, "4.4.4.4", 0, &s4); + SocketAddress addr4(reinterpret_cast(&s4)); + CHECK(!bl.Apply(addr4)); +} + +TEST(SocketAddressBlockList, Range) { + SocketAddressBlockList bl; + + sockaddr_storage s1, s2, s3, s4, s5; + SocketAddress::ToSockAddr(AF_INET, "10.0.0.1", 0, &s1); + SocketAddress::ToSockAddr(AF_INET, "10.0.0.10", 0, &s2); + SocketAddress::ToSockAddr(AF_INET, "10.0.0.5", 0, &s3); + SocketAddress::ToSockAddr(AF_INET, "10.0.0.11", 0, &s4); + SocketAddress::ToSockAddr(AF_INET, "10.0.0.0", 0, &s5); + + SocketAddress start(reinterpret_cast(&s1)); + SocketAddress end(reinterpret_cast(&s2)); + SocketAddress mid(reinterpret_cast(&s3)); + SocketAddress above(reinterpret_cast(&s4)); + SocketAddress below(reinterpret_cast(&s5)); + + bl.AddSocketAddressRange(start, end); + + CHECK(bl.Apply(start)); + CHECK(bl.Apply(end)); + CHECK(bl.Apply(mid)); + CHECK(!bl.Apply(above)); + CHECK(!bl.Apply(below)); + + // Remove range. + bl.RemoveSocketAddressRange(start, end); + CHECK(!bl.Apply(mid)); +} + +TEST(SocketAddressBlockList, Subnet) { + SocketAddressBlockList bl; + + sockaddr_storage s1, s2, s3; + SocketAddress::ToSockAddr(AF_INET, "192.168.1.0", 0, &s1); + SocketAddress::ToSockAddr(AF_INET, "192.168.1.100", 0, &s2); + SocketAddress::ToSockAddr(AF_INET, "192.168.2.1", 0, &s3); + + SocketAddress net(reinterpret_cast(&s1)); + SocketAddress inside(reinterpret_cast(&s2)); + SocketAddress outside(reinterpret_cast(&s3)); + + bl.AddSocketAddressMask(net, 24); + + CHECK(bl.Apply(inside)); + CHECK(!bl.Apply(outside)); + + // Remove subnet. + bl.RemoveSocketAddressMask(net, 24); + CHECK(!bl.Apply(inside)); +} + +TEST(SocketAddressBlockList, SubnetIPv6) { + SocketAddressBlockList bl; + + sockaddr_storage s1, s2, s3; + SocketAddress::ToSockAddr(AF_INET6, "2001:db8::", 0, &s1); + SocketAddress::ToSockAddr(AF_INET6, "2001:db8::1", 0, &s2); + SocketAddress::ToSockAddr(AF_INET6, "2001:db9::1", 0, &s3); + + SocketAddress net(reinterpret_cast(&s1)); + SocketAddress inside(reinterpret_cast(&s2)); + SocketAddress outside(reinterpret_cast(&s3)); + + bl.AddSocketAddressMask(net, 32); + + CHECK(bl.Apply(inside)); + CHECK(!bl.Apply(outside)); + + bl.RemoveSocketAddressMask(net, 32); + CHECK(!bl.Apply(inside)); +} + +TEST(SocketAddressBlockList, SubnetCrossFamily) { + SocketAddressBlockList bl; + + // Adding an IPv4 subnet should also match IPv4-mapped IPv6 addresses. + sockaddr_storage s1, s2, s3; + SocketAddress::ToSockAddr(AF_INET, "10.0.0.0", 0, &s1); + SocketAddress::ToSockAddr(AF_INET, "10.0.0.5", 0, &s2); + SocketAddress::ToSockAddr(AF_INET6, "::ffff:10.0.0.5", 0, &s3); + + SocketAddress net(reinterpret_cast(&s1)); + SocketAddress ipv4(reinterpret_cast(&s2)); + SocketAddress mapped(reinterpret_cast(&s3)); + + bl.AddSocketAddressMask(net, 24); + + CHECK(bl.Apply(ipv4)); + CHECK(bl.Apply(mapped)); +} + +TEST(SocketAddressBlockList, ClearAll) { + SocketAddressBlockList bl; + + sockaddr_storage s1, s2, s3; + SocketAddress::ToSockAddr(AF_INET, "1.1.1.1", 0, &s1); + SocketAddress::ToSockAddr(AF_INET, "10.0.0.1", 0, &s2); + SocketAddress::ToSockAddr(AF_INET, "192.168.0.0", 0, &s3); + + SocketAddress addr(reinterpret_cast(&s1)); + SocketAddress rangeStart(reinterpret_cast(&s2)); + SocketAddress subnet(reinterpret_cast(&s3)); + + sockaddr_storage s4; + SocketAddress::ToSockAddr(AF_INET, "10.0.0.10", 0, &s4); + SocketAddress rangeEnd(reinterpret_cast(&s4)); + + bl.AddSocketAddress(addr); + bl.AddSocketAddressRange(rangeStart, rangeEnd); + bl.AddSocketAddressMask(subnet, 16); + + CHECK(bl.Apply(addr)); + CHECK(bl.Apply(rangeStart)); + + sockaddr_storage s5; + SocketAddress::ToSockAddr(AF_INET, "192.168.1.1", 0, &s5); + SocketAddress subnetAddr(reinterpret_cast(&s5)); + CHECK(bl.Apply(subnetAddr)); + + bl.Clear(); + + CHECK(!bl.Apply(addr)); + CHECK(!bl.Apply(rangeStart)); + CHECK(!bl.Apply(subnetAddr)); +} + +TEST(SocketAddressBlockList, ParentBlockList) { + auto parent = std::make_shared(); + SocketAddressBlockList child(parent); + + sockaddr_storage s1, s2; + SocketAddress::ToSockAddr(AF_INET, "1.1.1.1", 0, &s1); + SocketAddress::ToSockAddr(AF_INET, "2.2.2.2", 0, &s2); + + SocketAddress addr1(reinterpret_cast(&s1)); + SocketAddress addr2(reinterpret_cast(&s2)); + + parent->AddSocketAddress(addr1); + child.AddSocketAddress(addr2); + + // Child should match both its own rules and parent's. + CHECK(child.Apply(addr1)); + CHECK(child.Apply(addr2)); + + // Parent should only match its own rules. + CHECK(parent->Apply(addr1)); + CHECK(!parent->Apply(addr2)); +} + +TEST(SocketAddressBlockList, SubnetOverlapRemoval) { + // Removing a broader subnet must restore narrower subnets that were + // subsumed by the broader prefix in the trie. + SocketAddressBlockList bl; + + sockaddr_storage s1, s2, s3; + SocketAddress::ToSockAddr(AF_INET, "10.0.0.0", 0, &s1); + SocketAddress::ToSockAddr(AF_INET, "10.1.0.0", 0, &s2); + SocketAddress::ToSockAddr(AF_INET, "10.1.2.3", 0, &s3); + + SocketAddress broad(reinterpret_cast(&s1)); + SocketAddress narrow(reinterpret_cast(&s2)); + SocketAddress target(reinterpret_cast(&s3)); + + bl.AddSocketAddressMask(broad, 8); // 10.0.0.0/8 + bl.AddSocketAddressMask(narrow, 16); // 10.1.0.0/16 (subsumed by /8) + + CHECK(bl.Apply(target)); // Covered by /8. + + bl.RemoveSocketAddressMask(broad, 8); + + // After removing /8, the /16 must still work. + CHECK(bl.Apply(target)); + + // Address outside /16 but inside old /8 should no longer match. + sockaddr_storage s4; + SocketAddress::ToSockAddr(AF_INET, "10.2.0.1", 0, &s4); + SocketAddress outside(reinterpret_cast(&s4)); + CHECK(!bl.Apply(outside)); +} + +TEST(SocketAddressBlockList, SubnetRemoveMixedFamily) { + // Removing one family's subnet must correctly rebuild the remaining + // rules, including those from the other family. + SocketAddressBlockList bl; + + sockaddr_storage s1, s2, s3, s4; + SocketAddress::ToSockAddr(AF_INET, "192.168.0.0", 0, &s1); + SocketAddress::ToSockAddr(AF_INET6, "2001:db8::", 0, &s2); + SocketAddress::ToSockAddr(AF_INET, "192.168.1.1", 0, &s3); + SocketAddress::ToSockAddr(AF_INET6, "2001:db8::1", 0, &s4); + + SocketAddress ipv4Net(reinterpret_cast(&s1)); + SocketAddress ipv6Net(reinterpret_cast(&s2)); + SocketAddress ipv4Addr(reinterpret_cast(&s3)); + SocketAddress ipv6Addr(reinterpret_cast(&s4)); + + bl.AddSocketAddressMask(ipv4Net, 16); + bl.AddSocketAddressMask(ipv6Net, 32); + + CHECK(bl.Apply(ipv4Addr)); + CHECK(bl.Apply(ipv6Addr)); + + // Remove IPv4 subnet — IPv6 subnet must survive the rebuild. + bl.RemoveSocketAddressMask(ipv4Net, 16); + CHECK(!bl.Apply(ipv4Addr)); + CHECK(bl.Apply(ipv6Addr)); + + // Re-add IPv4, then remove IPv6 — IPv4 must survive. + bl.AddSocketAddressMask(ipv4Net, 16); + bl.RemoveSocketAddressMask(ipv6Net, 32); + CHECK(bl.Apply(ipv4Addr)); + CHECK(!bl.Apply(ipv6Addr)); +} diff --git a/test/parallel/test-blocklist-fast-api.js b/test/parallel/test-blocklist-fast-api.js new file mode 100644 index 000000000000..3c59ad361d1a --- /dev/null +++ b/test/parallel/test-blocklist-fast-api.js @@ -0,0 +1,42 @@ +// Flags: --allow-natives-syntax --expose-internals --no-warnings +'use strict'; + +const common = require('../common'); +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. +const { kHandle: kBlockListHandle } = require('internal/blocklist'); +const { + SocketAddress, + kHandle: kSocketAddressHandle, +} = require('internal/socketaddress'); + +const blockList = new BlockList(); +blockList.addAddress('1.1.1.1'); +blockList.addSubnet('10.0.0.0', 24); + +const handle = blockList[kBlockListHandle]; +const addr1 = new SocketAddress({ address: '1.1.1.1' })[kSocketAddressHandle]; +const addr2 = new SocketAddress({ address: '2.2.2.2' })[kSocketAddressHandle]; +const addr3 = new SocketAddress({ address: '10.0.0.5' })[kSocketAddressHandle]; + +function testFastCheck() { + assert.strictEqual(handle.check(addr1), true); + assert.strictEqual(handle.check(addr2), false); + assert.strictEqual(handle.check(addr3), true); +} + +eval('%PrepareFunctionForOptimization(testFastCheck)'); +testFastCheck(); +eval('%OptimizeFunctionOnNextCall(testFastCheck)'); +testFastCheck(); + +if (common.isDebug) { + const { getV8FastApiCallCount } = internalBinding('debug'); + assert.strictEqual(getV8FastApiCallCount('blocklist.check'), 3); +} diff --git a/test/parallel/test-blocklist.js b/test/parallel/test-blocklist.js index 6895efcc1c00..08a293bb6a81 100644 --- a/test/parallel/test-blocklist.js +++ b/test/parallel/test-blocklist.js @@ -178,11 +178,11 @@ const util = require('util'); blockList.addSubnet('8592:757c:efae:4e45::', 64, 'IpV6'); // Case insensitive const rulesCheck = [ + 'Address: IPv4 1.1.1.1', 'Subnet: IPv6 8592:757c:efae:4e45::/64', 'Range: IPv4 10.0.0.1-10.0.0.10', - 'Address: IPv4 1.1.1.1', ]; - assert.deepStrictEqual(blockList.rules, rulesCheck); + assert.deepStrictEqual(blockList.rules.sort(), rulesCheck.sort()); assert(blockList.check('1.1.1.1')); assert(blockList.check('10.0.0.5')); @@ -288,6 +288,88 @@ const util = require('util'); assert(!BlockList.isBlockList({})); } +{ + // Test that adding the same address twice does not create duplicate rules. + // Previously, the second add would orphan the first rule in the internal + // list while overwriting its index entry, making it unreachable for removal + // but still evaluated during checks. + const blockList = new BlockList(); + blockList.addAddress('1.1.1.1'); + blockList.addAddress('1.1.1.1'); + + // Should have exactly one rule, not two. + assert.strictEqual(blockList.rules.length, 1); + assert(blockList.check('1.1.1.1')); +} + +{ + // Test clear() removes all rules. + const blockList = new BlockList(); + blockList.addAddress('1.1.1.1'); + blockList.addRange('10.0.0.1', '10.0.0.10'); + blockList.addSubnet('192.168.0.0', 16); + + assert.strictEqual(blockList.rules.length, 3); + assert(blockList.check('1.1.1.1')); + assert(blockList.check('10.0.0.5')); + assert(blockList.check('192.168.1.1')); + + blockList.clear(); + + assert.strictEqual(blockList.rules.length, 0); + assert(!blockList.check('1.1.1.1')); + assert(!blockList.check('10.0.0.5')); + assert(!blockList.check('192.168.1.1')); + + // Can add new rules after clearing. + blockList.addAddress('2.2.2.2'); + assert.strictEqual(blockList.rules.length, 1); + assert(blockList.check('2.2.2.2')); + assert(!blockList.check('1.1.1.1')); +} + +{ + // addAddresses() validation: non-array argument throws. + const blockList0 = new BlockList(); + assert.throws(() => blockList0.addAddresses('not-an-array'), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => blockList0.addAddresses(123), { + code: 'ERR_INVALID_ARG_TYPE', + }); +} + +{ + // Test addAddresses() batch insert. + const blockList = new BlockList(); + blockList.addAddresses(['1.1.1.1', '2.2.2.2', '3.3.3.3']); + + assert(blockList.check('1.1.1.1')); + assert(blockList.check('2.2.2.2')); + assert(blockList.check('3.3.3.3')); + assert(!blockList.check('4.4.4.4')); + assert.strictEqual(blockList.rules.length, 3); + + // Cross-family works with batch insert. + assert(blockList.check('::ffff:1.1.1.1', 'ipv6')); + + // Batch with SocketAddress objects. + const blockList2 = new BlockList(); + const sa1 = new SocketAddress({ address: '10.0.0.1' }); + const sa2 = new SocketAddress({ address: '10.0.0.2' }); + blockList2.addAddresses([sa1, sa2]); + assert(blockList2.check('10.0.0.1')); + assert(blockList2.check('10.0.0.2')); + assert(!blockList2.check('10.0.0.3')); + + // IPv6 batch. + const blockList3 = new BlockList(); + blockList3.addAddresses(['::1', '::2'], 'ipv6'); + assert(blockList3.check('::1', 'ipv6')); + assert(blockList3.check('::2', 'ipv6')); + assert(!blockList3.check('::3', 'ipv6')); +} + // Test exporting and importing the rule list to/from JSON { const ruleList = [ @@ -359,3 +441,489 @@ const util = require('util'); assert.strictEqual(test5.check(i[0], i[1]), i[2]); }); } + +// removeRange: basic removal +{ + const blockList = new BlockList(); + blockList.addRange('10.0.0.1', '10.0.0.100'); + blockList.addRange('192.168.1.1', '192.168.1.50'); + assert(blockList.check('10.0.0.50')); + assert(blockList.check('192.168.1.25')); + + blockList.removeRange('10.0.0.1', '10.0.0.100'); + assert(!blockList.check('10.0.0.50')); + assert(blockList.check('192.168.1.25')); + assert.strictEqual(blockList.rules.length, 1); +} + +// removeRange: non-existent range is a no-op +{ + const blockList = new BlockList(); + blockList.addRange('10.0.0.1', '10.0.0.10'); + assert.strictEqual(blockList.rules.length, 1); + blockList.removeRange('99.99.99.1', '99.99.99.10'); + assert.strictEqual(blockList.rules.length, 1); + assert(blockList.check('10.0.0.5')); +} + +// removeRange: IPv6 range +{ + const blockList = new BlockList(); + blockList.addRange('2001:db8::1', '2001:db8::ff', 'ipv6'); + assert(blockList.check('2001:db8::50', 'ipv6')); + + blockList.removeRange('2001:db8::1', '2001:db8::ff', 'ipv6'); + assert(!blockList.check('2001:db8::50', 'ipv6')); + assert.strictEqual(blockList.rules.length, 0); +} + +// removeRange: with SocketAddress objects +{ + const blockList = new BlockList(); + const start = new SocketAddress({ address: '10.0.0.1' }); + const end = new SocketAddress({ address: '10.0.0.10' }); + blockList.addRange(start, end); + assert(blockList.check('10.0.0.5')); + + blockList.removeRange(start, end); + assert(!blockList.check('10.0.0.5')); +} + +// removeSubnet: basic IPv4 removal +{ + const blockList = new BlockList(); + blockList.addSubnet('10.0.0.0', 8); + blockList.addSubnet('192.168.0.0', 16); + assert(blockList.check('10.1.2.3')); + assert(blockList.check('192.168.5.5')); + + blockList.removeSubnet('10.0.0.0', 8); + assert(!blockList.check('10.1.2.3')); + assert(blockList.check('192.168.5.5')); + assert.strictEqual(blockList.rules.length, 1); +} + +// removeSubnet: IPv6 +{ + const blockList = new BlockList(); + blockList.addSubnet('2001:db8::', 32, 'ipv6'); + assert(blockList.check('2001:db8::1', 'ipv6')); + + blockList.removeSubnet('2001:db8::', 32, 'ipv6'); + assert(!blockList.check('2001:db8::1', 'ipv6')); + assert.strictEqual(blockList.rules.length, 0); +} + +// removeSubnet: cross-family cleanup +{ + const blockList = new BlockList(); + blockList.addSubnet('10.0.0.0', 8); + assert(blockList.check('::ffff:10.0.0.1', 'ipv6')); + + blockList.removeSubnet('10.0.0.0', 8); + assert(!blockList.check('10.0.0.1')); + assert(!blockList.check('::ffff:10.0.0.1', 'ipv6')); +} + +// removeSubnet: non-existent subnet is a no-op +{ + const blockList = new BlockList(); + blockList.addSubnet('10.0.0.0', 8); + blockList.removeSubnet('172.16.0.0', 12); + assert(blockList.check('10.1.2.3')); + assert.strictEqual(blockList.rules.length, 1); +} + +// removeSubnet: with SocketAddress objects +{ + const blockList = new BlockList(); + const net = new SocketAddress({ address: '10.0.0.0' }); + blockList.addSubnet(net, 8); + assert(blockList.check('10.1.2.3')); + + blockList.removeSubnet(net, 8); + assert(!blockList.check('10.1.2.3')); +} + +// removeRange/removeSubnet don't affect other rule types +{ + const blockList = new BlockList(); + blockList.addAddress('1.1.1.1'); + blockList.addRange('10.0.0.1', '10.0.0.100'); + blockList.addSubnet('192.168.0.0', 16); + + blockList.removeRange('10.0.0.1', '10.0.0.100'); + blockList.removeSubnet('192.168.0.0', 16); + + // Address rule should still work + assert(blockList.check('1.1.1.1')); + assert(!blockList.check('10.0.0.50')); + assert(!blockList.check('192.168.1.1')); +} + +// addCIDR: IPv4 +{ + const blockList = new BlockList(); + blockList.addCIDR('10.0.0.0/8'); + blockList.addCIDR('192.168.1.0/24'); + assert(blockList.check('10.1.2.3')); + assert(blockList.check('192.168.1.50')); + assert(!blockList.check('192.168.2.1')); + assert(!blockList.check('11.0.0.1')); +} + +// addCIDR: IPv6 auto-detected +{ + const blockList = new BlockList(); + blockList.addCIDR('2001:db8::/32'); + assert(blockList.check('2001:db8::1', 'ipv6')); + assert(blockList.check('2001:db8:ffff::1', 'ipv6')); + assert(!blockList.check('2001:db9::1', 'ipv6')); +} + +// addCIDR: cross-family +{ + const blockList = new BlockList(); + blockList.addCIDR('10.0.0.0/8'); + assert(blockList.check('::ffff:10.0.0.1', 'ipv6')); +} + +// addCIDR: validation errors +{ + const blockList = new BlockList(); + assert.throws(() => blockList.addCIDR('10.0.0.0'), { + code: 'ERR_INVALID_ARG_VALUE', + }); + assert.throws(() => blockList.addCIDR('10.0.0.0/abc'), { + code: 'ERR_INVALID_ARG_VALUE', + }); + assert.throws(() => blockList.addCIDR(123), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => blockList.addCIDR('10.0.0.0/'), { + code: 'ERR_INVALID_ARG_VALUE', + }); +} + +// removeCIDR: basic +{ + const blockList = new BlockList(); + blockList.addCIDR('10.0.0.0/8'); + blockList.addCIDR('192.168.0.0/16'); + assert(blockList.check('10.1.2.3')); + + blockList.removeCIDR('10.0.0.0/8'); + assert(!blockList.check('10.1.2.3')); + assert(blockList.check('192.168.1.1')); + assert.strictEqual(blockList.rules.length, 1); +} + +// removeCIDR: IPv6 +{ + const blockList = new BlockList(); + blockList.addCIDR('2001:db8::/32'); + assert(blockList.check('2001:db8::1', 'ipv6')); + + blockList.removeCIDR('2001:db8::/32'); + assert(!blockList.check('2001:db8::1', 'ipv6')); + assert.strictEqual(blockList.rules.length, 0); +} + +// removeCIDR: non-existent is a no-op +{ + const blockList = new BlockList(); + blockList.addCIDR('10.0.0.0/8'); + blockList.removeCIDR('172.16.0.0/12'); + assert(blockList.check('10.1.2.3')); + assert.strictEqual(blockList.rules.length, 1); +} + +// addCIDR interoperates with removeSubnet, and vice versa +{ + const blockList = new BlockList(); + blockList.addCIDR('10.0.0.0/8'); + blockList.removeSubnet('10.0.0.0', 8); + assert(!blockList.check('10.1.2.3')); + + blockList.addSubnet('192.168.0.0', 16); + blockList.removeCIDR('192.168.0.0/16'); + assert(!blockList.check('192.168.1.1')); +} + +// removeAddress: basic +{ + const blockList = new BlockList(); + blockList.addAddress('1.1.1.1'); + blockList.addAddress('2.2.2.2'); + assert(blockList.check('1.1.1.1')); + + blockList.removeAddress('1.1.1.1'); + assert(!blockList.check('1.1.1.1')); + assert(blockList.check('2.2.2.2')); +} + +// removeAddress: cross-family cleanup +{ + const blockList = new BlockList(); + blockList.addAddress('3.3.3.3'); + assert(blockList.check('::ffff:3.3.3.3', 'ipv6')); + + blockList.removeAddress('3.3.3.3'); + assert(!blockList.check('3.3.3.3')); + assert(!blockList.check('::ffff:3.3.3.3', 'ipv6')); +} + +// removeAddress: IPv6 +{ + const blockList = new BlockList(); + blockList.addAddress('::1', 'ipv6'); + assert(blockList.check('::1', 'ipv6')); + + blockList.removeAddress('::1', 'ipv6'); + assert(!blockList.check('::1', 'ipv6')); +} + +// removeAddress: non-existent is a no-op +{ + const blockList = new BlockList(); + blockList.addAddress('1.1.1.1'); + blockList.removeAddress('9.9.9.9'); + assert(blockList.check('1.1.1.1')); +} + +// removeAddress: with SocketAddress object +{ + const blockList = new BlockList(); + const addr = new SocketAddress({ address: '5.5.5.5' }); + blockList.addAddress(addr); + assert(blockList.check('5.5.5.5')); + + blockList.removeAddress(addr); + assert(!blockList.check('5.5.5.5')); +} + +// addCIDRs: batch +{ + const blockList = new BlockList(); + blockList.addCIDRs(['10.0.0.0/8', '192.168.0.0/16', '2001:db8::/32']); + assert(blockList.check('10.1.2.3')); + assert(blockList.check('192.168.1.1')); + assert(blockList.check('2001:db8::1', 'ipv6')); + assert(!blockList.check('11.0.0.1')); + assert.strictEqual(blockList.rules.length, 3); +} + +// addCIDRs: validation +{ + const blockList = new BlockList(); + assert.throws(() => blockList.addCIDRs('not-an-array'), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => blockList.addCIDRs([123]), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => blockList.addCIDRs(['10.0.0.0']), { + code: 'ERR_INVALID_ARG_VALUE', + }); +} + +// addCIDRs: empty array is a no-op +{ + const blockList = new BlockList(); + blockList.addCIDRs([]); + assert.strictEqual(blockList.size, 0); +} + +// addCIDRs: invalid entry mid-array does not half-apply +{ + const blockList = new BlockList(); + assert.throws(() => blockList.addCIDRs(['10.0.0.0/8', 'bad', '1.1.1.0/24']), { + code: 'ERR_INVALID_ARG_VALUE', + }); + // Nothing should have been applied. + assert.strictEqual(blockList.size, 0); + assert(!blockList.check('10.0.0.1')); +} + +// size: tracks all rule types +{ + const blockList = new BlockList(); + assert.strictEqual(blockList.size, 0); + + blockList.addAddress('1.1.1.1'); + assert.strictEqual(blockList.size, 1); + + blockList.addRange('10.0.0.1', '10.0.0.10'); + assert.strictEqual(blockList.size, 2); + + blockList.addSubnet('192.168.0.0', 16); + assert.strictEqual(blockList.size, 3); + + // Matches rules.length + assert.strictEqual(blockList.size, blockList.rules.length); + + blockList.removeAddress('1.1.1.1'); + assert.strictEqual(blockList.size, 2); + + blockList.removeRange('10.0.0.1', '10.0.0.10'); + assert.strictEqual(blockList.size, 1); + + blockList.removeSubnet('192.168.0.0', 16); + assert.strictEqual(blockList.size, 0); + + // After clear + blockList.addAddress('5.5.5.5'); + blockList.clear(); + assert.strictEqual(blockList.size, 0); +} + +// size: duplicate addAddress does not double-count +{ + const blockList = new BlockList(); + blockList.addAddress('1.1.1.1'); + blockList.addAddress('1.1.1.1'); + assert.strictEqual(blockList.size, 1); +} + +// PRIVATE_RANGES: is a frozen array of CIDR strings +{ + assert(Array.isArray(BlockList.PRIVATE_RANGES)); + assert(Object.isFrozen(BlockList.PRIVATE_RANGES)); + assert(BlockList.PRIVATE_RANGES.length > 0); + for (const cidr of BlockList.PRIVATE_RANGES) { + assert.strictEqual(typeof cidr, 'string'); + assert(cidr.includes('/')); + } +} + +// PRIVATE_RANGES: covers expected addresses +{ + const blockList = new BlockList(); + blockList.addCIDRs(BlockList.PRIVATE_RANGES); + + // IPv4 private (RFC 1918) + assert(blockList.check('10.0.0.1')); + assert(blockList.check('10.255.255.255')); + assert(blockList.check('172.16.0.1')); + assert(blockList.check('172.31.255.255')); + assert(blockList.check('192.168.0.1')); + assert(blockList.check('192.168.255.255')); + + // Loopback + assert(blockList.check('127.0.0.1')); + assert(blockList.check('127.255.255.255')); + assert(blockList.check('::1', 'ipv6')); + + // Link-local + assert(blockList.check('169.254.0.1')); + assert(blockList.check('fe80::1', 'ipv6')); + + // ULA + assert(blockList.check('fc00::1', 'ipv6')); + assert(blockList.check('fd00::1', 'ipv6')); + + // Public addresses should not match + assert(!blockList.check('8.8.8.8')); + assert(!blockList.check('1.1.1.1')); + assert(!blockList.check('203.0.113.1')); + assert(!blockList.check('2001:db8::1', 'ipv6')); +} + +// check() with invalid address string returns false (exercises checkString +// error path in C++ — SocketAddress::New fails, returns false). +{ + const blockList = new BlockList(); + blockList.addAddress('1.1.1.1'); + assert.strictEqual(blockList.check('not_a_valid_ip'), false); + assert.strictEqual(blockList.check('', 'ipv4'), false); + assert.strictEqual(blockList.check('999.999.999.999'), false); + assert.strictEqual(blockList.check('not_valid_ipv6', 'ipv6'), false); +} + +// check() family parameter is case-insensitive. +{ + const blockList = new BlockList(); + blockList.addAddress('10.0.0.1'); + blockList.addAddress('::1', 'ipv6'); + + assert(blockList.check('10.0.0.1', 'ipv4')); + assert(blockList.check('10.0.0.1', 'IPv4')); + assert(blockList.check('10.0.0.1', 'IPV4')); + assert(blockList.check('::1', 'ipv6')); + assert(blockList.check('::1', 'IPv6')); + assert(blockList.check('::1', 'IPV6')); +} + +// SocketAddress constructor with invalid address throws ERR_INVALID_ADDRESS. +{ + assert.throws(() => new SocketAddress({ address: 'not_a_valid_ip' }), { + code: 'ERR_INVALID_ADDRESS', + }); + assert.throws( + () => new SocketAddress({ address: 'not_valid', family: 'ipv6' }), { + code: 'ERR_INVALID_ADDRESS', + }); +} + +// check() with SocketAddress objects across family boundaries. +{ + const blockList = new BlockList(); + const ipv4 = new SocketAddress({ address: '10.0.0.1' }); + const mapped = new SocketAddress({ + address: '::ffff:10.0.0.1', + family: 'ipv6', + }); + + blockList.addAddress(ipv4); + + // Check with SocketAddress objects (exercises the check() -> C++ fast path). + assert(blockList.check(ipv4)); + assert(blockList.check(mapped)); + + blockList.removeAddress(ipv4); + assert(!blockList.check(ipv4)); + assert(!blockList.check(mapped)); +} + +// Subnet with IPv4-mapped IPv6 network. +{ + const blockList = new BlockList(); + blockList.addSubnet('::ffff:10.0.0.0', 120, 'ipv6'); + + // IPv4-mapped IPv6 within the subnet should match. + assert(blockList.check('::ffff:10.0.0.5', 'ipv6')); + // The plain IPv4 form should also match (cross-family trie lookup). + assert(blockList.check('10.0.0.5')); + // Outside the subnet. + assert(!blockList.check('10.0.1.0')); +} + +// Range with IPv6 addresses. +{ + const blockList = new BlockList(); + blockList.addRange('::1', '::ff', 'ipv6'); + assert(blockList.check('::1', 'ipv6')); + assert(blockList.check('::a0', 'ipv6')); + assert(blockList.check('::ff', 'ipv6')); + assert(!blockList.check('::100', 'ipv6')); + assert(!blockList.check('::0', 'ipv6')); + + blockList.removeRange('::1', '::ff', 'ipv6'); + assert(!blockList.check('::a0', 'ipv6')); +} + +// Removing a broader subnet must restore subsumed narrower subnets. +{ + const blockList = new BlockList(); + blockList.addSubnet('10.0.0.0', 8); // /8 subsumes /16 in the trie + blockList.addSubnet('10.1.0.0', 16); + assert(blockList.check('10.1.2.3')); + + blockList.removeSubnet('10.0.0.0', 8); + + // /16 must still work after /8 is removed. + assert(blockList.check('10.1.2.3')); + // Address outside /16 but inside old /8 should no longer match. + assert(!blockList.check('10.2.0.1')); + assert.strictEqual(blockList.rules.length, 1); +} From ee5f72cf16be197e475352ff3a160db073bdbc13 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Thu, 6 Aug 2026 15:17:12 +0200 Subject: [PATCH 042/344] doc: fix broken link Signed-off-by: Antoine du Hamel PR-URL: https://github.com/nodejs/node/pull/65078 Reviewed-By: Colin Ihrig Reviewed-By: Filip Skokan --- doc/api/buffer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/api/buffer.md b/doc/api/buffer.md index 93af755b7620..4fd34498c050 100644 --- a/doc/api/buffer.md +++ b/doc/api/buffer.md @@ -5731,7 +5731,7 @@ or after startup, if the alignment has to hold at run time. [`buf.compare()`]: #bufcomparetarget-targetstart-targetend-sourcestart-sourceend [`buf.entries()`]: #bufentries [`buf.fill()`]: #buffillvalue-offset-end-encoding -[`buf.indexOf()`]: #bufindexofvalue-byteoffset-encoding +[`buf.indexOf()`]: #bufindexofvalue-start-end-encoding [`buf.keys()`]: #bufkeys [`buf.length`]: #buflength [`buf.slice()`]: #bufslicestart-end From 656cfaeb2d54000aa6e9b0abd6e469f0fe217d2d Mon Sep 17 00:00:00 2001 From: Kirill Saied Date: Thu, 6 Aug 2026 23:24:40 +0200 Subject: [PATCH 043/344] fs: add windowsHandle option to file streams Fixes: https://github.com/nodejs/node/issues/57288 Signed-off-by: PickBas PR-URL: https://github.com/nodejs/node/pull/63851 Reviewed-By: Stefan Stojanovic --- doc/api/fs.md | 22 +++++++ lib/internal/fs/streams.js | 38 +++++++++++- src/node_file.cc | 34 ++++++++++ test/addons/fs-windows-handle/binding.cc | 62 +++++++++++++++++++ test/addons/fs-windows-handle/binding.gyp | 9 +++ test/addons/fs-windows-handle/test.js | 35 +++++++++++ .../parallel/test-fs-stream-windows-handle.js | 47 ++++++++++++++ 7 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 test/addons/fs-windows-handle/binding.cc create mode 100644 test/addons/fs-windows-handle/binding.gyp create mode 100644 test/addons/fs-windows-handle/test.js create mode 100644 test/parallel/test-fs-stream-windows-handle.js diff --git a/doc/api/fs.md b/doc/api/fs.md index c7ee7c12ada0..70a3e7420bd2 100644 --- a/doc/api/fs.md +++ b/doc/api/fs.md @@ -2933,6 +2933,9 @@ behavior is similar to `cp dir1/ dir2/`. -```js +```cjs const { Writable } = require('node:stream'); class MyWritable extends Writable { @@ -3829,18 +3829,18 @@ class MyWritable extends Writable { } ``` -Or, when using pre-ES6 style constructors: + -```js -const { Writable } = require('node:stream'); -const util = require('node:util'); +```mjs +import { Writable } from 'node:stream'; -function MyWritable(options) { - if (!(this instanceof MyWritable)) - return new MyWritable(options); - Writable.call(this, options); +class MyWritable extends Writable { + constructor(options) { + // Calls the stream.Writable() constructor. + super(options); + // ... + } } -util.inherits(MyWritable, Writable); ``` Or, using the simplified constructor approach: @@ -4190,20 +4190,6 @@ class MyReadable extends Readable { } ``` -Or, when using pre-ES6 style constructors: - -```js -const { Readable } = require('node:stream'); -const util = require('node:util'); - -function MyReadable(options) { - if (!(this instanceof MyReadable)) - return new MyReadable(options); - Readable.call(this, options); -} -util.inherits(MyReadable, Readable); -``` - Or, using the simplified constructor approach: ```js @@ -4522,7 +4508,7 @@ changes: -```js +```cjs const { Duplex } = require('node:stream'); class MyDuplex extends Duplex { @@ -4533,18 +4519,17 @@ class MyDuplex extends Duplex { } ``` -Or, when using pre-ES6 style constructors: + -```js -const { Duplex } = require('node:stream'); -const util = require('node:util'); +```mjs +import { Duplex } from 'node:stream'; -function MyDuplex(options) { - if (!(this instanceof MyDuplex)) - return new MyDuplex(options); - Duplex.call(this, options); +class MyDuplex extends Duplex { + constructor(options) { + super(options); + // ... + } } -util.inherits(MyDuplex, Duplex); ``` Or, using the simplified constructor approach: @@ -4719,7 +4704,7 @@ output on the `Readable` side is not consumed. -```js +```cjs const { Transform } = require('node:stream'); class MyTransform extends Transform { @@ -4730,18 +4715,17 @@ class MyTransform extends Transform { } ``` -Or, when using pre-ES6 style constructors: + -```js -const { Transform } = require('node:stream'); -const util = require('node:util'); +```mjs +import { Transform } from 'node:stream'; -function MyTransform(options) { - if (!(this instanceof MyTransform)) - return new MyTransform(options); - Transform.call(this, options); +class MyTransform extends Transform { + constructor(options) { + super(options); + // ... + } } -util.inherits(MyTransform, Transform); ``` Or, using the simplified constructor approach: From 00c71fec936c063beeaa09fa705ebf1c2681e132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9?= Date: Fri, 7 Aug 2026 14:06:02 +0100 Subject: [PATCH 052/344] dns: validate port range in `setServers()` Signed-off-by: Renegade334 PR-URL: https://github.com/nodejs/node/pull/65021 Reviewed-By: Aviv Keller Reviewed-By: Tim Perry Reviewed-By: Luigi Pinca --- lib/internal/dns/utils.js | 8 +++++--- test/parallel/test-dns.js | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/lib/internal/dns/utils.js b/lib/internal/dns/utils.js index 271731f87c43..8e70baffb904 100644 --- a/lib/internal/dns/utils.js +++ b/lib/internal/dns/utils.js @@ -24,6 +24,7 @@ const { validateArray, validateInt32, validateOneOf, + validatePort, validateString, validateUint32, } = require('internal/validators'); @@ -129,6 +130,7 @@ class ResolverBase { if (ipVersion !== 0) { const port = NumberParseInt( RegExpPrototypeSymbolReplace(addrSplitRE, serv, '$2')) || IANA_DNS_PORT; + validatePort(port); return ArrayPrototypePush(newSet, [ipVersion, match[1], port]); } } @@ -138,13 +140,13 @@ class ResolverBase { if (addrSplitMatch) { const hostIP = addrSplitMatch[1]; - const port = addrSplitMatch[2] || IANA_DNS_PORT; + const port = NumberParseInt(addrSplitMatch[2]) || IANA_DNS_PORT; ipVersion = isIP(hostIP); if (ipVersion !== 0) { - return ArrayPrototypePush( - newSet, [ipVersion, hostIP, NumberParseInt(port)]); + validatePort(port); + return ArrayPrototypePush(newSet, [ipVersion, hostIP, port]); } } diff --git a/test/parallel/test-dns.js b/test/parallel/test-dns.js index d6056d459a1b..d04877f5177e 100644 --- a/test/parallel/test-dns.js +++ b/test/parallel/test-dns.js @@ -90,6 +90,22 @@ assert(existing.length > 0); }); } +{ + // Out-of-range ports, which should throw a clean error. + const invalidPorts = [2 ** 16, 2 ** 32, 2 ** 64]; + invalidPorts.forEach((port) => { + assert.throws( + () => { + dns.setServers([`1.2.3.4:${port}`]); + }, + { + name: 'RangeError', + code: 'ERR_SOCKET_BAD_PORT' + } + ); + }); +} + const goog = [ '8.8.8.8', '8.8.4.4', From 132c578e9795de450fb3a736d4d5d9ca083268cb Mon Sep 17 00:00:00 2001 From: Aviv Keller Date: Fri, 7 Aug 2026 10:01:19 -0400 Subject: [PATCH 053/344] meta: add Aviv Keller to `.mailmap` Signed-off-by: Aviv Keller PR-URL: https://github.com/nodejs/node/pull/65048 Reviewed-By: Antoine du Hamel Reviewed-By: Filip Skokan Reviewed-By: Luigi Pinca Reviewed-By: Darshan Sen --- .mailmap | 1 + 1 file changed, 1 insertion(+) diff --git a/.mailmap b/.mailmap index 0860e8e01478..6cdb3bc4f739 100644 --- a/.mailmap +++ b/.mailmap @@ -55,6 +55,7 @@ Ashok Suthar Ashutosh Kumar Singh Atsuo Fukaya Austin Kelleher +Aviv Keller Azard <330815461@qq.com> Ben Lugavere Ben Noordhuis From 003a9137994dfd4f27401b920561a520aaffb8d9 Mon Sep 17 00:00:00 2001 From: Sumit Kumar Das <151006536+skdas20@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:59:13 +0530 Subject: [PATCH 054/344] doc: clarify sqlite bare parameter default Signed-off-by: skdas20 PR-URL: https://github.com/nodejs/node/pull/62009 Reviewed-By: Trivikram Kamat --- doc/api/sqlite.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/doc/api/sqlite.md b/doc/api/sqlite.md index 96504fd43ebb..0af16c53da94 100644 --- a/doc/api/sqlite.md +++ b/doc/api/sqlite.md @@ -1135,13 +1135,13 @@ added: v22.5.0 without the prefix character. The names of SQLite parameters begin with a prefix character. By default, -`node:sqlite` requires that this prefix character is present when binding -parameters. However, with the exception of dollar sign character, these -prefix characters also require extra quoting when used in object keys. +`node:sqlite` allows binding named parameters without this prefix character in +the parameter object. With the exception of the dollar sign character, these +prefix characters require extra quoting when used in object keys. -To improve ergonomics, this method can be used to also allow bare named -parameters, which do not require the prefix character in JavaScript code. There -are several caveats to be aware of when enabling bare named parameters: +This method enables or disables support for bare named parameters, which do not +require the prefix character in JavaScript code. There are several caveats to +be aware of when bare named parameters are enabled: * The prefix character is still required in SQL. * The prefix character is still allowed in JavaScript. In fact, prefixed names From aa3b598e2e75854f62866726b966a4df4e039f9e Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Sat, 8 Aug 2026 09:29:24 +0200 Subject: [PATCH 055/344] tools: prefilter commit queue metadata Run `git node metadata --readme` in the lightweight commit queue selector before starting the checkout-heavy landing job. The selector keeps `commit-queue` on PRs that are only waiting for approvals, TSC approval count, or wait time. Other metadata failures continue to the existing commit queue script so failure labels and comments are still produced by the current landing path. Fetch age-based, fast-track, and broader queue buckets before deduplicating the list so not-yet-ready PRs do not crowd out PRs that would otherwise have been selected. Signed-off-by: Filip Skokan PR-URL: https://github.com/nodejs/node/pull/64343 Reviewed-By: Antoine du Hamel Reviewed-By: Trivikram Kamat --- .github/workflows/commit-queue.yml | 128 ++++++++++++++++++----- doc/contributing/commit-queue.md | 161 +++++++++++++++++++---------- 2 files changed, 208 insertions(+), 81 deletions(-) diff --git a/.github/workflows/commit-queue.yml b/.github/workflows/commit-queue.yml index 7af712268711..73c6e6bdee86 100644 --- a/.github/workflows/commit-queue.yml +++ b/.github/workflows/commit-queue.yml @@ -22,51 +22,44 @@ permissions: contents: read jobs: - get_mergeable_prs: + get_candidate_prs: permissions: pull-requests: read if: github.repository == 'nodejs/node' runs-on: ubuntu-slim outputs: - numbers: ${{ steps.get_mergeable_prs.outputs.numbers }} + candidates: ${{ steps.get_candidate_prs.outputs.candidates }} steps: - - name: Get Pull Requests - id: get_mergeable_prs + - name: Get Pull Request Candidates + id: get_candidate_prs run: | - prs=$(gh pr list \ + list_prs() { + gh pr list \ --repo "$GITHUB_REPOSITORY" \ --base "$GITHUB_REF_NAME" \ --label 'commit-queue' \ + "$@" \ --json 'number' \ - --search "created:<=$(date --date="2 days ago" +"%Y-%m-%dT%H:%M:%S%z") -label:blocked" \ -t '{{ range . }}{{ .number }} {{ end }}' \ - --limit 100) - fast_track_prs=$(gh pr list \ - --repo "$GITHUB_REPOSITORY" \ - --base "$GITHUB_REF_NAME" \ - --label 'commit-queue' \ + --limit 100 + } + aged_prs=$(list_prs \ + --search "created:<=$(date --date="2 days ago" +"%Y-%m-%dT%H:%M:%S%z") -label:blocked") + fast_track_prs=$(list_prs \ --label 'fast-track' \ - --search "-label:blocked" \ - --json 'number' \ - -t '{{ range . }}{{ .number }} {{ end }}' \ - --limit 100) - numbers=$(echo $prs' '$fast_track_prs | jq -r -s 'unique | join(" ")') - echo "numbers=$numbers" >> "$GITHUB_OUTPUT" + --search "-label:blocked") + queued_prs=$(list_prs \ + --search "-label:blocked") + candidates=$(printf '%s %s %s\n' "$aged_prs" "$fast_track_prs" "$queued_prs" | + jq -r -s 'reduce .[] as $pr ([]; if index($pr) then . else . + [$pr] end) | join(" ")') + echo "candidates=$candidates" >> "$GITHUB_OUTPUT" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} commitQueue: - needs: get_mergeable_prs - if: needs.get_mergeable_prs.outputs.numbers != '' + needs: get_candidate_prs + if: needs.get_candidate_prs.outputs.candidates != '' runs-on: ubuntu-slim steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - # A personal token is required because pushing with GITHUB_TOKEN will - # prevent commits from running CI after they land. It needs - # to be set here because `checkout` configures GitHub authentication - # for push as well. - token: ${{ secrets.GH_USER_TOKEN }} - # Install dependencies - name: Install Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -93,7 +86,86 @@ jobs: GITHUB_TOKEN: ${{ secrets.GH_USER_TOKEN }} JENKINS_TOKEN: ${{ secrets.JENKINS_TOKEN }} + - name: Filter Pull Requests + id: get_mergeable_prs + run: | + readme="${RUNNER_TEMP}/README.md" + curl -fsSLo "$readme" "https://github.com/${GITHUB_REPOSITORY}/raw/${GITHUB_SHA}/README.md" + + numbers= + # shellcheck disable=SC2086 + for pr in $CANDIDATES; do + metadata="${RUNNER_TEMP}/metadata-${pr}.json" + output="${RUNNER_TEMP}/metadata-${pr}.txt" + if git node metadata "$pr" \ + --owner "$GITHUB_REPOSITORY_OWNER" \ + --repo "$REPOSITORY" \ + --readme "$readme" \ + --json > "$metadata" 2> "$output"; then + metadata_status=0 + else + metadata_status=$? + fi + + if [ -s "$output" ]; then + cat "$output" + fi + + case "$metadata_status" in + 0|2[0-9]|4[0-9]) ;; + *) + echo "git node metadata failed for pr ${pr} with exit code ${metadata_status}" + exit 1 + ;; + esac + + metadata_exit_code=$(jq -r '.exitCode' "$metadata") || { + echo "failed to parse metadata JSON for pr ${pr}" + exit 1 + } + if [ "$metadata_exit_code" != "$metadata_status" ]; then + echo "metadata JSON exitCode mismatch for pr ${pr}" + exit 1 + fi + metadata_reason_codes=$(jq -r '.reasonCodes | join(", ")' "$metadata") || { + echo "failed to parse metadata reason codes for pr ${pr}" + exit 1 + } + + if [ "$metadata_status" -eq 0 ]; then + echo "pr ${pr} is ready for the commit queue" + numbers="$numbers $pr" + continue + fi + + if [ "$metadata_status" -ge 20 ] && [ "$metadata_status" -le 29 ]; then + echo "pr ${pr} skipped, not ready to land" + echo "reason codes: ${metadata_reason_codes}" + continue + fi + + echo "pr ${pr} will be handled by the commit queue" + echo "reason codes: ${metadata_reason_codes}" + numbers="$numbers $pr" + done + + numbers=$(echo "$numbers" | xargs) + echo "numbers=$numbers" >> "$GITHUB_OUTPUT" + env: + CANDIDATES: ${{ needs.get_candidate_prs.outputs.candidates }} + GITHUB_TOKEN: ${{ secrets.GH_USER_TOKEN }} + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + if: steps.get_mergeable_prs.outputs.numbers != '' + with: + # A personal token is required because pushing with GITHUB_TOKEN will + # prevent commits from running CI after they land. It needs + # to be set here because `checkout` configures GitHub authentication + # for push as well. + token: ${{ secrets.GH_USER_TOKEN }} + - name: Start the Commit Queue - run: ./tools/actions/commit-queue.sh "${GITHUB_REPOSITORY_OWNER}" "${REPOSITORY}" ${{ needs.get_mergeable_prs.outputs.numbers }} + if: steps.get_mergeable_prs.outputs.numbers != '' + run: ./tools/actions/commit-queue.sh "${GITHUB_REPOSITORY_OWNER}" "${REPOSITORY}" ${{ steps.get_mergeable_prs.outputs.numbers }} env: GITHUB_TOKEN: ${{ secrets.GH_USER_TOKEN }} diff --git a/doc/contributing/commit-queue.md b/doc/contributing/commit-queue.md index 6cedc833029e..08f01b32d4ee 100644 --- a/doc/contributing/commit-queue.md +++ b/doc/contributing/commit-queue.md @@ -1,36 +1,48 @@ # Commit queue -_tl;dr: You can land pull requests by adding the `commit-queue` label to it._ +_tl;dr: You can ask the queue to land pull requests by adding the +`commit-queue` label to them._ Commit Queue is a feature for the project which simplifies the landing process by automating it via GitHub Actions. With it, collaborators can -land pull requests by adding the `commit-queue` label to a PR. All -checks will run via `@node-core/utils`, and if the pull request is ready to -land, the Action will rebase it and push to `main`. +queue pull requests for landing by adding the `commit-queue` label to a PR. The +selector checks readiness with `@node-core/utils`. If the pull request is only +blocked on a deferrable condition, currently wait time, the queue leaves the +label in place and retries later. Other failures continue to the existing +landing and failure-reporting path. This document gives an overview of how the Commit Queue works, as well as implementation details, reasoning for design choices, and current limitations. ## Overview -From a high-level, the Commit Queue works as follow: - -1. Collaborators will add `commit-queue` label to pull requests ready to land -2. Every five minutes the queue will do the following for each mergeable pull request - with the label: - 1. Check if the PR also has a `request-ci` label (if it has, skip this PR +From a high-level, the Commit Queue works as follows: + +1. Collaborators will add `commit-queue` label to pull requests they want the + queue to land. The label can be added before the pull request has completed + its wait time, or before requested CI has finished. Required approvals must + already be in place. The commit queue does not request CI on its own. +2. On each scheduled run, the queue builds a candidate list from open pull + requests with the `commit-queue` label and without the `blocked` label. The + workflow uses a five-minute cron, but GitHub Actions scheduled workflows are + not guaranteed to run exactly every five minutes. For each candidate, the + queue will: + 1. In the landing job, install and configure `@node-core/utils`, then run a + metadata-only readiness check without checking out the repository + 2. If the metadata check exits with a deferrable readiness code, meaning + the PR is only blocked on wait time, keep the `commit-queue` label and + skip this PR until a later queue run + 3. Check if the PR also has a `request-ci` label (if it has, skip this PR since it's pending a CI run) - 2. Check if the last Jenkins CI is finished running (if it is not, skip this - PR) - 3. Remove the `commit-queue` label - 4. Run `git node land --oneCommitMax` - 5. If it fails: - 1. Abort `git node land` session - 2. Add `commit-queue-failed` label to the PR - 3. Leave a comment on the PR with the output from `git node land` - 4. Skip next steps, go to next PR in the queue - 6. If it succeeds: - 1. Push the changes to nodejs/node + 4. Check whether GitHub checks are still running (if they are, skip this PR) + 5. Remove the `commit-queue` label and run `git node land` + 6. If it fails: + 1. Add the `commit-queue-failed` label to the PR + 2. Leave a comment on the PR with the output from `git node land` + 3. Abort the `git node land` session. If the abort succeeds, continue to + the next PR; otherwise, stop the queue in an unknown state + 7. If it succeeds: + 1. Push or merge the changes into nodejs/node 2. Leave a comment on the PR with `Landed in ...` 3. Close the PR 4. Go to next PR in the queue @@ -51,7 +63,7 @@ of the commit queue: guidelines or be a valid [`fixup!`](https://git-scm.com/docs/git-commit#Documentation/git-commit.txt---fixupamendrewordltcommitgt) commit that will be correctly handled by the [`--autosquash`](https://git-scm.com/docs/git-rebase#Documentation/git-rebase.txt---autosquash) option -2. A CI must've ran and succeeded since the last change on the PR +2. A CI must have run and succeeded since the last change on the PR 3. A collaborator must have approved the PR since the last change 4. Only Jenkins CI and GitHub Actions are checked (V8 CI and CITGM are ignored) 5. The PR must target the `main` branch (PRs opened against other branches, such @@ -59,10 +71,15 @@ of the commit queue: ## Implementation -The [action](../../.github/workflows/commit-queue.yml) will run on scheduler -events every five minutes. Five minutes is the smallest number accepted by -the scheduler. The scheduler is not guaranteed to run every five minutes, it -might take longer between runs. +The [action](../../.github/workflows/commit-queue.yml) runs on scheduled events. +It uses a five-minute cron because that is the smallest interval accepted by +GitHub Actions. Scheduled workflows are not guaranteed to run exactly at that +cadence and might take longer between runs. + +The workflow also uses a concurrency group so only one commit queue run can be +active at a time. If a scheduled run starts while a previous run is still +running, GitHub Actions keeps at most one pending run for the same concurrency +group. A newer pending run replaces an older pending run. Using the scheduler is preferable over using pull\_request\_target for two reasons: @@ -76,41 +93,79 @@ reasons: commit, meaning we wouldn't be able to use it for already opened PRs without rebasing them first. -`@node-core/utils` is configured with a personal token and -a Jenkins token from -[@nodejs-github-bot](https://github.com/nodejs/github-bot). -`octokit/graphql-action` is used to fetch all pull requests with the -`commit-queue` label. The output is a JSON payload, so `jq` is used to turn -that into a list of PR ids we can pass as arguments to -[`commit-queue.sh`](../../tools/actions/commit-queue.sh). - -> The personal token only needs permission for public repositories and to read -> profiles, we can use the GITHUB\_TOKEN for write operations. Jenkins token is +The workflow starts with a small candidate job that uses GitHub CLI to fetch +pull requests with the `commit-queue` label. It first fetches the same +age-based and fast-track buckets the queue used before accepting early queue +requests, then fetches the broader queue and de-duplicates the result. This +keeps not-yet-ready PRs from crowding out PRs that the previous query would +have selected if GitHub paginates or caps a query result. + +If there are candidate PRs, the landing job installs and configures +`@node-core/utils` once with a personal token and a Jenkins token from +[@nodejs-github-bot](https://github.com/nodejs/github-bot). It then downloads +the workflow commit's README without checking out the repository and runs +`git node metadata --readme --json` for each candidate. This uses the same +`@node-core/utils` PR readiness checks as `git node land`, but does not clone, +fetch, or merge the PR. The filter consumes the structured metadata result +and its exit code instead of matching human-readable output: + +* exit code `0`: the PR is ready and is passed to + [`commit-queue.sh`](../../tools/actions/commit-queue.sh) +* exit codes `20`-`29`: the PR is not ready for a deferrable metadata reason, + currently wait time, so it keeps the `commit-queue` label and is retried + later +* exit codes `40`-`49`: the PR has a hard or mixed metadata readiness failure + and is passed to [`commit-queue.sh`](../../tools/actions/commit-queue.sh) + +The `20`-`29` exit code range is reserved by `@node-core/utils` for deferrable +metadata readiness states, and `40`-`49` is reserved for hard metadata failure +states. Unknown filter failures fail the workflow before starting the landing +script and leave PR labels unchanged so the queue can retry on a later +scheduled run. PRs passed through with exit code `40`-`49` continue through +`commit-queue.sh`. The workflow checks out the repository only when at least +one PR remains after filtering. The script still applies its existing +`request-ci` and pending-check deferrals before removing the queue label and +reporting a hard failure. + +> The personal token needs permission for public repositories and to read +> profiles. It is used by `@node-core/utils` and by the landing job for +> checkout, label and comment updates, merging, and pushing. Jenkins token is > required to check CI status. `commit-queue.sh` receives the following positional arguments: 1. The repository owner 2. The repository name -3. The Action GITHUB\_TOKEN -4. Every positional argument starting at this one will be a pull request ID of +3. Every positional argument starting at this one will be a pull request ID of a pull request with commit-queue set. -The script will iterate over the pull requests. `ncu-ci` is used to check if -the last CI is still pending, and calls to the GitHub API are used to check if -the PR is waiting for CI to start (`request-ci` label). The PR is skipped if CI -is pending. No other CI validation is done here since `git node land` will fail -if the last CI failed. - -The script removes the `commit-queue` label. It then runs `git node land`, -forwarding stdout and stderr to a file. If any errors happen, -`git node land --abort` is run, and then a `commit-queue-failed` label is added -to the PR, as well as a comment with the output of `git node land`. - -If no errors happen during `git node land`, the script will use the -`GITHUB_TOKEN` to push the changes to `main`, and then will leave a -`Landed in ...` comment in the PR, and then will close it. Iteration continues -until all PRs have done the steps above. +The script will iterate over the pull requests. GitHub CLI is used to check if +the PR is waiting for CI to start (`request-ci` label) or still has pending +GitHub checks. The PR is skipped if CI is pending. No other CI validation is +done here since `git node land` will fail if the last CI failed. + +The script removes the `commit-queue` label, then runs `git node land`, +forwarding stdout and stderr to a file. PRs that are only blocked on wait time +should have already been filtered by the metadata check. If a hard readiness +failure appears between the metadata filter and `git node land`, the landing +job adds a `commit-queue-failed` label to the PR, leaves a comment with the +output of `git node land`, and then aborts the landing session. If the abort +fails, the queue stops instead of continuing in an unknown state. + +Fast-tracked PRs use the metadata check before checkout and the landing script. +If the fast-track request has not yet received enough collaborator thumbs-up, +the queue keeps the `commit-queue` label and retries until either the +fast-track request is approved or the PR becomes landable through the regular +wait-time rules. The commit queue does not create the fast-track request +comment; that is handled when the `fast-track` label is added. If that comment +is missing, the queue reports the failure instead of keeping the PR queued. + +If no errors happen during `git node land`, the script either pushes the direct +rebase landing to `main` or uses GitHub's squash merge API for single-commit and +fixup landings. It then leaves a `Landed in ...` comment in the PR. GitHub +closes PRs merged through the merge API automatically; for direct pushes, the +script closes the PR. Iteration continues until all PRs have done the steps +above. ## Reverting broken commits From 06b1758dcdffaa7f6ae65ca3a5411af6e3afa095 Mon Sep 17 00:00:00 2001 From: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:29:36 -0700 Subject: [PATCH 056/344] sqlite: reject non-positive backup rates Passing a rate of 0 to backup() causes sqlite3_backup_step() to copy no pages. The backup job then continually reschedules itself and the returned promise never settles. Require backup rates to be positive integers to prevent zero-work backup jobs. Signed-off-by: Kamat, Trivikram <16024985+trivikr@users.noreply.github.com> Assisted-by: codex:gpt-5.6-sol PR-URL: https://github.com/nodejs/node/pull/64893 Fixes: https://github.com/nodejs/node/issues/64892 Reviewed-By: James M Snell --- doc/api/sqlite.md | 2 +- src/node_sqlite.cc | 6 ++++++ test/parallel/test-sqlite-backup.mjs | 9 +++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/doc/api/sqlite.md b/doc/api/sqlite.md index 0af16c53da94..74d258be6815 100644 --- a/doc/api/sqlite.md +++ b/doc/api/sqlite.md @@ -1366,7 +1366,7 @@ changes: database that have been added with [`ATTACH DATABASE`][] **Default:** `'main'`. * `target` {string} Name of the target database. This can be `'main'` (the default primary database) or any other database that have been added with [`ATTACH DATABASE`][] **Default:** `'main'`. - * `rate` {number} Number of pages to be transmitted in each batch of the backup. **Default:** `100`. + * `rate` {integer} Positive number of pages to be transmitted in each batch of the backup. **Default:** `100`. * `progress` {Function} An optional callback function that will be called after each backup step. The argument passed to this callback is an {Object} with `remainingPages` and `totalPages` properties, describing the current progress of the backup operation. diff --git a/src/node_sqlite.cc b/src/node_sqlite.cc index 41b0094a606b..926b1ef298ae 100644 --- a/src/node_sqlite.cc +++ b/src/node_sqlite.cc @@ -2200,6 +2200,12 @@ void Backup(const FunctionCallbackInfo& args) { return; } rate = rate_v.As()->Value(); + if (rate <= 0) { + THROW_ERR_OUT_OF_RANGE( + env->isolate(), + "The \"options.rate\" argument must be a positive integer."); + return; + } } Local source_v; diff --git a/test/parallel/test-sqlite-backup.mjs b/test/parallel/test-sqlite-backup.mjs index 80061ee6601d..d1e09569e1ca 100644 --- a/test/parallel/test-sqlite-backup.mjs +++ b/test/parallel/test-sqlite-backup.mjs @@ -124,6 +124,15 @@ describe('backup()', () => { message: 'The "options.rate" argument must be an integer.' }); + for (const rate of [0, -1]) { + t.assert.throws(() => { + backup(database, 'hello.db', { rate }); + }, { + code: 'ERR_OUT_OF_RANGE', + message: 'The "options.rate" argument must be a positive integer.' + }); + } + t.assert.throws(() => { backup(database, 'hello.db', { progress: 'invalid' From 7f3a42035c4be7771b5358dc7f323519ea41d08e Mon Sep 17 00:00:00 2001 From: "Kamat, Trivikram" <16024985+trivikr@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:18:53 -0700 Subject: [PATCH 057/344] test: use libuv clock for immediate queue test Use getLibuvNow() when waiting for the timeout to expire. Date.now() may have different precision from libuv's clock, causing the immediate queue to run an extra iteration on platforms with coarse timers. Signed-off-by: Kamat, Trivikram <16024985+trivikr@users.noreply.github.com> Assisted-by: codex:gpt-5.6-sol PR-URL: https://github.com/nodejs/node/pull/64889 Refs: https://github.com/nodejs/reliability/issues?q=sort%3Aupdated-desc%20test-timers-immediate-queue Reviewed-By: Tim Perry --- test/parallel/test-timers-immediate-queue.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/parallel/test-timers-immediate-queue.js b/test/parallel/test-timers-immediate-queue.js index 8b433ddedbf4..517bb280d49d 100644 --- a/test/parallel/test-timers-immediate-queue.js +++ b/test/parallel/test-timers-immediate-queue.js @@ -19,9 +19,13 @@ // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE // USE OR OTHER DEALINGS IN THE SOFTWARE. +// Flags: --expose-internals --no-warnings + 'use strict'; require('../common'); const assert = require('assert'); +const { internalBinding } = require('internal/test/binding'); +const timersBinding = internalBinding('timers'); // setImmediate should run clear its queued cbs once per event loop turn // but immediates queued while processing the current queue should happen @@ -38,8 +42,8 @@ const QUEUE = 10; function run() { if (hit === 0) { setTimeout(() => { ticked = true; }, 1); - const now = Date.now(); - while (Date.now() - now < 2); + const now = timersBinding.getLibuvNow(); + while (timersBinding.getLibuvNow() - now < 2); } if (ticked) return; From 89d390559525b7d7545a8253841cff8e27b23a23 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Sat, 8 Aug 2026 10:36:58 +0200 Subject: [PATCH 058/344] tools: move ncu config to global for commit queue Signed-off-by: Filip Skokan PR-URL: https://github.com/nodejs/node/pull/65132 Refs: https://github.com/nodejs/node/pull/64343 Reviewed-By: Antoine du Hamel Reviewed-By: Aviv Keller --- .github/workflows/commit-queue.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/commit-queue.yml b/.github/workflows/commit-queue.yml index 73c6e6bdee86..16cb6fef3e3a 100644 --- a/.github/workflows/commit-queue.yml +++ b/.github/workflows/commit-queue.yml @@ -74,13 +74,14 @@ jobs: - name: Configure @node-core/utils run: | - ncu-config set branch "${GITHUB_REF_NAME}" - ncu-config set upstream origin - ncu-config set username "$USERNAME" - ncu-config set token "$GITHUB_TOKEN" - ncu-config set jenkins_token "$JENKINS_TOKEN" - ncu-config set repo "${REPOSITORY}" - ncu-config set owner "${GITHUB_REPOSITORY_OWNER}" + # Keep the config outside the workspace so checkout does not remove it. + ncu-config --global set branch "${GITHUB_REF_NAME}" + ncu-config --global set upstream origin + ncu-config --global set username "$USERNAME" + ncu-config --global set token "$GITHUB_TOKEN" + ncu-config --global set jenkins_token "$JENKINS_TOKEN" + ncu-config --global set repo "${REPOSITORY}" + ncu-config --global set owner "${GITHUB_REPOSITORY_OWNER}" env: USERNAME: ${{ secrets.JENKINS_USER }} GITHUB_TOKEN: ${{ secrets.GH_USER_TOKEN }} From 82b2d9c08d29e430233393ad0e3b79bc371fc155 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Sat, 8 Aug 2026 14:18:49 +0200 Subject: [PATCH 059/344] test: update passphrases to comply with the next OpenSSL FIPS mode Signed-off-by: Filip Skokan PR-URL: https://github.com/nodejs/node/pull/65077 Reviewed-By: Richard Lau Reviewed-By: Luigi Pinca --- test/parallel/test-crypto-key-store.js | 7 ++++--- test/parallel/test-crypto-no-algorithm.js | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/test/parallel/test-crypto-key-store.js b/test/parallel/test-crypto-key-store.js index 72826afaaeff..b6f012416673 100644 --- a/test/parallel/test-crypto-key-store.js +++ b/test/parallel/test-crypto-key-store.js @@ -109,21 +109,22 @@ const data = Buffer.from('hello store'); { // Encrypted PKCS#8 with passphrase via { key: url, passphrase }. + const passphrase = 'correct-passphrase'; const { privateKey, publicKey } = generateKeyPairSync('ed25519'); const file = path.join(tmpdir.path, 'enc.pem'); fs.writeFileSync(file, privateKey.export({ - format: 'pem', type: 'pkcs8', cipher: 'aes-256-cbc', passphrase: 'pw', + format: 'pem', type: 'pkcs8', cipher: 'aes-256-cbc', passphrase, })); const url = pathToFileURL(file); - const sig = sign(null, data, { key: url, passphrase: Buffer.from('pw') }); + const sig = sign(null, data, { key: url, passphrase: Buffer.from(passphrase) }); assert.strictEqual(verify(null, data, publicKey, sig), true); assert.throws(() => createPrivateKey(url), { code: 'ERR_MISSING_PASSPHRASE', }); - assert.throws(() => createPrivateKey({ key: url, passphrase: 'bad' }), + assert.throws(() => createPrivateKey({ key: url, passphrase: 'wrong-passphrase' }), common.expectsError({ name: 'Error', code: /^ERR_OSSL_/, diff --git a/test/parallel/test-crypto-no-algorithm.js b/test/parallel/test-crypto-no-algorithm.js index 3a9473dfb165..90d19ff97fcb 100644 --- a/test/parallel/test-crypto-no-algorithm.js +++ b/test/parallel/test-crypto-no-algorithm.js @@ -30,7 +30,7 @@ if (isMainThread) { const derivations = [ ['HKDF', () => crypto.hkdfSync('sha256', Buffer.alloc(32), Buffer.alloc(8), Buffer.alloc(0), 32)], - ['PBKDF2', () => crypto.pbkdf2Sync('secret', Buffer.alloc(16), 1000, 32, + ['PBKDF2', () => crypto.pbkdf2Sync('passphrase', Buffer.alloc(16), 1000, 32, 'sha256')], ]; for (const { 0: name, 1: derive } of derivations) { From ee7bf09090ca4f7a3a75c4ae57a91395cc5ba08d Mon Sep 17 00:00:00 2001 From: Archkon <180910180+Archkon@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:33:14 +0800 Subject: [PATCH 060/344] zlib: validate pledgedSrcSize for sync zstd Zstd replaces a configured pledged source size with the input size when the first call to ZSTD_compressStream2() uses ZSTD_e_end. Track consumed input in Node and report ZSTD_error_srcSize_wrong when the completed frame does not match the original pledge. Signed-off-by: Archkon <180910180+Archkon@users.noreply.github.com> PR-URL: https://github.com/nodejs/node/pull/64601 Fixes: https://github.com/nodejs/node/issues/64600 Reviewed-By: James M Snell Reviewed-By: Aviv Keller --- src/node_zlib.cc | 23 +++++++- .../test-zlib-zstd-pledged-src-size.js | 55 ++++++++++++++++--- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/src/node_zlib.cc b/src/node_zlib.cc index 638982c7ede3..af82aa2ae73b 100644 --- a/src/node_zlib.cc +++ b/src/node_zlib.cc @@ -47,10 +47,11 @@ #include +#include #include #include #include -#include +#include namespace node { @@ -349,6 +350,7 @@ class ZstdCompressContext final : public ZstdContext { DeleteFnPtr cctx_; uint64_t pledged_src_size_ = ZSTD_CONTENTSIZE_UNKNOWN; + std::optional consumed_src_size_; }; class ZstdDecompressContext final : public ZstdContext { @@ -1661,6 +1663,11 @@ void ZstdCompressContext::Close() { CompressionError ZstdCompressContext::Init(uint64_t pledged_src_size, std::string_view dictionary) { pledged_src_size_ = pledged_src_size; + if (pledged_src_size == ZSTD_CONTENTSIZE_UNKNOWN) { + consumed_src_size_.reset(); + } else { + consumed_src_size_ = 0; + } #ifdef NODE_BUNDLED_ZSTD ZSTD_customMem custom_mem = { CompressionStreamMemoryOwner::AllocForBrotli, @@ -1700,12 +1707,26 @@ CompressionError ZstdCompressContext::ResetStream() { } void ZstdCompressContext::DoThreadPoolWork() { + // Zstd overrides a configured pledge when the first call uses ZSTD_e_end. + size_t const input_pos = input_.pos; size_t const remaining = ZSTD_compressStream2(cctx_.get(), &output_, &input_, flush_); + if (consumed_src_size_.has_value()) { + *consumed_src_size_ += input_.pos - input_pos; + } 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_); + } } } diff --git a/test/parallel/test-zlib-zstd-pledged-src-size.js b/test/parallel/test-zlib-zstd-pledged-src-size.js index 4a5f27394c66..a0b1babd3f73 100644 --- a/test/parallel/test-zlib-zstd-pledged-src-size.js +++ b/test/parallel/test-zlib-zstd-pledged-src-size.js @@ -3,6 +3,11 @@ const common = require('../common'); const assert = require('assert'); const zlib = require('zlib'); +const pledgedSrcSizeError = { + code: 'ZSTD_error_srcSize_wrong', + errno: zlib.constants.ZSTD_error_srcSize_wrong, +}; + function compressWithPledgedSrcSize({ pledgedSrcSize, actualSrcSize }) { return new Promise((resolve, reject) => { const compressor = zlib.createZstdCompress({ pledgedSrcSize }); @@ -18,23 +23,59 @@ function compressWithPledgedSrcSize({ pledgedSrcSize, actualSrcSize }) { // Compression should only succeed if sizes match assert.strictEqual(pledgedSrcSize, actualSrcSize); }, (error) => { - assert.strictEqual(error.code, 'ZSTD_error_srcSize_wrong'); + assert.strictEqual(error.code, pledgedSrcSizeError.code); + assert.strictEqual(error.errno, pledgedSrcSizeError.errno); // Size error should only happen when sizes do not match assert.notStrictEqual(pledgedSrcSize, actualSrcSize); }).then(common.mustCall()); } -compressWithPledgedSrcSize({ pledgedSrcSize: 0, actualSrcSize: 0 }); +function compressSyncWithPledgedSrcSize({ pledgedSrcSize, actualSrcSize }) { + const compress = () => zlib.zstdCompressSync( + 'x'.repeat(actualSrcSize), + { pledgedSrcSize }, + ); + + if (pledgedSrcSize === actualSrcSize) { + compress(); + } else { + assert.throws(compress, pledgedSrcSizeError); + } +} -compressWithPledgedSrcSize({ pledgedSrcSize: 0, actualSrcSize: 42 }); +const testCases = [ + { pledgedSrcSize: 0, actualSrcSize: 0 }, + { pledgedSrcSize: 0, actualSrcSize: 42 }, + { pledgedSrcSize: 1, actualSrcSize: 42 }, + { pledgedSrcSize: 13, actualSrcSize: 42 }, + { pledgedSrcSize: 42, actualSrcSize: 0 }, + { pledgedSrcSize: 42, actualSrcSize: 13 }, + { pledgedSrcSize: 42, actualSrcSize: 42 }, +]; -compressWithPledgedSrcSize({ pledgedSrcSize: 13, actualSrcSize: 42 }); +for (const testCase of testCases) { + compressWithPledgedSrcSize(testCase); + compressSyncWithPledgedSrcSize(testCase); +} -compressWithPledgedSrcSize({ pledgedSrcSize: 42, actualSrcSize: 0 }); +const retryInput = Buffer.allocUnsafe(256 * 1024); +let randomState = 0x12345678; +for (let i = 0; i < retryInput.length; i++) { + randomState = (Math.imul(randomState, 1664525) + 1013904223) | 0; + retryInput[i] = randomState >>> 24; +} -compressWithPledgedSrcSize({ pledgedSrcSize: 42, actualSrcSize: 13 }); +const compressed = zlib.zstdCompressSync(retryInput, { + pledgedSrcSize: retryInput.length, + chunkSize: 64, +}); +assert.ok(compressed.length > 64); +assert.deepStrictEqual(zlib.zstdDecompressSync(compressed), retryInput); -compressWithPledgedSrcSize({ pledgedSrcSize: 42, actualSrcSize: 42 }); +assert.throws(() => zlib.zstdCompressSync(retryInput, { + pledgedSrcSize: retryInput.length - 1, + chunkSize: 64, +}), pledgedSrcSizeError); function assertInvalidPledgedSrcSize(pledgedSrcSize, expected) { assert.throws( From ca885876f62d3036b85a7bfdbb070aa6cae36620 Mon Sep 17 00:00:00 2001 From: Ali Hassan <24819103+thisalihassan@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:54:01 +0500 Subject: [PATCH 061/344] sqlite: refactor error helpers and user function pointers Signed-off-by: Ali Hassan PR-URL: https://github.com/nodejs/node/pull/62794 Reviewed-By: Edy Silva Reviewed-By: James M Snell --- src/node_sqlite.cc | 119 +++++++++++++++++++++------------------------ src/node_sqlite.h | 4 +- 2 files changed, 57 insertions(+), 66 deletions(-) diff --git a/src/node_sqlite.cc b/src/node_sqlite.cc index 926b1ef298ae..2a36e464ccf2 100644 --- a/src/node_sqlite.cc +++ b/src/node_sqlite.cc @@ -170,59 +170,49 @@ static constexpr const LimitInfo* GetLimitInfoFromName(std::string_view name) { return nullptr; } -inline MaybeLocal CreateSQLiteError(Isolate* isolate, - const char* message) { +namespace { +MaybeLocal CreateSQLiteErrorImpl(Isolate* isolate, + const char* message, + const char* errstr, + int errcode) { + Environment* env = Environment::GetCurrent(isolate); + Local context = isolate->GetCurrentContext(); Local js_msg; Local e; - Environment* env = Environment::GetCurrent(isolate); if (!String::NewFromUtf8(isolate, message).ToLocal(&js_msg) || - !Exception::Error(js_msg) - ->ToObject(isolate->GetCurrentContext()) - .ToLocal(&e) || - e->Set(isolate->GetCurrentContext(), - env->code_string(), - env->err_sqlite_error_string()) + !Exception::Error(js_msg)->ToObject(context).ToLocal(&e) || + e->Set(context, env->code_string(), env->err_sqlite_error_string()) .IsNothing()) { return MaybeLocal(); } + + if (errstr != nullptr) { + Local js_errstr; + if (!String::NewFromUtf8(isolate, errstr).ToLocal(&js_errstr) || + e->Set(context, env->errcode_string(), Integer::New(isolate, errcode)) + .IsNothing() || + e->Set(context, env->errstr_string(), js_errstr).IsNothing()) { + return MaybeLocal(); + } + } return e; } +} // namespace + +inline MaybeLocal CreateSQLiteError(Isolate* isolate, + const char* message) { + return CreateSQLiteErrorImpl(isolate, message, nullptr, 0); +} inline MaybeLocal CreateSQLiteError(Isolate* isolate, int errcode) { const char* errstr = sqlite3_errstr(errcode); - Local js_errmsg; - Local e; - Environment* env = Environment::GetCurrent(isolate); - if (!String::NewFromUtf8(isolate, errstr).ToLocal(&js_errmsg) || - !CreateSQLiteError(isolate, errstr).ToLocal(&e) || - e->Set(env->context(), - env->errcode_string(), - Integer::New(isolate, errcode)) - .IsNothing() || - e->Set(env->context(), env->errstr_string(), js_errmsg).IsNothing()) { - return MaybeLocal(); - } - return e; + return CreateSQLiteErrorImpl(isolate, errstr, errstr, errcode); } inline MaybeLocal CreateSQLiteError(Isolate* isolate, sqlite3* db) { int errcode = sqlite3_extended_errcode(db); - const char* errstr = sqlite3_errstr(errcode); - const char* errmsg = sqlite3_errmsg(db); - Local js_errmsg; - Local e; - Environment* env = Environment::GetCurrent(isolate); - if (!String::NewFromUtf8(isolate, errstr).ToLocal(&js_errmsg) || - !CreateSQLiteError(isolate, errmsg).ToLocal(&e) || - e->Set(isolate->GetCurrentContext(), - env->errcode_string(), - Integer::New(isolate, errcode)) - .IsNothing() || - e->Set(isolate->GetCurrentContext(), env->errstr_string(), js_errmsg) - .IsNothing()) { - return MaybeLocal(); - } - return e; + return CreateSQLiteErrorImpl( + isolate, sqlite3_errmsg(db), sqlite3_errstr(errcode), errcode); } void JSValueToSQLiteResult(Isolate* isolate, @@ -307,14 +297,14 @@ inline MaybeLocal NullableSQLiteStringToValue(Isolate* isolate, class CustomAggregate { public: explicit CustomAggregate(Environment* env, - DatabaseSync* db, + BaseObjectWeakPtr db, bool use_bigint_args, Local start, Local step_fn, Local inverse_fn, Local result_fn) : env_(env), - db_(db), + db_(std::move(db)), use_bigint_args_(use_bigint_args), start_(env->isolate(), start), step_fn_(env->isolate(), step_fn), @@ -350,7 +340,7 @@ class CustomAggregate { Global CustomAggregate::*mptr) { CustomAggregate* self = static_cast(sqlite3_user_data(ctx)); - CallbackDepthGuard guard(self->db_); + CallbackDepthGuard guard(self->db_.get()); Environment* env = self->env_; Isolate* isolate = env->isolate(); auto agg = self->GetAggregate(ctx); @@ -408,7 +398,7 @@ class CustomAggregate { static inline void xValueBase(sqlite3_context* ctx, bool is_final) { CustomAggregate* self = static_cast(sqlite3_user_data(ctx)); - CallbackDepthGuard guard(self->db_); + CallbackDepthGuard guard(self->db_.get()); Environment* env = self->env_; Isolate* isolate = env->isolate(); auto agg = self->GetAggregate(ctx); @@ -487,7 +477,7 @@ class CustomAggregate { } Environment* env_; - DatabaseSync* db_; + BaseObjectWeakPtr db_; bool use_bigint_args_; Global start_; Global step_fn_; @@ -670,11 +660,11 @@ class BackupJob : public ThreadPoolWork { UserDefinedFunction::UserDefinedFunction(Environment* env, Local fn, - DatabaseSync* db, + BaseObjectWeakPtr db, bool use_bigint_args) : env_(env), fn_(env->isolate(), fn), - db_(db), + db_(std::move(db)), use_bigint_args_(use_bigint_args) {} UserDefinedFunction::~UserDefinedFunction() {} @@ -684,7 +674,7 @@ void UserDefinedFunction::xFunc(sqlite3_context* ctx, sqlite3_value** argv) { UserDefinedFunction* self = static_cast(sqlite3_user_data(ctx)); - CallbackDepthGuard guard(self->db_); + CallbackDepthGuard guard(self->db_.get()); Environment* env = self->env_; Isolate* isolate = env->isolate(); auto recv = Undefined(isolate); @@ -1735,8 +1725,8 @@ void DatabaseSync::CustomFunction(const FunctionCallbackInfo& args) { argc = js_len.As()->Value(); } - UserDefinedFunction* user_data = - new UserDefinedFunction(env, fn, db, use_bigint_args); + UserDefinedFunction* user_data = new UserDefinedFunction( + env, fn, BaseObjectWeakPtr(db), use_bigint_args); int text_rep = SQLITE_UTF8; if (deterministic) { @@ -2057,22 +2047,23 @@ void DatabaseSync::AggregateFunction(const FunctionCallbackInfo& args) { auto xInverse = !inverseFunc.IsEmpty() ? CustomAggregate::xInverse : nullptr; auto xValue = xInverse ? CustomAggregate::xValue : nullptr; - int r = sqlite3_create_window_function(db->connection_, - *name, - argc, - text_rep, - new CustomAggregate(env, - db, - use_bigint_args, - start_v, - stepFunction, - inverseFunc, - resultFunction), - CustomAggregate::xStep, - CustomAggregate::xFinal, - xValue, - xInverse, - CustomAggregate::xDestroy); + int r = sqlite3_create_window_function( + db->connection_, + *name, + argc, + text_rep, + new CustomAggregate(env, + BaseObjectWeakPtr(db), + use_bigint_args, + start_v, + stepFunction, + inverseFunc, + resultFunction), + CustomAggregate::xStep, + CustomAggregate::xFinal, + xValue, + xInverse, + CustomAggregate::xDestroy); CHECK_ERROR_OR_THROW(env->isolate(), db, r, SQLITE_OK, void()); } diff --git a/src/node_sqlite.h b/src/node_sqlite.h index 675595e55025..b4446e5db859 100644 --- a/src/node_sqlite.h +++ b/src/node_sqlite.h @@ -430,7 +430,7 @@ class UserDefinedFunction { public: UserDefinedFunction(Environment* env, v8::Local fn, - DatabaseSync* db, + BaseObjectWeakPtr db, bool use_bigint_args); ~UserDefinedFunction(); static void xFunc(sqlite3_context* ctx, int argc, sqlite3_value** argv); @@ -439,7 +439,7 @@ class UserDefinedFunction { private: Environment* env_; v8::Global fn_; - DatabaseSync* db_; + BaseObjectWeakPtr db_; bool use_bigint_args_; }; From 15da4a190bdad7e5af8371076892df666fb454f4 Mon Sep 17 00:00:00 2001 From: Tim Perry <1526883+pimterry@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:05:06 +0200 Subject: [PATCH 062/344] tls: fix authorized state on no-cert TLS1.3 client cert resumption Previously if you used TLS 1.3 and the server requested a client cert, but the client didn't send one, and you used rejectUnauthorized:false the resumed session would report authorized=true. This doesn't match TLS 1.2 behaviour or make any sense, and was purely an artifact of our internal logic for handling TLS 1.3 resumption details. We now correctly report the authorization state and/or error from the original connection in all cases, with a matrix test that fully checks the invariant: authorized state after resume should always match the initial state. Signed-off-by: Tim Perry PR-URL: https://github.com/nodejs/node/pull/64677 Reviewed-By: James M Snell --- lib/internal/tls/wrap.js | 7 + .../test-tls-client-cert-resumption.js | 199 ++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 test/parallel/test-tls-client-cert-resumption.js diff --git a/lib/internal/tls/wrap.js b/lib/internal/tls/wrap.js index 51fea0e99614..1c6e0577ce3d 100644 --- a/lib/internal/tls/wrap.js +++ b/lib/internal/tls/wrap.js @@ -1320,6 +1320,13 @@ function onServerSocketSecure() { if (verifyError) { this.authorizationError = verifyError.code; + if (this._rejectUnauthorized) + this.destroy(); + } else if (!this._handle.getPeerX509Certificate()) { + // Ncrypto reports X509_V_OK for TLS 1.3 resumption without a peer + // certificate, as it uses PSKs. Require one to authorize the socket. + this.authorizationError = 'UNABLE_TO_GET_ISSUER_CERT'; + if (this._rejectUnauthorized) this.destroy(); } else { diff --git a/test/parallel/test-tls-client-cert-resumption.js b/test/parallel/test-tls-client-cert-resumption.js new file mode 100644 index 000000000000..790809678d66 --- /dev/null +++ b/test/parallel/test-tls-client-cert-resumption.js @@ -0,0 +1,199 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +// Server-side client-certificate authorization must survive TLS session +// resumption. On a resumed handshake the client does not re-send its +// certificate, so the server has to report the same authorization state it +// derived from the original full handshake: +// +// - a trusted certificate stays authorized, +// - an untrusted certificate stays unauthorized with its verification error, +// - a missing certificate stays unauthorized (UNABLE_TO_GET_ISSUER_CERT). +// +// The missing-certificate case is special on TLS 1.3: ncrypto reports X509_V_OK +// for the resumed PSK handshake even though no certificate was presented, so +// the absence has to be detected explicitly (see onServerSocketSecure() in +// lib/internal/tls/wrap.js). The final case checks that such a certificate-less +// resumed session is rejected outright when rejectUnauthorized is set. + +const assert = require('assert'); +const crypto = require('crypto'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); +const { once } = require('events'); + +const ca = fixtures.readKey('ca1-cert.pem'); +const serverCert = { + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem'), +}; + +// Client certificate variants, keyed by the peer state they produce. +const CLIENTS = { + trusted: { // Signed by ca1 + creds: { + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem'), + }, + authorized: true, + authorizationError: null, + peerCN: 'agent1', + }, + untrusted: { // Signed by ca2, not trusted + creds: { + key: fixtures.readKey('agent3-key.pem'), + cert: fixtures.readKey('agent3-cert.pem'), + }, + authorized: false, + authorizationError: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + peerCN: 'agent3', + }, + missing: { // No client certificate + creds: {}, + authorized: false, + authorizationError: 'UNABLE_TO_GET_ISSUER_CERT', + peerCN: undefined, + }, +}; + +async function handshake(options, captureSession) { + const socket = tls.connect(options); + const sessionPromise = captureSession ? + once(socket, 'session').then(([session]) => session) : null; + + socket.resume(); + await once(socket, 'secureConnect'); + + const closePromise = once(socket, 'close'); + const session = sessionPromise ? await sessionPromise : undefined; + socket.end(); + await closePromise; + return session; +} + +// Test a single resumption configuration and expected result: +async function testResumption(version, name) { + const { creds, authorized, authorizationError, peerCN } = CLIENTS[name]; + + let connections = 0; + const server = tls.createServer({ + ...serverCert, + ca, + requestCert: true, + rejectUnauthorized: false, + minVersion: version, + maxVersion: version, + }, common.mustCall((socket) => { + // 2nd conn must resume: + const resumed = connections++ === 1; + const where = `${version} ${name} ${resumed ? 'resumed' : 'new'}`; + assert.strictEqual(socket.isSessionReused(), resumed, where); + + // Both conns must report same expected auth state: + assert.strictEqual(socket.authorized, authorized, where); + assert.strictEqual(socket.authorizationError, authorizationError, where); + const peer = socket.getPeerCertificate(); + if (peerCN === undefined) + assert.deepStrictEqual(peer, {}, where); + else + assert.strictEqual(peer.subject.CN, peerCN, where); + + // N.b. BoringSSL only sends a ticket after a write: + socket.end('.'); + }, 2)); + + server.listen(0); + await once(server, 'listening'); + + const options = { + port: server.address().port, + host: '127.0.0.1', + checkServerIdentity: () => undefined, + rejectUnauthorized: false, + minVersion: version, + maxVersion: version, + ...creds, + }; + + try { + const session = await handshake(options, true); + assert(session); + await handshake({ ...options, session }); + } finally { + server.close(); + await once(server, 'close'); + } +} + +// Test the special case of resumption from rejectUnauthorized:false to +// rejectUnauthorized:true, which must be rejected even though the original +// session worked initially. +async function testRejectResumedWithoutCert() { + const options = { + ...serverCert, + ca, + requestCert: true, + minVersion: 'TLSv1.3', + maxVersion: 'TLSv1.3', + ticketKeys: crypto.randomBytes(48), + }; + const lenient = tls.createServer({ ...options, rejectUnauthorized: false }); + lenient.on('secureConnection', common.mustCall((socket) => { + assert.strictEqual(socket.authorized, false); + assert.strictEqual(socket.authorizationError, 'UNABLE_TO_GET_ISSUER_CERT'); + socket.end('.'); + })); + + const strict = tls.createServer({ ...options, rejectUnauthorized: true }); + strict.on('secureConnection', common.mustNotCall()); + + const clientOptions = (port) => ({ + port, + host: '127.0.0.1', + rejectUnauthorized: false, + checkServerIdentity: () => undefined, + minVersion: 'TLSv1.3', + maxVersion: 'TLSv1.3', + }); + + lenient.listen(0); + await once(lenient, 'listening'); + const session = await handshake(clientOptions(lenient.address().port), true); + assert(session); + lenient.close(); + await once(lenient, 'close'); + + strict.listen(0); + await once(strict, 'listening'); + + const resumed = tls.connect({ ...clientOptions(strict.address().port), session }); + resumed.on('error', () => {}); // May observe the server's reset. + resumed.resume(); + + // The client completes the resumed handshake (it has the server's Finished) + // before the server's reset can arrive, so this asserts the strict server + // actually resumed rather than falling back to a rejected full handshake. + await once(resumed, 'secureConnect'); + assert.strictEqual(resumed.isSessionReused(), true); + + // Then the socket is destroyed during 'secure', which surfaces as a reset + // rather than a handshake failure. + const [err] = await once(strict, 'tlsClientError'); + assert.strictEqual(err.code, 'ECONNRESET'); + + resumed.destroy(); + strict.close(); + await once(strict, 'close'); +} + +(async function() { + // Run the full matrix of configurations: + for (const version of ['TLSv1.2', 'TLSv1.3']) + for (const name of Object.keys(CLIENTS)) + await testResumption(version, name); + + // Validate the rejectUnauth:false->true case + await testRejectResumedWithoutCert(); +})().then(common.mustCall()); From 0157c450121aec364aa2eaf56fd2a05ebd4ece9a Mon Sep 17 00:00:00 2001 From: Issac <163278029+theSnackOverflow@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:41:43 +0900 Subject: [PATCH 063/344] doc: document quic stopSending() and resetStream() `QuicStream` exposes `stopSending()` and `resetStream()`, but neither appeared in the QuicStream API reference. Both matter when half-closing a stream, which protocols such as WebTransport rely on. Document the two methods and list them in the "Aborting a stream" summary, which previously covered only `writer.fail()` and `stream.destroy()`. Unlike those, both send the given code as-is rather than deriving a wire code from an error. Fixes: https://github.com/nodejs/node/issues/63680 Signed-off-by: Ji Hoon Kang PR-URL: https://github.com/nodejs/node/pull/64888 Reviewed-By: James M Snell --- doc/api/quic.md | 49 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/doc/api/quic.md b/doc/api/quic.md index 2a657cb6834e..9b0254337d76 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -1921,9 +1921,14 @@ True if `stream.destroy()` has been called. ### Aborting a stream -A QuicStream can be aborted in three ways, each producing different +A QuicStream can be aborted in several ways, each producing different wire-frame side effects: +* [`stream.stopSending()`][] — Aborts only the readable side. Sends + `STOP_SENDING` to the peer. The writable side is unaffected. +* [`stream.resetStream()`][] — Aborts only the writable side. Sends + `RESET_STREAM` to the peer. Unlike [`writer.fail(reason)`][], the wire + code is given directly rather than derived from an error. * [`writer.fail(reason)`][] — Aborts only the writable side. Sends `RESET_STREAM` to the peer. The readable side is unaffected; any data already buffered for read remains available. @@ -1941,6 +1946,46 @@ the wire code for both `writer.fail()` and `stream.destroy()`. Otherwise the implementation falls back to the negotiated application protocol's "internal error" code (see [`QuicError`][]). +[`stream.stopSending()`][] and [`stream.resetStream()`][] do +not perform this derivation: they send `code` as given. + +### `stream.resetStream([code])` + + + +* `code` {number|bigint} The application error code to send to the peer. + **Default:** `0n`. + +Tells the peer that this end will not send any more data on this stream, +sending a `RESET_STREAM` frame carrying `code`. The readable side is left +open, so data already sent by the peer remains available to read. + +Any data still queued for sending is discarded. A reset stream is never +acknowledged by the peer, so the outbound queue can no longer drain. + +No acknowledgement of this action is provided. The call does nothing if the +stream has been destroyed, if it has already been reset, or if it is a +remote-initiated unidirectional stream, which has no writable side to abort. + +### `stream.stopSending([code])` + + + +* `code` {number|bigint} The application error code to send to the peer. + **Default:** `0n`. + +Asks the peer to stop sending data on this stream, sending a `STOP_SENDING` +frame carrying `code`. The writable side is left open, so this end can +still send data. + +No acknowledgement of this action is provided. The call does nothing if the +stream has been destroyed, or if it is a locally-initiated unidirectional +stream, which has no readable side to abort. + ### `stream.early` * `changeset` {Uint8Array} A binary changeset or patchset. + * `options` {Object} The configuration options for how the changes will be applied. * `filter` {Function} for each table affected by at least one change in the changeset, the `filter` callback is invoked with the @@ -852,6 +859,7 @@ added: applying the changeset is aborted and the database is rolled back. **Default**: A function that returns `SQLITE_CHANGESET_ABORT`. + * Returns: {boolean} Whether the changeset was applied successfully without being aborted. An exception is thrown if the database is not @@ -977,11 +985,61 @@ times with different bound values. Parameters also offer protection against [SQL injection][] attacks. For these reasons, prepared statements are preferred over hand-crafted SQL strings when handling user input. +### Binding parameters + +The `all()`, `get()`, `iterate()`, and `run()` methods bind their arguments to +the parameters of the prepared statement before executing it. Parameters are +either anonymous or named. + +Anonymous parameters are written as `?` in SQL and are bound in order from the +arguments passed to the method. The `?NNN` form assigns SQLite parameter index +`NNN` to a placeholder. Avoid mixing numbered and named parameters because they +share parameter indexes. + +```js +db.prepare('SELECT ? AS a, ? AS b').get('x', 42); +// { a: 'x', b: 42 } +db.prepare('SELECT ?2 AS a, ?1 AS b').get('first', 'second'); +// { a: 'second', b: 'first' } +``` + +Named parameters begin with one of the prefix characters `$`, `:`, or `@` in +SQL. They are bound from an object passed as the first argument. Repeating a +name in the SQL binds the same value to every occurrence. + +```js +db.prepare('SELECT $a AS a, $b AS b').get({ $a: 1, $b: 2 }); +// { a: 1, b: 2 } +db.prepare('SELECT :a AS a').get({ ':a': 1 }); +// { a: 1 } +db.prepare('SELECT @a AS a').get({ '@a': 1 }); +// { a: 1 } +db.prepare('SELECT $k AS a, $k AS b').get({ k: 7 }); +// { a: 7, b: 7 } +``` + +The last example omits the prefix character from the object key. Bare names are +allowed by default; see [`statement.setAllowBareNamedParameters()`][] for their +caveats. + +Binding a key that does not name a parameter of the statement throws an +`ERR_INVALID_STATE` error unless unknown named parameters are ignored. See +[`statement.setAllowUnknownNamedParameters()`][]. + +See [Type conversion between JavaScript and SQLite][] for the values that can be +bound. Binding any other value throws an `ERR_INVALID_ARG_TYPE` error. + ### `statement.all([namedParameters][, ...anonymousParameters])` * `stringElements` {string\[]} Template literal elements containing the SQL query. -* `...boundParameters` {null|number|bigint|string|Buffer|TypedArray|DataView} +* `...boundParameters` {null|number|bigint|boolean|string|Buffer|TypedArray|DataView|ArrayBuffer|SharedArrayBuffer} Parameter values to be bound to placeholders in the template string. * Returns: {Array} An array of objects representing the rows returned by the query. @@ -1266,11 +1353,18 @@ called directly. * `stringElements` {string\[]} Template literal elements containing the SQL query. -* `...boundParameters` {null|number|bigint|string|Buffer|TypedArray|DataView} +* `...boundParameters` {null|number|bigint|boolean|string|Buffer|TypedArray|DataView|ArrayBuffer|SharedArrayBuffer} Parameter values to be bound to placeholders in the template string. * Returns: {Object | undefined} An object representing the first row returned by the query, or `undefined` if no rows are returned. @@ -1284,11 +1378,18 @@ called directly. * `stringElements` {string\[]} Template literal elements containing the SQL query. -* `...boundParameters` {null|number|bigint|string|Buffer|TypedArray|DataView} +* `...boundParameters` {null|number|bigint|boolean|string|Buffer|TypedArray|DataView|ArrayBuffer|SharedArrayBuffer} Parameter values to be bound to placeholders in the template string. * Returns: {Iterator} An iterator that yields objects representing the rows returned by the query. @@ -1301,11 +1402,18 @@ called directly. * `stringElements` {string\[]} Template literal elements containing the SQL query. -* `...boundParameters` {null|number|bigint|string|Buffer|TypedArray|DataView} +* `...boundParameters` {null|number|bigint|boolean|string|Buffer|TypedArray|DataView|ArrayBuffer|SharedArrayBuffer} Parameter values to be bound to placeholders in the template string. * Returns: {Object} An object containing information about the execution, including `changes` and `lastInsertRowid`. @@ -1671,6 +1779,7 @@ callback function to indicate what type of operation is being authorized. +[Binding parameters]: #binding-parameters [Changesets and Patchsets]: https://www.sqlite.org/sessionintro.html#changesets_and_patchsets [Constants Passed To The Conflict Handler]: https://www.sqlite.org/session/c_changeset_conflict.html [Constants Returned From The Conflict Handler]: https://www.sqlite.org/session/c_changeset_abort.html @@ -1719,6 +1828,8 @@ callback function to indicate what type of operation is being authorized. [`sqlite3session_create()`]: https://www.sqlite.org/session/sqlite3session_create.html [`sqlite3session_delete()`]: https://www.sqlite.org/session/sqlite3session_delete.html [`sqlite3session_patchset()`]: https://www.sqlite.org/session/sqlite3session_patchset.html +[`statement.setAllowBareNamedParameters()`]: #statementsetallowbarenamedparametersenabled +[`statement.setAllowUnknownNamedParameters()`]: #statementsetallowunknownnamedparametersenabled [busy timeout]: https://sqlite.org/c3ref/busy_timeout.html [connection]: https://www.sqlite.org/c3ref/sqlite3.html [data types]: https://www.sqlite.org/datatype3.html From 4674a1036840e42b88547ed9e2dab3f2873c20f0 Mon Sep 17 00:00:00 2001 From: Hallison Pereira Melo Date: Sat, 8 Aug 2026 22:18:55 -0300 Subject: [PATCH 071/344] quic: convert incoming :status header to number Signed-off-by: Hallison Melo Co-authored-by: Cursor PR-URL: https://github.com/nodejs/node/pull/63589 Fixes: https://github.com/nodejs/node/issues/63557 Reviewed-By: Stephen Belanger Reviewed-By: Tim Perry --- doc/api/quic.md | 5 +- lib/internal/quic/quic.js | 15 +++-- .../parallel/test-quic-h3-callback-errors.mjs | 4 +- test/parallel/test-quic-h3-close-behavior.mjs | 4 +- .../test-quic-h3-concurrent-requests.mjs | 2 +- test/parallel/test-quic-h3-datagram.mjs | 4 +- test/parallel/test-quic-h3-error-codes.mjs | 4 +- test/parallel/test-quic-h3-goaway.mjs | 2 +- .../test-quic-h3-header-validation.mjs | 4 +- .../test-quic-h3-informational-headers.mjs | 8 +-- test/parallel/test-quic-h3-origin.mjs | 4 +- test/parallel/test-quic-h3-pending-stream.mjs | 2 +- .../parallel/test-quic-h3-post-filehandle.mjs | 2 +- test/parallel/test-quic-h3-post-request.mjs | 2 +- test/parallel/test-quic-h3-priority.mjs | 10 +-- test/parallel/test-quic-h3-qpack-settings.mjs | 2 +- .../test-quic-h3-request-response.mjs | 4 +- test/parallel/test-quic-h3-settings.mjs | 6 +- .../test-quic-h3-status-code-type.mjs | 64 +++++++++++++++++++ .../test-quic-h3-trailing-headers.mjs | 4 +- ...est-quic-h3-zero-rtt-rejected-settings.mjs | 2 +- test/parallel/test-quic-h3-zero-rtt.mjs | 4 +- 22 files changed, 114 insertions(+), 44 deletions(-) create mode 100644 test/parallel/test-quic-h3-status-code-type.mjs diff --git a/doc/api/quic.md b/doc/api/quic.md index 9b0254337d76..b23f27984fc0 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -3980,8 +3980,9 @@ A few things to note: the request is `HEADERS` followed by `END_STREAM`. * The `onheaders` callback receives the response pseudo-headers and regular headers in a single object with lowercase string keys. - After the callback returns, the same object is also accessible - via [`stream.headers`][]. + For incoming headers, the `:status` pseudo-header is converted to + a `number`, matching HTTP/2 behavior. After the callback returns, + the same object is also accessible via [`stream.headers`][]. * Reading `for await (const chunks of stream)` consumes the response body. Each iteration yields a `Uint8Array[]` batch of chunks. * HTTP semantic helpers (URL parsing, method/status validation, diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index 666e1f7b013b..f645998e628d 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -1285,14 +1285,19 @@ function parseHeaderPairs(pairs) { assert(pairs.length % 2 === 0); const block = { __proto__: null }; for (let n = 0; n + 1 < pairs.length; n += 2) { - if (block[pairs[n]] !== undefined) { - if (ArrayIsArray(block[pairs[n]])) { - ArrayPrototypePush(block[pairs[n]], pairs[n + 1]); + const name = pairs[n]; + let value = pairs[n + 1]; + // Match HTTP/2 behavior: incoming :status is exposed as a number. + if (name === ':status') + value |= 0; + if (block[name] !== undefined) { + if (ArrayIsArray(block[name])) { + ArrayPrototypePush(block[name], value); } else { - block[pairs[n]] = [block[pairs[n]], pairs[n + 1]]; + block[name] = [block[name], value]; } } else { - block[pairs[n]] = pairs[n + 1]; + block[name] = value; } } return block; diff --git a/test/parallel/test-quic-h3-callback-errors.mjs b/test/parallel/test-quic-h3-callback-errors.mjs index f4a9477ca873..be8ed391b8d5 100644 --- a/test/parallel/test-quic-h3-callback-errors.mjs +++ b/test/parallel/test-quic-h3-callback-errors.mjs @@ -151,7 +151,7 @@ async function makeServer(onheadersHandler, extraOpts = {}) { ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), ontrailers: mustCall(function() { throw new Error('ontrailers sync error'); @@ -265,7 +265,7 @@ async function makeServer(onheadersHandler, extraOpts = {}) { ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); diff --git a/test/parallel/test-quic-h3-close-behavior.mjs b/test/parallel/test-quic-h3-close-behavior.mjs index d25cd50e4ef5..e3b73ce9b0b9 100644 --- a/test/parallel/test-quic-h3-close-behavior.mjs +++ b/test/parallel/test-quic-h3-close-behavior.mjs @@ -62,7 +62,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall((headers) => { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); @@ -74,7 +74,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall((headers) => { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); diff --git a/test/parallel/test-quic-h3-concurrent-requests.mjs b/test/parallel/test-quic-h3-concurrent-requests.mjs index c81403bf1362..5bd5635008ca 100644 --- a/test/parallel/test-quic-h3-concurrent-requests.mjs +++ b/test/parallel/test-quic-h3-concurrent-requests.mjs @@ -72,7 +72,7 @@ const requests = paths.map(mustCall(async (path) => { ':authority': 'localhost', }, onheaders: mustCall((headers) => { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); headersReceived.resolve(); }), }); diff --git a/test/parallel/test-quic-h3-datagram.mjs b/test/parallel/test-quic-h3-datagram.mjs index 4d081a9f1bce..38aeb971c8fe 100644 --- a/test/parallel/test-quic-h3-datagram.mjs +++ b/test/parallel/test-quic-h3-datagram.mjs @@ -87,7 +87,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); @@ -151,7 +151,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall((headers) => { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); diff --git a/test/parallel/test-quic-h3-error-codes.mjs b/test/parallel/test-quic-h3-error-codes.mjs index cd5c9ff0a25a..3a91a2e8f056 100644 --- a/test/parallel/test-quic-h3-error-codes.mjs +++ b/test/parallel/test-quic-h3-error-codes.mjs @@ -55,7 +55,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); @@ -106,7 +106,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); diff --git a/test/parallel/test-quic-h3-goaway.mjs b/test/parallel/test-quic-h3-goaway.mjs index c3b6e3ae246a..bb0bf8e966c1 100644 --- a/test/parallel/test-quic-h3-goaway.mjs +++ b/test/parallel/test-quic-h3-goaway.mjs @@ -78,7 +78,7 @@ dc.subscribe('quic.session.goaway', mustCall((msg) => { await clientSession.opened; const onClientHeaders = mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); if (++clientHeaderCount === 2) { bothHeadersReceived.resolve(); } diff --git a/test/parallel/test-quic-h3-header-validation.mjs b/test/parallel/test-quic-h3-header-validation.mjs index 873991a89864..a75a884c39b9 100644 --- a/test/parallel/test-quic-h3-header-validation.mjs +++ b/test/parallel/test-quic-h3-header-validation.mjs @@ -91,7 +91,7 @@ const decoder = new TextDecoder(); }, onheaders: mustCall(function(headers) { // Client should also receive lowercased response header names. - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); assert.strictEqual(headers['content-type'], 'text/html'); assert.strictEqual(headers['x-response-header'], 'ResponseValue'); @@ -148,7 +148,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall((headers) => { - assert.strictEqual(headers[':status'], '204'); + assert.strictEqual(headers[':status'], 204); }), }); diff --git a/test/parallel/test-quic-h3-informational-headers.mjs b/test/parallel/test-quic-h3-informational-headers.mjs index 1bab5b26d436..357b507ae0db 100644 --- a/test/parallel/test-quic-h3-informational-headers.mjs +++ b/test/parallel/test-quic-h3-informational-headers.mjs @@ -34,7 +34,7 @@ dc.subscribe('quic.stream.info', mustCall((msg) => { assert.ok(msg.stream, 'stream.info should include stream'); assert.ok(msg.session, 'stream.info should include session'); assert.ok(msg.headers, 'stream.info should include headers'); - assert.strictEqual(msg.headers[':status'], '103'); + assert.strictEqual(msg.headers[':status'], 103); })); // quic.stream.headers also fires for the final response headers. @@ -89,12 +89,12 @@ const stream = await clientSession.createBidirectionalStream({ ':authority': 'localhost', }, oninfo: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '103'); + assert.strictEqual(headers[':status'], 103); assert.strictEqual(headers.link, '; rel=preload; as=style'); clientInfoReceived.resolve(); }), onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); assert.strictEqual(headers['content-type'], 'text/plain'); clientHeadersReceived.resolve(); }), @@ -107,7 +107,7 @@ const body = await bytes(stream); assert.strictEqual(decoder.decode(body), responseBody); // stream.headers should return the final (initial) headers, not 1xx. -assert.strictEqual(stream.headers[':status'], '200'); +assert.strictEqual(stream.headers[':status'], 200); await Promise.all([stream.closed, serverDone.promise]); await clientSession.close(); diff --git a/test/parallel/test-quic-h3-origin.mjs b/test/parallel/test-quic-h3-origin.mjs index 05e7d166585e..9f80449b6b65 100644 --- a/test/parallel/test-quic-h3-origin.mjs +++ b/test/parallel/test-quic-h3-origin.mjs @@ -77,7 +77,7 @@ const decoder = new TextDecoder(); ':authority': 'example.com', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); @@ -173,7 +173,7 @@ const decoder = new TextDecoder(); ':authority': 'custom-port.example.com', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); diff --git a/test/parallel/test-quic-h3-pending-stream.mjs b/test/parallel/test-quic-h3-pending-stream.mjs index 836c032e2b99..a6e9c8cfd912 100644 --- a/test/parallel/test-quic-h3-pending-stream.mjs +++ b/test/parallel/test-quic-h3-pending-stream.mjs @@ -64,7 +64,7 @@ const decoder = new TextDecoder(); priority: 'high', incremental: true, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); diff --git a/test/parallel/test-quic-h3-post-filehandle.mjs b/test/parallel/test-quic-h3-post-filehandle.mjs index ce6bec75c57a..a4c583463d24 100644 --- a/test/parallel/test-quic-h3-post-filehandle.mjs +++ b/test/parallel/test-quic-h3-post-filehandle.mjs @@ -76,7 +76,7 @@ writeFileSync(testFile, testContent); }, body: fh, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); clientHeadersReceived.resolve(); }), }); diff --git a/test/parallel/test-quic-h3-post-request.mjs b/test/parallel/test-quic-h3-post-request.mjs index adf874a0e8aa..c5d9635a640c 100644 --- a/test/parallel/test-quic-h3-post-request.mjs +++ b/test/parallel/test-quic-h3-post-request.mjs @@ -84,7 +84,7 @@ const stream = await clientSession.createBidirectionalStream({ }, body: encoder.encode(requestBody), onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); clientHeadersReceived.resolve(); }), }); diff --git a/test/parallel/test-quic-h3-priority.mjs b/test/parallel/test-quic-h3-priority.mjs index fcf7210b703c..10be3d6f216e 100644 --- a/test/parallel/test-quic-h3-priority.mjs +++ b/test/parallel/test-quic-h3-priority.mjs @@ -67,7 +67,7 @@ const decoder = new TextDecoder(); priority: 'high', incremental: false, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); @@ -85,7 +85,7 @@ const decoder = new TextDecoder(); priority: 'low', incremental: true, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); assert.deepStrictEqual(stream2.priority, { level: 'low', incremental: true }); @@ -99,7 +99,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); assert.deepStrictEqual(stream3.priority, { level: 'default', incremental: false }); @@ -113,7 +113,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); // Default priority initially. @@ -215,7 +215,7 @@ const decoder = new TextDecoder(); }, body: encoder.encode('signal'), onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); assert.deepStrictEqual(stream.priority, { level: 'default', incremental: false }); diff --git a/test/parallel/test-quic-h3-qpack-settings.mjs b/test/parallel/test-quic-h3-qpack-settings.mjs index e56730531c0f..f30b7163cf6b 100644 --- a/test/parallel/test-quic-h3-qpack-settings.mjs +++ b/test/parallel/test-quic-h3-qpack-settings.mjs @@ -35,7 +35,7 @@ async function makeRequest(clientSession, path) { ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); const body = await bytes(stream); diff --git a/test/parallel/test-quic-h3-request-response.mjs b/test/parallel/test-quic-h3-request-response.mjs index e359e492f753..cde16684d7e9 100644 --- a/test/parallel/test-quic-h3-request-response.mjs +++ b/test/parallel/test-quic-h3-request-response.mjs @@ -93,7 +93,7 @@ const stream = await clientSession.createBidirectionalStream({ ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); assert.strictEqual(headers['content-type'], 'text/plain'); clientHeadersReceived.resolve(); }), @@ -106,7 +106,7 @@ const body = await bytes(stream); assert.strictEqual(decoder.decode(body), responseBody); // stream.headers should return the buffered response headers. -assert.strictEqual(stream.headers[':status'], '200'); +assert.strictEqual(stream.headers[':status'], 200); await Promise.all([stream.closed, serverDone.promise]); await clientSession.close(); diff --git a/test/parallel/test-quic-h3-settings.mjs b/test/parallel/test-quic-h3-settings.mjs index 3a2bd9387f57..733d3865e872 100644 --- a/test/parallel/test-quic-h3-settings.mjs +++ b/test/parallel/test-quic-h3-settings.mjs @@ -71,7 +71,7 @@ const decoder = new TextDecoder(); 'x-second': 'two', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); @@ -129,7 +129,7 @@ const decoder = new TextDecoder(); 'x-long': longValue, }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); @@ -185,7 +185,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); diff --git a/test/parallel/test-quic-h3-status-code-type.mjs b/test/parallel/test-quic-h3-status-code-type.mjs new file mode 100644 index 000000000000..a1bc7178e17a --- /dev/null +++ b/test/parallel/test-quic-h3-status-code-type.mjs @@ -0,0 +1,64 @@ +// Flags: --experimental-quic --experimental-stream-iter --no-warnings + +// Verify incoming :status is exposed as a number, matching HTTP/2 behavior. +// See https://github.com/nodejs/node/issues/63557 + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect } = await import('node:quic'); +const { createPrivateKey } = await import('node:crypto'); + +const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); +const cert = fixtures.readKey('agent1-cert.pem'); + +const codes = [200, 204, 404]; +let serverResponses = 0; +const serverDone = Promise.withResolvers(); + +const serverEndpoint = await listen(mustCall(async (ss) => { + ss.onstream = mustCall(() => { + if (++serverResponses === codes.length) { + ss.close(); + serverDone.resolve(); + } + }, codes.length); +}), { + sni: { '*': { keys: [key], certs: [cert] } }, + onheaders: mustCall(function() { + const status = codes[serverResponses - 1]; + this.sendHeaders({ ':status': String(status) }, { terminal: true }); + this.writer.endSync(); + }, codes.length), +}); + +const clientSession = await connect(serverEndpoint.address, { + servername: 'localhost', + verifyPeer: 'manual', +}); +await clientSession.opened; + +for (const expected of codes) { + const stream = await clientSession.createBidirectionalStream({ + headers: { + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'localhost', + }, + onheaders: mustCall(function(headers) { + assert.strictEqual(typeof headers[':status'], 'number'); + assert.strictEqual(headers[':status'], expected); + }), + }); + await stream.closed; +} + +await serverDone.promise; +await clientSession.close(); +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-h3-trailing-headers.mjs b/test/parallel/test-quic-h3-trailing-headers.mjs index e19886fa5bad..f4ffc223d4d5 100644 --- a/test/parallel/test-quic-h3-trailing-headers.mjs +++ b/test/parallel/test-quic-h3-trailing-headers.mjs @@ -94,7 +94,7 @@ const stream = await clientSession.createBidirectionalStream({ ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); clientHeadersReceived.resolve(); }), ontrailers: mustCall(function(trailers) { @@ -114,7 +114,7 @@ assert.strictEqual(decoder.decode(body), responseBody); await clientTrailersReceived.promise; // stream.headers should still be the initial headers, not trailers. -assert.strictEqual(stream.headers[':status'], '200'); +assert.strictEqual(stream.headers[':status'], 200); await Promise.all([stream.closed, serverDone.promise]); await clientSession.close(); diff --git a/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs b/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs index c17f2ad3994c..755bde188e0b 100644 --- a/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs +++ b/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs @@ -74,7 +74,7 @@ async function getTicket(endpointOptions) { ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); const body = await bytes(s); diff --git a/test/parallel/test-quic-h3-zero-rtt.mjs b/test/parallel/test-quic-h3-zero-rtt.mjs index ef51c63ee8aa..f836caa1ec4f 100644 --- a/test/parallel/test-quic-h3-zero-rtt.mjs +++ b/test/parallel/test-quic-h3-zero-rtt.mjs @@ -83,7 +83,7 @@ const s1 = await cs1.createBidirectionalStream({ ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); const body1 = await bytes(s1); @@ -111,7 +111,7 @@ const s2 = await cs2.createBidirectionalStream({ ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); From dad9548fbc8bd8b88bedf233c309d3d799e75eee Mon Sep 17 00:00:00 2001 From: Naman Trivedi Date: Mon, 3 Aug 2026 22:29:08 +0000 Subject: [PATCH 072/344] http: emit drain on socket takeover and avoid stale HWM reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When OutgoingMessage transitions from pre-socket buffering (Path B) to socket-connected writing (Path A), the backpressure domain changes — subsequent writes go directly to the socket, which enforces its own backpressure via socket.write() return values. The OM should emit drain at this transition point to signal that its buffer is clear and the caller can resume writing under the socket backpressure regime. Previously, _flush() gated drain emission on writableLength === 0 which included socket.writableLength. This conflated two independent backpressure domains: the OM pre-socket buffer and the socket kernel write queue. When the socket had a higher writableHighWaterMark than the OM (e.g. agent-reused socket from a prior request), the socket was never backpressured and never emitted drain, causing a permanent deadlock. Additionally, avoid reusing a pooled socket in http.Agent when its writableHighWaterMark differs from the request highWaterMark, so that the user backpressure threshold is respected for the common case of the built-in Agent. Signed-off-by: Naman Trivedi Fixes: https://github.com/nodejs/node/issues/64680 Refs: https://github.com/nodejs/node/pull/64653 Refs: https://github.com/nodejs/node/pull/62936 PR-URL: https://github.com/nodejs/node/pull/64991 Reviewed-By: Robert Nagy Reviewed-By: Trivikram Kamat Reviewed-By: James M Snell Reviewed-By: Gürgün Dayıoğlu --- lib/_http_agent.js | 10 ++++ lib/_http_outgoing.js | 6 +- .../test-http-agent-highwatermark-reuse.js | 56 ++++++++++++++++++ .../test-http-outgoing-flush-drain.js | 57 +++++++++++++++++++ 4 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 test/parallel/test-http-agent-highwatermark-reuse.js create mode 100644 test/parallel/test-http-outgoing-flush-drain.js diff --git a/lib/_http_agent.js b/lib/_http_agent.js index eb58650f8851..9e3129100f90 100644 --- a/lib/_http_agent.js +++ b/lib/_http_agent.js @@ -392,6 +392,16 @@ Agent.prototype.addRequest = function addRequest(req, options, port/* legacy */, const sockLen = freeLen + this.sockets[name].length; // Reusing a socket from the pool. + // If the caller specified a highWaterMark that differs from the pooled + // socket's writableHighWaterMark, sync the socket's HWM so that + // backpressure semantics match what the caller requested. + if (socket && options.highWaterMark != null && + socket.writableHighWaterMark !== options.highWaterMark) { + debug('sync reused socket HWM (socket=%d, request=%d)', + socket.writableHighWaterMark, options.highWaterMark); + socket._writableState.highWaterMark = options.highWaterMark; + } + if (socket) { asyncResetHandle(socket); this.reuseSocket(socket, req); diff --git a/lib/_http_outgoing.js b/lib/_http_outgoing.js index 41465bf42196..b266cf49971e 100644 --- a/lib/_http_outgoing.js +++ b/lib/_http_outgoing.js @@ -1230,12 +1230,14 @@ OutgoingMessage.prototype._flush = function _flush() { if (socket?.writable) { // There might be remaining data in this.output; write it out - this._flushOutput(socket); + const ret = this._flushOutput(socket); if (this.finished) { // This is a queue to the server or client to bring in the next this. this._finish(); - } else if (this[kNeedDrain] && this.writableLength === 0) { + } else if (this[kNeedDrain] && ret !== false) { + // Socket accepted all data without backpressure - it won't emit + // drain, so we emit it since the OM buffer is now clear. this[kNeedDrain] = false; this.emit('drain'); } diff --git a/test/parallel/test-http-agent-highwatermark-reuse.js b/test/parallel/test-http-agent-highwatermark-reuse.js new file mode 100644 index 000000000000..b78b475ef6f1 --- /dev/null +++ b/test/parallel/test-http-agent-highwatermark-reuse.js @@ -0,0 +1,56 @@ +'use strict'; + +// Regression test: when a pooled socket's writableHighWaterMark differs from +// the new request's highWaterMark, the agent must sync the socket's HWM so +// that backpressure semantics match what the caller requested. +// +// See: https://github.com/nodejs/node/issues/64680 + +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); + +const server = http.createServer(common.mustCall((req, res) => { + req.resume(); + req.on('end', () => res.end('ok')); +}, 2)); + +server.listen(0, common.mustCall(() => { + const port = server.address().port; + const agent = new http.Agent({ keepAlive: true }); + + // Request A: creates socket with HWM=1MB. + http.request({ + host: 'localhost', port, method: 'POST', agent, + highWaterMark: 1024 * 1024, + }, common.mustCall((res) => { + res.resume(); + res.on('end', common.mustCall(() => { + // Wait for socket to return to pool. + setTimeout(common.mustCall(requestB), 100); + })); + })).end('x'); + + function requestB() { + const freeCount = Object.values(agent.freeSockets).flat().length; + assert.strictEqual(freeCount, 1); + + // Request B: HWM=10KB — agent must sync the reused socket's HWM. + const reqB = http.request({ + host: 'localhost', port, method: 'POST', agent, + highWaterMark: 10 * 1024, + }, common.mustCall((res) => { + res.resume(); + res.on('end', common.mustCall(() => { + server.close(); + })); + })); + + reqB.on('socket', common.mustCall((socket) => { + // Socket HWM must be synced to the request's value. + assert.strictEqual(socket.writableHighWaterMark, 10 * 1024); + })); + + reqB.end('y'); + } +})); diff --git a/test/parallel/test-http-outgoing-flush-drain.js b/test/parallel/test-http-outgoing-flush-drain.js new file mode 100644 index 000000000000..12b5a5036cce --- /dev/null +++ b/test/parallel/test-http-outgoing-flush-drain.js @@ -0,0 +1,57 @@ +'use strict'; + +// Regression test: when _flush() hands buffered data to a socket whose +// writableHighWaterMark is higher than the OutgoingMessage's kHighWaterMark, +// drain must still fire. Previously, _flush() gated drain emission on +// writableLength === 0, which included socket.writableLength — but the +// socket was never backpressured (data < socket HWM), so drain never fired. +// +// See: https://github.com/nodejs/node/issues/64680 + +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); + +// Server that delays reading to keep socket.writableLength > 0 during flush. +const server = http.createServer(common.mustCall((req, res) => { + setTimeout(() => { + req.resume(); + req.on('end', () => res.end('ok')); + }, 500); +}, 2)); + +server.listen(0, common.mustCall(() => { + const port = server.address().port; + const agent = new http.Agent({ keepAlive: true }); + + // Request A: creates socket with HWM=2MB. + http.request({ + host: 'localhost', port, method: 'POST', agent, + highWaterMark: 2 * 1024 * 1024, + }, common.mustCall((res) => { + res.resume(); + res.on('end', common.mustCall(() => { + // Wait for socket to return to pool. + setTimeout(common.mustCall(() => { + // Request B: default HWM (64KB), reuses socket (HWM=2MB). + // Write 500KB: above OM HWM (64KB), below socket HWM (2MB). + const reqB = http.request({ + host: 'localhost', port, method: 'POST', agent, + }, common.mustCall((res2) => { + res2.resume(); + res2.on('end', common.mustCall(() => { + server.close(); + })); + })); + + const result = reqB.write(Buffer.alloc(500 * 1024)); + assert.strictEqual(result, false); + + // Drain must fire — no deadlock. + reqB.on('drain', common.mustCall(() => { + reqB.end(); + })); + }), 100); + })); + })).end('x'); +})); From d8acfc45f117ba362f4afdca03ffee0eae16ce95 Mon Sep 17 00:00:00 2001 From: Luan Muniz Date: Sun, 9 Aug 2026 09:43:52 +0200 Subject: [PATCH 073/344] benchmark: add test runner hooks and options Add benchmarks for node:test hooks and test options. The hooks benchmark covers before, after, beforeEach, and afterEach, with a none mode as the baseline. The test options benchmark covers skip and todo behavior. This adds coverage for part of the benchmark/test_runner gaps tracked in the issue. Refs: https://github.com/nodejs/node/issues/55723 Signed-off-by: Luan Muniz PR-URL: https://github.com/nodejs/node/pull/63754 Reviewed-By: Aviv Keller Reviewed-By: Rafael Gonzaga --- benchmark/test_runner/hooks.js | 51 ++++++++++++ benchmark/test_runner/test-options.js | 114 ++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 benchmark/test_runner/hooks.js create mode 100644 benchmark/test_runner/test-options.js diff --git a/benchmark/test_runner/hooks.js b/benchmark/test_runner/hooks.js new file mode 100644 index 000000000000..dc73ff4fb1e1 --- /dev/null +++ b/benchmark/test_runner/hooks.js @@ -0,0 +1,51 @@ +'use strict'; + +const common = require('../common'); +const { finished } = require('node:stream/promises'); +const reporter = require('../fixtures/empty-test-reporter'); +const { + after, + afterEach, + before, + beforeEach, + describe, + it, +} = require('node:test'); + +const bench = common.createBenchmark(main, { + n: [1000], + hook: ['before', 'after', 'beforeEach', 'afterEach'], +}, { + // We don't want to test the reporter here. + flags: ['--test-reporter=./benchmark/fixtures/empty-test-reporter.js'], +}); + +const hookList = { + before: before, + after: after, + beforeEach: beforeEach, + afterEach: afterEach, +}; + +const noop = () => {}; + +function run(loopAmount, hookFn) { + for (let i = 0; i < loopAmount; i++) { + describe(`${i}`, () => { + hookFn(noop); + it(`${i}`, noop); + }); + } + + return finished(reporter); +} + +function main(params) { + const hookFn = hookList[params.hook]; + + bench.start(); + + run(params.n, hookFn).then(() => { + bench.end(params.n); + }); +} diff --git a/benchmark/test_runner/test-options.js b/benchmark/test_runner/test-options.js new file mode 100644 index 000000000000..1d608c1f9ccb --- /dev/null +++ b/benchmark/test_runner/test-options.js @@ -0,0 +1,114 @@ +'use strict'; + +const common = require('../common'); +const { finished } = require('node:stream/promises'); +const reporter = require('../fixtures/empty-test-reporter'); +const { it } = require('node:test'); + +const bench = common.createBenchmark(main, { + n: [10000], + option: [ + 'none', + 'skip', + 'skip-with-message', + 'skip-method', + 'skip-method-with-message', + 'todo', + 'todo-with-message', + 'todo-method', + 'todo-method-with-message', + ], +}, { + // We don't want to test the reporter here. + flags: ['--test-reporter=./benchmark/fixtures/empty-test-reporter.js'], +}); + +const noop = () => {}; + +const allTests = { + 'none': (loopAmount) => { + for (let i = 0; i < loopAmount; i++) { + it(`${i}`, noop); + } + + return finished(reporter); + }, + 'skip': (loopAmount) => { + for (let i = 0; i < loopAmount; i++) { + it(`${i}`, { skip: true }, () => { + throw new Error('This test should not run.'); + }); + } + + return finished(reporter); + }, + 'skip-with-message': (loopAmount) => { + for (let i = 0; i < loopAmount; i++) { + it(`${i}`, { skip: 'skip reason' }, () => { + throw new Error('This test should not run.'); + }); + } + + return finished(reporter); + }, + 'skip-method': (loopAmount) => { + for (let i = 0; i < loopAmount; i++) { + it(`${i}`, (t) => { + t.skip(); + }); + } + + return finished(reporter); + }, + 'skip-method-with-message': (loopAmount) => { + for (let i = 0; i < loopAmount; i++) { + it(`${i}`, (t) => { + t.skip('skip reason'); + }); + } + + return finished(reporter); + }, + 'todo': (loopAmount) => { + for (let i = 0; i < loopAmount; i++) { + it(`${i}`, { todo: true }, noop); + } + + return finished(reporter); + }, + 'todo-with-message': (loopAmount) => { + for (let i = 0; i < loopAmount; i++) { + it(`${i}`, { todo: 'todo reason' }, noop); + } + + return finished(reporter); + }, + 'todo-method': (loopAmount) => { + for (let i = 0; i < loopAmount; i++) { + it(`${i}`, (t) => { + t.todo(); + }); + } + + return finished(reporter); + }, + 'todo-method-with-message': (loopAmount) => { + for (let i = 0; i < loopAmount; i++) { + it(`${i}`, (t) => { + t.todo('todo reason'); + }); + } + + return finished(reporter); + }, +}; + +function main({ n, option }) { + const runOption = allTests[option]; + + bench.start(); + + runOption(n).then(() => { + bench.end(n); + }); +} From 5a852b22d49fe9bf1aed425463b83acb448fc128 Mon Sep 17 00:00:00 2001 From: Nora Dossche <7771979+ndossche@users.noreply.github.com> Date: Sun, 9 Aug 2026 09:44:02 +0200 Subject: [PATCH 074/344] sqlite: check null returns from sqlite value functions sqlite3_column_text() can return nullptr on failure which was not handled. sqlite3_column_blob() can return nullptr for zero-length BLOBs, which is then passed to memcpy() which is UB. Avoid this by checking for a nullptr. Signed-off-by: ndossche PR-URL: https://github.com/nodejs/node/pull/63288 Reviewed-By: Trivikram Kamat --- src/node_sqlite.cc | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/node_sqlite.cc b/src/node_sqlite.cc index e3604d97bc53..027372e42daa 100644 --- a/src/node_sqlite.cc +++ b/src/node_sqlite.cc @@ -121,6 +121,10 @@ inline MaybeLocal Utf8StringMaybeOneByte(Isolate* isolate, case SQLITE_TEXT: { \ const char* v = \ reinterpret_cast(sqlite3_##from##_text(__VA_ARGS__)); \ + if (v == nullptr) [[unlikely]] { \ + THROW_ERR_MEMORY_ALLOCATION_FAILED((isolate)); \ + break; \ + } \ const int v_len = sqlite3_##from##_bytes(__VA_ARGS__); \ (result) = \ Utf8StringMaybeOneByte((isolate), std::string_view(v, v_len)) \ @@ -138,7 +142,9 @@ inline MaybeLocal Utf8StringMaybeOneByte(Isolate* isolate, sqlite3_##from##_blob(__VA_ARGS__)); \ auto store = ArrayBuffer::NewBackingStore( \ (isolate), size, BackingStoreInitializationMode::kUninitialized); \ - memcpy(store->Data(), data, size); \ + if (data != nullptr) [[likely]] { \ + memcpy(store->Data(), data, size); \ + } \ auto ab = ArrayBuffer::New((isolate), std::move(store)); \ (result) = Uint8Array::New(ab, 0, size); \ break; \ From 9633bb0f2957acbbc85a5f91d6c938b21b33df69 Mon Sep 17 00:00:00 2001 From: greenhead Date: Sun, 9 Aug 2026 17:06:32 +0900 Subject: [PATCH 075/344] doc: fix permission documentation examples Signed-off-by: greenhead PR-URL: https://github.com/nodejs/node/pull/64897 Reviewed-By: Aviv Keller --- doc/api/permissions.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/api/permissions.md b/doc/api/permissions.md index c387e899cf1a..0b46f42d982a 100644 --- a/doc/api/permissions.md +++ b/doc/api/permissions.md @@ -138,7 +138,7 @@ const config = fs.readFileSync('/etc/myapp/config.json', 'utf8'); // Drop read access to /etc/myapp after initialization process.permission.drop('fs.read', '/etc/myapp'); -// This will now throw ERR_ACCESS_DENIED +// This will now return false process.permission.has('fs.read', '/etc/myapp/config.json'); // false // Drop child process permission entirely @@ -219,7 +219,7 @@ $ node --permission index.js * `index.js` will be included in the allowed file system read list ```console -$ node -r /path/to/custom-require.js --permission index.js. +$ node -r /path/to/custom-require.js --permission index.js ``` * `/path/to/custom-require.js` will be included in the allowed file system read From 7fc8a3ad8cd289f8f7476dd409ec82400212cf27 Mon Sep 17 00:00:00 2001 From: kyungrae2002 <148605113+kyungrae2002@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:06:42 +0900 Subject: [PATCH 076/344] test,doc: cover and document multi-byte offset/size in randomFill Signed-off-by: kyungrae PR-URL: https://github.com/nodejs/node/pull/64834 Reviewed-By: Daeyeon Jeong --- doc/api/crypto.md | 18 +++++++---- lib/internal/crypto/random.js | 2 +- test/parallel/test-crypto-random.js | 49 +++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/doc/api/crypto.md b/doc/api/crypto.md index 6811bfb40d91..659905e17482 100644 --- a/doc/api/crypto.md +++ b/doc/api/crypto.md @@ -5608,9 +5608,12 @@ changes: * `buffer` {ArrayBuffer|Buffer|TypedArray|DataView} Must be supplied. The size of the provided `buffer` must not be larger than `2**31 - 1`. -* `offset` {number} **Default:** `0` -* `size` {number} **Default:** `buffer.length - offset`. The `size` must - not be larger than `2**31 - 1`. +* `offset` {number} The start position, in elements for a `TypedArray` and in + bytes for an `ArrayBuffer` or `DataView`. **Default:** `0` +* `size` {number} The amount to fill, in the same units as `offset`. + **Default:** `buffer.length - offset` for a `TypedArray`, or + `buffer.byteLength - offset` for an `ArrayBuffer` or `DataView`. The `size` + must not be larger than `2**31 - 1`. * `callback` {Function} `function(err, buf) {}`. This function is similar to [`crypto.randomBytes()`][] but requires the first @@ -5745,9 +5748,12 @@ changes: * `buffer` {ArrayBuffer|Buffer|TypedArray|DataView} Must be supplied. The size of the provided `buffer` must not be larger than `2**31 - 1`. -* `offset` {number} **Default:** `0` -* `size` {number} **Default:** `buffer.length - offset`. The `size` must - not be larger than `2**31 - 1`. +* `offset` {number} The start position, in elements for a `TypedArray` and in + bytes for an `ArrayBuffer` or `DataView`. **Default:** `0` +* `size` {number} The amount to fill, in the same units as `offset`. + **Default:** `buffer.length - offset` for a `TypedArray`, or + `buffer.byteLength - offset` for an `ArrayBuffer` or `DataView`. The `size` + must not be larger than `2**31 - 1`. * Returns: {ArrayBuffer|Buffer|TypedArray|DataView} The object passed as `buffer` argument. diff --git a/lib/internal/crypto/random.js b/lib/internal/crypto/random.js index a75c14fd2a1c..919ad68f5617 100644 --- a/lib/internal/crypto/random.js +++ b/lib/internal/crypto/random.js @@ -168,7 +168,7 @@ function randomFill(buf, offset, size, callback) { size = buf.length; } else if (typeof size === 'function') { callback = size; - size = buf.length - offset; + size = (buf.length ?? buf.byteLength) - offset; } else { validateFunction(callback, 'callback'); } diff --git a/test/parallel/test-crypto-random.js b/test/parallel/test-crypto-random.js index ceaa859a0e03..88b6fcba84d7 100644 --- a/test/parallel/test-crypto-random.js +++ b/test/parallel/test-crypto-random.js @@ -218,6 +218,55 @@ common.expectWarning('DeprecationWarning', })); } +{ + const buf = new Uint16Array(10); + const before = Buffer.from(buf.buffer).toString('hex'); + crypto.randomFillSync(buf, 1, 8); + const after = Buffer.from(buf.buffer).toString('hex'); + assert.notStrictEqual(before, after); + assert.deepStrictEqual(before.slice(0, 4), after.slice(0, 4)); + assert.deepStrictEqual(before.slice(-4), after.slice(-4)); +} + +{ + const buf = new Uint32Array(10); + const before = Buffer.from(buf.buffer).toString('hex'); + crypto.randomFillSync(buf, 1, 8); + const after = Buffer.from(buf.buffer).toString('hex'); + assert.notStrictEqual(before, after); + assert.deepStrictEqual(before.slice(0, 8), after.slice(0, 8)); + assert.deepStrictEqual(before.slice(-8), after.slice(-8)); +} + +{ + const buf = new Uint16Array(10); + const before = Buffer.from(buf.buffer).toString('hex'); + crypto.randomFill(buf, 1, 8, common.mustSucceed((buf) => { + const after = Buffer.from(buf.buffer).toString('hex'); + assert.notStrictEqual(before, after); + assert.deepStrictEqual(before.slice(0, 4), after.slice(0, 4)); + assert.deepStrictEqual(before.slice(-4), after.slice(-4)); + })); +} + +{ + const buf = new Uint32Array(10); + const before = Buffer.from(buf.buffer).toString('hex'); + crypto.randomFill(buf, 1, 8, common.mustSucceed((buf) => { + const after = Buffer.from(buf.buffer).toString('hex'); + assert.notStrictEqual(before, after); + assert.deepStrictEqual(before.slice(0, 8), after.slice(0, 8)); + assert.deepStrictEqual(before.slice(-8), after.slice(-8)); + })); +} + +{ + // randomFill() with an offset and no size must not throw for types + // without a .length property, matching randomFillSync(). + crypto.randomFill(new ArrayBuffer(10), 2, common.mustSucceed()); + crypto.randomFill(new DataView(new ArrayBuffer(10)), 2, common.mustSucceed()); +} + { [ Buffer.alloc(10), From 5b64b377391008cd2c7fa745c371447e80fd13d4 Mon Sep 17 00:00:00 2001 From: Brian Muenzenmeyer Date: Sun, 9 Aug 2026 03:06:52 -0500 Subject: [PATCH 077/344] doc: report proper return type on urlPattern.test Signed-off-by: bmuenzenmeyer PR-URL: https://github.com/nodejs/node/pull/64831 Reviewed-By: Aviv Keller Reviewed-By: Luigi Pinca Reviewed-By: James M Snell --- doc/api/url.md | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/api/url.md b/doc/api/url.md index bdc27144fa3a..7759c03a15e7 100644 --- a/doc/api/url.md +++ b/doc/api/url.md @@ -830,6 +830,7 @@ console.log(myPattern.exec('https://nodejs.org/docs/latest/api/dns.html')); * `input` {string | Object} A URL or URL parts * `baseURL` {string | undefined} A base URL string +* Returns {boolean} Input can be a string or an object providing the individual URL parts. The object members can be any of `protocol`, `username`, `password`, `hostname`, From 31c1bcb627056f20b25f0e75b9dce2729d93e9f8 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Sun, 9 Aug 2026 12:42:30 +0200 Subject: [PATCH 078/344] tools: delay removal of `commit-queue` label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The removes the possiblity for a PR to drop from the queue if the CQ job is cancelled (or times out) in the middle of handling a PR. This increases the window for two concurrent CQ jobs to pick up the same PR, but that's an unlikely scenario. Signed-off-by: Antoine du Hamel PR-URL: https://github.com/nodejs/node/pull/65101 Refs: https://github.com/nodejs/node/issues/64972 Reviewed-By: René Reviewed-By: Marco Ippolito Reviewed-By: Colin Ihrig Reviewed-By: Aviv Keller Reviewed-By: Luigi Pinca Reviewed-By: Moshe Atlow --- tools/actions/commit-queue.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tools/actions/commit-queue.sh b/tools/actions/commit-queue.sh index aa63a442377a..b6e62139d626 100755 --- a/tools/actions/commit-queue.sh +++ b/tools/actions/commit-queue.sh @@ -15,7 +15,7 @@ COMMIT_QUEUE_FAILED_LABEL="commit-queue-failed" commit_queue_failed() { pr=$1 - gh pr edit "$pr" --add-label "${COMMIT_QUEUE_FAILED_LABEL}" + gh pr edit "$pr" --add-label "${COMMIT_QUEUE_FAILED_LABEL}" --remove-label "${COMMIT_QUEUE_LABEL}" # shellcheck disable=SC2154 cqurl="${GITHUB_SERVER_URL}/${OWNER}/${REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" @@ -47,9 +47,6 @@ for pr in "$@"; do continue fi - # Delete the commit queue label - gh pr edit "$pr" --remove-label "$COMMIT_QUEUE_LABEL" - if jq -e 'map(.name) | index("commit-queue-squash")' < labels.json; then MULTIPLE_COMMIT_POLICY="--fixupAll" elif jq -e 'map(.name) | index("commit-queue-rebase")' < labels.json; then @@ -114,6 +111,9 @@ for pr in "$@"; do gh pr comment "$pr" --body "Landed in $commits" [ -z "$MULTIPLE_COMMIT_POLICY" ] && gh pr close "$pr" + + # Delete the commit queue label (but ignore errors, it's no big deal if a closed PR still has the label) + gh pr edit "$pr" --remove-label "$COMMIT_QUEUE_LABEL" || true done rm -f labels.json From 8f5f48ad9690b033bc9cfc26ff756b78139dac79 Mon Sep 17 00:00:00 2001 From: greenhead Date: Sun, 9 Aug 2026 20:38:10 +0900 Subject: [PATCH 079/344] doc: fix broken internal links Signed-off-by: greenhead PR-URL: https://github.com/nodejs/node/pull/64901 Reviewed-By: Daeyeon Jeong --- doc/api/child_process.md | 2 +- doc/contributing/large-pull-requests.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/api/child_process.md b/doc/api/child_process.md index e90759b16d3f..913542c2dc06 100644 --- a/doc/api/child_process.md +++ b/doc/api/child_process.md @@ -2390,7 +2390,7 @@ or [`child_process.fork()`][]. [`subprocess.stdin`]: #subprocessstdin [`subprocess.stdio`]: #subprocessstdio [`subprocess.stdout`]: #subprocessstdout -[`util.convertProcessSignalToExitCode()`]: util.md#utilconvertprocesssignaltoexitcodesignalcode +[`util.convertProcessSignalToExitCode()`]: util.md#utilconvertprocesssignaltoexitcodesignal [`util.promisify()`]: util.md#utilpromisifyoriginal [synchronous counterparts]: #synchronous-process-creation [v8.serdes]: v8.md#serialization-api diff --git a/doc/contributing/large-pull-requests.md b/doc/contributing/large-pull-requests.md index 8920ffdcbb37..6660fb0d48f1 100644 --- a/doc/contributing/large-pull-requests.md +++ b/doc/contributing/large-pull-requests.md @@ -8,7 +8,7 @@ * [Review guide](#review-guide) * [Approval requirements](#approval-requirements) * [Dependency changes](#dependency-changes) -* [Splitting large pull requests](#splitting-large-pull-requests) +* [Avoiding large pull requests](#avoiding-large-pull-requests) * [Feature forks and branches](#feature-forks-and-branches) * [Guidance for reviewers](#guidance-for-reviewers) From b5182f0a1ce120e3365907beff948ecac1371d26 Mon Sep 17 00:00:00 2001 From: greenhead Date: Sun, 9 Aug 2026 20:55:05 +0900 Subject: [PATCH 080/344] lib: use validateArray for array arguments Replace manual ArrayIsArray checks that throw ERR_INVALID_ARG_TYPE with the shared validateArray helper. The error code, argument name and expected type are unchanged, so the thrown error stays identical. Signed-off-by: greenhead PR-URL: https://github.com/nodejs/node/pull/64959 Reviewed-By: Aviv Keller Reviewed-By: Filip Skokan Reviewed-By: Mattias Buelens --- lib/internal/streams/iter/broadcast.js | 9 +++------ lib/internal/streams/iter/classic.js | 6 ++---- lib/internal/streams/iter/push.js | 11 +++-------- lib/internal/tls/secure-context.js | 6 ++---- lib/tls.js | 6 ++---- 5 files changed, 12 insertions(+), 26 deletions(-) diff --git a/lib/internal/streams/iter/broadcast.js b/lib/internal/streams/iter/broadcast.js index 5eadbb0da49f..4799554b4f50 100644 --- a/lib/internal/streams/iter/broadcast.js +++ b/lib/internal/streams/iter/broadcast.js @@ -33,6 +33,7 @@ const { } = require('internal/errors'); const { validateAbortSignal, + validateArray, validateInteger, validateObject, } = require('internal/validators'); @@ -559,9 +560,7 @@ class BroadcastWriter { } writev(chunks, options) { - if (!ArrayIsArray(chunks)) { - throw new ERR_INVALID_ARG_TYPE('chunks', 'Array', chunks); - } + validateArray(chunks, 'chunks'); const signal = getWriterSignal(options); // Fast path: no signal, writer open, buffer has space if (this.#canUseWriteFastPath(signal)) { @@ -620,9 +619,7 @@ class BroadcastWriter { } writevSync(chunks) { - if (!ArrayIsArray(chunks)) { - throw new ERR_INVALID_ARG_TYPE('chunks', 'Array', chunks); - } + validateArray(chunks, 'chunks'); if (this.#isClosedOrAborted()) return false; if (!this.#broadcast[kCanWrite]()) return false; const converted = convertChunks(chunks); diff --git a/lib/internal/streams/iter/classic.js b/lib/internal/streams/iter/classic.js index 6796fa4cefc3..0769348a3981 100644 --- a/lib/internal/streams/iter/classic.js +++ b/lib/internal/streams/iter/classic.js @@ -12,7 +12,6 @@ // toWritable(writer) -- stream/iter Writer -> classic Writable const { - ArrayIsArray, ArrayPrototypePush, NumberMAX_SAFE_INTEGER, Promise, @@ -41,6 +40,7 @@ const { } = require('internal/errors'); const { + validateArray, validateInteger, validateObject, } = require('internal/validators'); @@ -619,9 +619,7 @@ function fromWritable(writable, options = kNullPrototype) { }, writev(chunks, options) { - if (!ArrayIsArray(chunks)) { - throw new ERR_INVALID_ARG_TYPE('chunks', 'Array', chunks); - } + validateArray(chunks, 'chunks'); getWriterSignal(options); if (!isWritable()) { return PromiseReject(new ERR_STREAM_WRITE_AFTER_END()); diff --git a/lib/internal/streams/iter/push.js b/lib/internal/streams/iter/push.js index 1b0b5b35dab2..cc7bfc900cec 100644 --- a/lib/internal/streams/iter/push.js +++ b/lib/internal/streams/iter/push.js @@ -6,7 +6,6 @@ // with built-in backpressure. const { - ArrayIsArray, ArrayPrototypePush, PromisePrototypeThen, PromiseReject, @@ -21,13 +20,13 @@ const { const { codes: { - ERR_INVALID_ARG_TYPE, ERR_INVALID_STATE, }, } = require('internal/errors'); const { lazyDOMException } = require('internal/util'); const { validateAbortSignal, + validateArray, validateInteger, } = require('internal/validators'); @@ -629,9 +628,7 @@ class PushWriter { } writev(chunks, options) { - if (!ArrayIsArray(chunks)) { - throw new ERR_INVALID_ARG_TYPE('chunks', 'Array', chunks); - } + validateArray(chunks, 'chunks'); const signal = getWriterSignal(options); if (!signal && this.#queue.canWriteSync()) { const bytes = convertChunks(chunks); @@ -648,9 +645,7 @@ class PushWriter { } writevSync(chunks) { - if (!ArrayIsArray(chunks)) { - throw new ERR_INVALID_ARG_TYPE('chunks', 'Array', chunks); - } + validateArray(chunks, 'chunks'); const bytes = convertChunks(chunks); return this.#queue.writeSync(bytes); } diff --git a/lib/internal/tls/secure-context.js b/lib/internal/tls/secure-context.js index 41c3bb57acd8..862d7501a1d9 100644 --- a/lib/internal/tls/secure-context.js +++ b/lib/internal/tls/secure-context.js @@ -26,6 +26,7 @@ const { } = require('internal/util/types'); const { + validateArray, validateBuffer, validateInt32, validateObject, @@ -212,10 +213,7 @@ function configSecureContext(context, options = kEmptyObject, name = 'options') } if (certificateCompression != null) { - if (!ArrayIsArray(certificateCompression)) { - throw new ERR_INVALID_ARG_TYPE( - `${name}.certificateCompression`, 'Array', certificateCompression); - } + validateArray(certificateCompression, `${name}.certificateCompression`); if (certificateCompression.length > 0) { // Pack length + algorithm IDs into a single Uint32 for a cheap diff --git a/lib/tls.js b/lib/tls.js index 296f6189da17..d2ecc7f0a583 100644 --- a/lib/tls.js +++ b/lib/tls.js @@ -70,7 +70,7 @@ const { canonicalizeIP } = internalBinding('cares_wrap'); const tlsCommon = require('internal/tls/common'); const tlsWrap = require('internal/tls/wrap'); const { domainToASCII } = require('internal/url'); -const { validateString } = require('internal/validators'); +const { validateArray, validateString } = require('internal/validators'); const { namespace: { @@ -206,9 +206,7 @@ function getCACertificates(type = 'default') { exports.getCACertificates = getCACertificates; function setDefaultCACertificates(certs) { - if (!ArrayIsArray(certs)) { - throw new ERR_INVALID_ARG_TYPE('certs', 'Array', certs); - } + validateArray(certs, 'certs'); // Verify that all elements in the array are strings for (let i = 0; i < certs.length; i++) { From 001c37c0a24cc6ac5f4f9c53bb0fa74b8270bf5e Mon Sep 17 00:00:00 2001 From: Archkon <180910180+Archkon@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:37:53 +0800 Subject: [PATCH 081/344] url: bounds-check short Windows file URL paths Check the decoded pathname length before reading the drive letter and colon. This prevents an out-of-bounds read for short URLs such as file:/// and reports ERR_INVALID_FILE_URL_PATH instead. Signed-off-by: Archkon <180910180+Archkon@users.noreply.github.com> PR-URL: https://github.com/nodejs/node/pull/64788 Reviewed-By: Stefan Stojanovic Reviewed-By: Aviv Keller --- src/node_url.cc | 6 ++++++ test/cctest/test_path.cc | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/node_url.cc b/src/node_url.cc index 9553942496f8..38aa7fc48eb1 100644 --- a/src/node_url.cc +++ b/src/node_url.cc @@ -665,6 +665,12 @@ std::optional FileURLToPath(Environment* env, return "\\\\" + ada::idna::to_unicode(hostname) + decoded_pathname; } + if (decoded_pathname.size() < 3) { + THROW_ERR_INVALID_FILE_URL_PATH(env->isolate(), + "File URL path must be absolute"); + return std::nullopt; + } + char letter = decoded_pathname[1] | 0x20; char sep = decoded_pathname[2]; diff --git a/test/cctest/test_path.cc b/test/cctest/test_path.cc index 9e860d02cf77..1fd991340452 100644 --- a/test/cctest/test_path.cc +++ b/test/cctest/test_path.cc @@ -8,6 +8,7 @@ #include "v8.h" using node::BufferValue; +using node::NormalizeFileURLOrPath; using node::PathResolve; using node::ToNamespacedPath; @@ -93,3 +94,26 @@ TEST_F(PathTest, ToNamespacedPath) { EXPECT_EQ(data.ToStringView(), "hello world"); // Input should not be mutated #endif } + +#ifdef _WIN32 +TEST_F(PathTest, NormalizeShortFileURLPath) { + const v8::HandleScope handle_scope(isolate_); + Argv argv; + Env env{handle_scope, argv, node::EnvironmentFlags::kNoBrowserGlobals}; + v8::TryCatch try_catch(isolate_); + + EXPECT_EQ(NormalizeFileURLOrPath(*env, "file:///"), ""); + ASSERT_TRUE(try_catch.HasCaught()); + + v8::Local exception = try_catch.Exception(); + ASSERT_TRUE(exception->IsObject()); + v8::Local code; + ASSERT_TRUE(exception.As() + ->Get((*env)->context(), + v8::String::NewFromUtf8Literal(isolate_, "code")) + .ToLocal(&code)); + ASSERT_TRUE(code->IsString()); + node::Utf8Value code_value(isolate_, code); + EXPECT_EQ(code_value.ToStringView(), "ERR_INVALID_FILE_URL_PATH"); +} +#endif From 1d9dd252f19da35454af2fcf96201e169f5d7878 Mon Sep 17 00:00:00 2001 From: Archkon <180910180+Archkon@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:02:41 +0800 Subject: [PATCH 082/344] sea: reject malformed --node-options values Propagate tokenization errors from ParseNodeOptionsEnvVar through FixupArgsForSEA and abort startup with an invalid command-line status instead of applying partially parsed options. Signed-off-by: Archkon <180910180+Archkon@users.noreply.github.com> PR-URL: https://github.com/nodejs/node/pull/64803 Reviewed-By: James M Snell --- src/node.cc | 9 +++++- src/node_sea.cc | 10 +++++-- src/node_sea.h | 4 ++- ...ble-application-exec-argv-extension-cli.js | 30 +++++++++++++++---- 4 files changed, 42 insertions(+), 11 deletions(-) diff --git a/src/node.cc b/src/node.cc index b5455fe40356..00282634419d 100644 --- a/src/node.cc +++ b/src/node.cc @@ -1631,7 +1631,14 @@ static ExitCode StartInternal(int argc, char** argv) { int Start(int argc, char** argv) { #ifndef DISABLE_SINGLE_EXECUTABLE_APPLICATION - std::tie(argc, argv) = sea::FixupArgsForSEA(argc, argv); + std::vector errors; + std::tie(argc, argv) = sea::FixupArgsForSEA(argc, argv, &errors); + if (!errors.empty()) { + for (const std::string& error : errors) { + FPrintF(stderr, "%s: %s\n", argv[0], error); + } + return static_cast(ExitCode::kInvalidCommandLineArgument); + } #endif return static_cast(StartInternal(argc, argv)); } diff --git a/src/node_sea.cc b/src/node_sea.cc index 1be41e6f1414..9b8454039129 100644 --- a/src/node_sea.cc +++ b/src/node_sea.cc @@ -283,7 +283,9 @@ void IsExperimentalSeaWarningNeeded(const FunctionCallbackInfo& args) { sea_resource.flags & SeaFlags::kDisableExperimentalSeaWarning)); } -std::tuple FixupArgsForSEA(int argc, char** argv) { +std::tuple FixupArgsForSEA(int argc, + char** argv, + std::vector* errors) { // Repeats argv[0] at position 1 on argv as a replacement for the missing // entry point file path. if (IsSingleExecutable()) { @@ -303,8 +305,10 @@ std::tuple FixupArgsForSEA(int argc, char** argv) { for (int i = 1; i < argc; ++i) { if (strncmp(argv[i], "--node-options=", 15) == 0) { std::string node_options = argv[i] + 15; - std::vector errors; - cli_extension_args = ParseNodeOptionsEnvVar(node_options, &errors); + cli_extension_args = ParseNodeOptionsEnvVar(node_options, errors); + if (!errors->empty()) { + return {argc, argv}; + } // Remove this argument by shifting the rest for (int j = i; j < argc - 1; ++j) { argv[j] = argv[j + 1]; diff --git a/src/node_sea.h b/src/node_sea.h index dd0b89db841e..b147caefa5e5 100644 --- a/src/node_sea.h +++ b/src/node_sea.h @@ -70,7 +70,9 @@ struct SeaResource { bool IsSingleExecutable(); std::string_view FindSingleExecutableBlob(); SeaResource FindSingleExecutableResource(); -std::tuple FixupArgsForSEA(int argc, char** argv); +std::tuple FixupArgsForSEA(int argc, + char** argv, + std::vector* errors); node::ExitCode WriteSingleExecutableBlob( const std::string& config_path, const std::vector& args, diff --git a/test/sea/test-single-executable-application-exec-argv-extension-cli.js b/test/sea/test-single-executable-application-exec-argv-extension-cli.js index 3999cf0cbaec..82f539063341 100644 --- a/test/sea/test-single-executable-application-exec-argv-extension-cli.js +++ b/test/sea/test-single-executable-application-exec-argv-extension-cli.js @@ -20,18 +20,36 @@ tmpdir.refresh(); const outputFile = buildSEA(fixtures.path('sea', 'exec-argv-extension-cli')); +const env = { + ...process.env, + NODE_OPTIONS: '--max-old-space-size=2048', // Should be ignored + COMMON_DIRECTORY: join(__dirname, '..', 'common'), + NODE_DEBUG_NATIVE: 'SEA', +}; + // Test that --node-options works with execArgvExtension: "cli" spawnSyncAndAssert( outputFile, ['--node-options=--max-old-space-size=1024', 'user-arg1', 'user-arg2'], { - env: { - ...process.env, - NODE_OPTIONS: '--max-old-space-size=2048', // Should be ignored - COMMON_DIRECTORY: join(__dirname, '..', 'common'), - NODE_DEBUG_NATIVE: 'SEA', - }, + env, }, { stdout: /execArgvExtension cli test passed/, }); + +// Test that malformed --node-options values are rejected. +[ + ['--no-warnings "', /unterminated string/], + ['"--no-warnings\\', /invalid escape/], +].forEach(([nodeOptions, stderr]) => { + spawnSyncAndAssert( + outputFile, + [`--node-options=${nodeOptions}`], + { env }, + { + status: 9, + stdout: '', + stderr, + }); +}); From ad7c67026b14907425e494239089fa00abfe80b5 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Fri, 7 Aug 2026 20:14:51 +0200 Subject: [PATCH 083/344] lib: harden webidl dictionary member reads Member descriptors are plain object literals that spell out only the members they need, so createDictionaryConverter() reading the optional validator, defaultValue and required members off them resolves through %Object.prototype%. Copy each descriptor once at construction time with every key present. They keep an ordinary prototype because a null-prototype object literal lands in V8 dictionary mode, and dictionaries with no defaults and no required members now skip steps 4.1.5 and 4.1.6. Signed-off-by: Filip Skokan PR-URL: https://github.com/nodejs/node/pull/65115 Reviewed-By: Rafael Gonzaga Reviewed-By: Aviv Keller --- lib/internal/webidl.js | 56 +++++++++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/lib/internal/webidl.js b/lib/internal/webidl.js index 13575d4f730d..7f61bae8c21f 100644 --- a/lib/internal/webidl.js +++ b/lib/internal/webidl.js @@ -20,6 +20,7 @@ const { NumberIsNaN, NumberMAX_SAFE_INTEGER, NumberMIN_SAFE_INTEGER, + ObjectPrototypeHasOwnProperty, ObjectPrototypeIsPrototypeOf, SafeArrayIterator, SafeSet, @@ -699,16 +700,47 @@ function createDictionaryConverter( const dictionaries = ArrayIsArray(members[0]) ? members : [members]; const sortedDictionaries = []; + function ownMember(member, key) { + return ObjectPrototypeHasOwnProperty(member, key) ? member[key] : undefined; + } + + // Dictionaries with no defaults and no required members skip steps + // 4.1.5/4.1.6 entirely, keeping the absent-member path free. + let anyMissingMemberHandling = false; + // Web IDL dictionary conversion steps 3-4 process inherited dictionaries // from least-derived to most-derived and sort only within each dictionary. // Callers with inheritance pass one member array per dictionary level. for (let i = 0; i < dictionaries.length; i++) { - ArrayPrototypePush( - sortedDictionaries, - ArrayPrototypeToSorted(dictionaries[i], compareMembers), + const sortedMembers = ArrayPrototypeToSorted( + dictionaries[i], + compareMembers, ); + // Definition sites spell out only the members they need, so reading the + // optional ones below would resolve through %Object.prototype%. + // Re-materialize each descriptor once with every key present, copied from + // own properties only. The ordinary prototype is deliberate: nothing + // consults it now, and detaching it measurably slows these reads down. + for (let j = 0; j < sortedMembers.length; j++) { + const member = sortedMembers[j]; + const defaultValue = ownMember(member, 'defaultValue'); + const required = ownMember(member, 'required'); + if (typeof defaultValue === 'function' || required) { + anyMissingMemberHandling = true; + } + sortedMembers[j] = { + key: ownMember(member, 'key'), + converter: ownMember(member, 'converter'), + defaultValue, + required, + validator: ownMember(member, 'validator'), + }; + } + ArrayPrototypePush(sortedDictionaries, sortedMembers); } + const hasMissingMemberHandling = anyMissingMemberHandling; + return function(jsDict, options = kEmptyObject) { // Step 1: reject non-object, non-null, non-undefined values. if (jsDict != null && type(jsDict) !== 'Object') { @@ -747,14 +779,16 @@ function createDictionaryConverter( member.validator?.(idlMemberValue, jsDict); // Step 4.1.4.2: set idlDict[key] to the IDL value. idlDict[key] = idlMemberValue; - } else if (typeof member.defaultValue === 'function') { - // Step 4.1.5: store the member default value. - idlDict[key] = member.defaultValue(); - } else if (member.required) { - // Step 4.1.6: required missing members throw. - throw makeException( - missingDictionaryMemberMessage(dictionaryName, key), - makeOptions(options, options.context, 'ERR_MISSING_OPTION')); + } else if (hasMissingMemberHandling) { + if (typeof member.defaultValue === 'function') { + // Step 4.1.5: store the member default value. + idlDict[key] = member.defaultValue(); + } else if (member.required) { + // Step 4.1.6: required missing members throw. + throw makeException( + missingDictionaryMemberMessage(dictionaryName, key), + makeOptions(options, options.context, 'ERR_MISSING_OPTION')); + } } } } From d8a0f288ab3aa45f4d527d21eba785da867d34cc Mon Sep 17 00:00:00 2001 From: Archkon <180910180+Archkon@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:10:51 +0800 Subject: [PATCH 084/344] src: match cmd.exe case-insensitively in task runner Use a case-insensitive suffix comparison for ComSpec so uppercase and mixed-case CMD.EXE paths use the correct /c invocation. Signed-off-by: Archkon <180910180+Archkon@users.noreply.github.com> PR-URL: https://github.com/nodejs/node/pull/64907 Reviewed-By: Aviv Keller --- src/node_task_runner.cc | 6 +++++- test/parallel/test-node-run.js | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/node_task_runner.cc b/src/node_task_runner.cc index 2b3e005abf34..9263a502a45f 100644 --- a/src/node_task_runner.cc +++ b/src/node_task_runner.cc @@ -60,7 +60,11 @@ ProcessRunner::ProcessRunner(std::shared_ptr result, } #ifdef _WIN32 - if (file_.ends_with("cmd.exe")) { + static constexpr std::string_view cmd_exe = "cmd.exe"; + if (file_.size() >= cmd_exe.size() && + StringEqualNoCaseN(file_.data() + file_.size() - cmd_exe.size(), + cmd_exe.data(), + cmd_exe.size())) { // If the file is cmd.exe, use the following command line arguments: // "/c" Carries out the command and exit. // "/d" Disables execution of AutoRun commands. diff --git a/test/parallel/test-node-run.js b/test/parallel/test-node-run.js index 7c1f6609f6f1..ece9e48878b3 100644 --- a/test/parallel/test-node-run.js +++ b/test/parallel/test-node-run.js @@ -37,6 +37,28 @@ describe('node --run [command]', () => { assert.strictEqual(child.code, 1); }); + it('recognizes cmd.exe case-insensitively', { + skip: !common.isWindows, + }, async () => { + const env = { ...process.env }; + const comspecKey = Object.keys(env) + .find((key) => key.toLowerCase() === 'comspec'); + assert.notStrictEqual(comspecKey, undefined); + const comspec = env[comspecKey]; + assert.match(comspec, /cmd\.exe$/i); + delete env[comspecKey]; + env.ComSpec = comspec.replace(/cmd\.exe$/i, 'CMD.EXE'); + + const child = await common.spawnPromisified( + process.execPath, + [ '--run', 'pwd-windows'], + { cwd: fixtures.path('run-script'), env }, + ); + assert.strictEqual(child.stdout.trim(), fixtures.path('run-script')); + assert.strictEqual(child.stderr, ''); + assert.strictEqual(child.code, 0); + }); + it('adds node_modules/.bin to path', async () => { const child = await common.spawnPromisified( process.execPath, From 64a8843ff25122f5932daed0b4f218144f04eba1 Mon Sep 17 00:00:00 2001 From: Archkon <180910180+Archkon@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:11:12 +0800 Subject: [PATCH 085/344] sea: reject trailing content in config JSON Ensure the on-demand parser reaches the end of the document after reading the root object. Reject concatenated JSON values with TRAILING_CONTENT Signed-off-by: Archkon <180910180+Archkon@users.noreply.github.com> PR-URL: https://github.com/nodejs/node/pull/64774 Reviewed-By: Aviv Keller --- src/node_sea.cc | 8 ++++++++ ...est-single-executable-blob-config-errors.js | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/node_sea.cc b/src/node_sea.cc index 9b8454039129..1518b32ebc4a 100644 --- a/src/node_sea.cc +++ b/src/node_sea.cc @@ -541,6 +541,14 @@ std::optional ParseSingleExecutableConfig( } } + if (!document.at_end()) { + FPrintF(stderr, + "Cannot parse JSON from %s: %s\n", + config_path, + simdjson::error_message(simdjson::TRAILING_CONTENT)); + return std::nullopt; + } + if (static_cast(result.flags & SeaFlags::kUseSnapshot) && static_cast(result.flags & SeaFlags::kUseCodeCache)) { // TODO(joyeecheung): code cache in snapshot should be configured by diff --git a/test/sea/test-single-executable-blob-config-errors.js b/test/sea/test-single-executable-blob-config-errors.js index 322f430aad81..9562faad6b79 100644 --- a/test/sea/test-single-executable-blob-config-errors.js +++ b/test/sea/test-single-executable-blob-config-errors.js @@ -47,6 +47,24 @@ const { spawnSyncAndAssert } = require('../common/child_process'); }); } +{ + tmpdir.refresh(); + const config = tmpdir.resolve('trailing-content.json'); + writeFileSync( + config, + '{"main":"bundle.js","output":"sea.blob"}{}', + 'utf8', + ); + spawnSyncAndAssert( + process.execPath, + ['--experimental-sea-config', config], { + cwd: tmpdir.path, + }, { + status: 1, + stderr: /TRAILING_CONTENT/, + }); +} + { tmpdir.refresh(); const config = tmpdir.resolve('empty.json'); From 3126ce57792b8a0df5e60781d2743071520c04d4 Mon Sep 17 00:00:00 2001 From: Archkon <180910180+Archkon@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:11:28 +0800 Subject: [PATCH 086/344] sea: handle NUL bytes in asset keys Construct the asset lookup string_view with the explicit Utf8Value length so embedded NUL bytes do not truncate keys. Signed-off-by: Archkon <180910180+Archkon@users.noreply.github.com> PR-URL: https://github.com/nodejs/node/pull/64773 Reviewed-By: Aviv Keller --- src/node_sea.cc | 2 +- test/fixtures/sea/assets/sea-config.json | 2 ++ test/fixtures/sea/assets/sea.js | 6 ++++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/node_sea.cc b/src/node_sea.cc index 1518b32ebc4a..901f2a95529c 100644 --- a/src/node_sea.cc +++ b/src/node_sea.cc @@ -841,7 +841,7 @@ void GetAsset(const FunctionCallbackInfo& args) { if (sea_resource.assets.empty()) { return; } - auto it = sea_resource.assets.find(*key); + auto it = sea_resource.assets.find(std::string_view(*key, key.length())); if (it == sea_resource.assets.end()) { return; } diff --git a/test/fixtures/sea/assets/sea-config.json b/test/fixtures/sea/assets/sea-config.json index 78a64534b44e..979bda0dca13 100644 --- a/test/fixtures/sea/assets/sea-config.json +++ b/test/fixtures/sea/assets/sea-config.json @@ -2,6 +2,8 @@ "main": "sea.js", "output": "sea-prep.blob", "assets": { + "a": "utf8_test_text.txt", + "a\u0000b": "person.jpg", "utf8_test_text.txt": "utf8_test_text.txt", "person.jpg": "person.jpg" } diff --git a/test/fixtures/sea/assets/sea.js b/test/fixtures/sea/assets/sea.js index e1a2189aa4da..b6b775fdd159 100644 --- a/test/fixtures/sea/assets/sea.js +++ b/test/fixtures/sea/assets/sea.js @@ -54,6 +54,12 @@ assert(isSea()); const textAssetOnDisk = readFileSync(process.env.__TEST_UTF8_TEXT_PATH, 'utf8'); const binaryAssetOnDisk = readFileSync(process.env.__TEST_PERSON_JPG); +// Check asset keys containing NUL. +{ + assert.strictEqual(getAsset('a', 'utf8'), textAssetOnDisk); + assert.deepStrictEqual(Buffer.from(getAsset('a\0b')), binaryAssetOnDisk); +} + // Check getAsset() buffer copies. { // Check that the asset embedded is the same as the original. From f32a8631ad07811411ca2f5c4802e0251c77528b Mon Sep 17 00:00:00 2001 From: Archkon <180910180+Archkon@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:11:39 +0800 Subject: [PATCH 087/344] sea: avoid dangling CLI option pointers Reserve exec argv storage before inserting configured and CLI-expanded arguments so vector reallocation cannot invalidate pointers in argv. Signed-off-by: Archkon <180910180+Archkon@users.noreply.github.com> PR-URL: https://github.com/nodejs/node/pull/64755 Reviewed-By: Yagiz Nizipli Reviewed-By: James M Snell Reviewed-By: Aviv Keller --- src/node_sea.cc | 5 +++-- test/fixtures/sea/exec-argv-extension-cli/sea-config.json | 1 - test/fixtures/sea/exec-argv-extension-cli/sea.js | 2 +- ...single-executable-application-exec-argv-extension-cli.js | 6 +++++- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/node_sea.cc b/src/node_sea.cc index 901f2a95529c..81d47c11ffab 100644 --- a/src/node_sea.cc +++ b/src/node_sea.cc @@ -325,10 +325,11 @@ std::tuple FixupArgsForSEA(int argc, cli_extension_args.size() + 2); new_argv.emplace_back(argv[0]); + exec_argv_storage.reserve(sea_resource.exec_argv.size() + + cli_extension_args.size()); + // Insert exec argv from SEA config if (!sea_resource.exec_argv.empty()) { - exec_argv_storage.reserve(sea_resource.exec_argv.size() + - cli_extension_args.size()); for (const auto& arg : sea_resource.exec_argv) { exec_argv_storage.emplace_back(arg); new_argv.emplace_back(exec_argv_storage.back().data()); diff --git a/test/fixtures/sea/exec-argv-extension-cli/sea-config.json b/test/fixtures/sea/exec-argv-extension-cli/sea-config.json index 0ec0d706b384..cb4606558c7f 100644 --- a/test/fixtures/sea/exec-argv-extension-cli/sea-config.json +++ b/test/fixtures/sea/exec-argv-extension-cli/sea-config.json @@ -2,6 +2,5 @@ "main": "sea.js", "output": "sea-prep.blob", "disableExperimentalSEAWarning": true, - "execArgv": ["--no-warnings"], "execArgvExtension": "cli" } diff --git a/test/fixtures/sea/exec-argv-extension-cli/sea.js b/test/fixtures/sea/exec-argv-extension-cli/sea.js index e9585483fcc2..11bc7fa36560 100644 --- a/test/fixtures/sea/exec-argv-extension-cli/sea.js +++ b/test/fixtures/sea/exec-argv-extension-cli/sea.js @@ -3,7 +3,7 @@ const assert = require('assert'); console.log('process.argv:', JSON.stringify(process.argv)); console.log('process.execArgv:', JSON.stringify(process.execArgv)); -// Should have execArgv from SEA config + CLI --node-options +// Should have all options from CLI --node-options assert.deepStrictEqual(process.execArgv, ['--no-warnings', '--max-old-space-size=1024']); assert.deepStrictEqual(process.argv.slice(2), [ diff --git a/test/sea/test-single-executable-application-exec-argv-extension-cli.js b/test/sea/test-single-executable-application-exec-argv-extension-cli.js index 82f539063341..8055fe642ece 100644 --- a/test/sea/test-single-executable-application-exec-argv-extension-cli.js +++ b/test/sea/test-single-executable-application-exec-argv-extension-cli.js @@ -30,7 +30,11 @@ const env = { // Test that --node-options works with execArgvExtension: "cli" spawnSyncAndAssert( outputFile, - ['--node-options=--max-old-space-size=1024', 'user-arg1', 'user-arg2'], + [ + '--node-options=--no-warnings --max-old-space-size=1024', + 'user-arg1', + 'user-arg2', + ], { env, }, From 73de450845ab044d8054dfc80c14ab52bcdfb096 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Sun, 9 Aug 2026 21:26:21 +0200 Subject: [PATCH 088/344] tools: use the read-only token when filtering PRs in CQ Signed-off-by: Antoine du Hamel PR-URL: https://github.com/nodejs/node/pull/65169 Reviewed-By: Filip Skokan Reviewed-By: Aviv Keller Reviewed-By: Moshe Atlow --- .github/workflows/commit-queue.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/commit-queue.yml b/.github/workflows/commit-queue.yml index 16cb6fef3e3a..641fe2a71742 100644 --- a/.github/workflows/commit-queue.yml +++ b/.github/workflows/commit-queue.yml @@ -58,6 +58,8 @@ jobs: commitQueue: needs: get_candidate_prs if: needs.get_candidate_prs.outputs.candidates != '' + permissions: + pull-requests: read runs-on: ubuntu-slim steps: # Install dependencies @@ -78,13 +80,13 @@ jobs: ncu-config --global set branch "${GITHUB_REF_NAME}" ncu-config --global set upstream origin ncu-config --global set username "$USERNAME" - ncu-config --global set token "$GITHUB_TOKEN" + ncu-config --global set token "$GH_TOKEN" ncu-config --global set jenkins_token "$JENKINS_TOKEN" ncu-config --global set repo "${REPOSITORY}" ncu-config --global set owner "${GITHUB_REPOSITORY_OWNER}" env: USERNAME: ${{ secrets.JENKINS_USER }} - GITHUB_TOKEN: ${{ secrets.GH_USER_TOKEN }} + GH_TOKEN: ${{ github.token }} JENKINS_TOKEN: ${{ secrets.JENKINS_TOKEN }} - name: Filter Pull Requests @@ -154,7 +156,7 @@ jobs: echo "numbers=$numbers" >> "$GITHUB_OUTPUT" env: CANDIDATES: ${{ needs.get_candidate_prs.outputs.candidates }} - GITHUB_TOKEN: ${{ secrets.GH_USER_TOKEN }} + GH_TOKEN: ${{ github.token }} - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 if: steps.get_mergeable_prs.outputs.numbers != '' @@ -167,6 +169,8 @@ jobs: - name: Start the Commit Queue if: steps.get_mergeable_prs.outputs.numbers != '' - run: ./tools/actions/commit-queue.sh "${GITHUB_REPOSITORY_OWNER}" "${REPOSITORY}" ${{ steps.get_mergeable_prs.outputs.numbers }} + run: | + ncu-config set token "$GH_TOKEN" + ./tools/actions/commit-queue.sh "${GITHUB_REPOSITORY_OWNER}" "${REPOSITORY}" ${{ steps.get_mergeable_prs.outputs.numbers }} env: - GITHUB_TOKEN: ${{ secrets.GH_USER_TOKEN }} + GH_TOKEN: ${{ secrets.GH_USER_TOKEN }} From 8765afd4c2769f7ea0e1a06cdad0828ce65d7aa0 Mon Sep 17 00:00:00 2001 From: Archkon <180910180+Archkon@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:32:48 +0800 Subject: [PATCH 089/344] process: validate resource stats array offsets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assert that the internally allocated Float64Array views used by CPU, thread CPU, memory, and resource statistics have zero byte offsets. Signed-off-by: Archkon <180910180+Archkon@users.noreply.github.com> PR-URL: https://github.com/nodejs/node/pull/65098 Reviewed-By: René Reviewed-By: Aviv Keller --- src/node_process_methods.cc | 1 + 1 file changed, 1 insertion(+) diff --git a/src/node_process_methods.cc b/src/node_process_methods.cc index 1c695be2d5fa..8f800be35431 100644 --- a/src/node_process_methods.cc +++ b/src/node_process_methods.cc @@ -107,6 +107,7 @@ inline Local get_fields_array_buffer( CHECK(args[index]->IsFloat64Array()); Local arr = args[index].As(); CHECK_EQ(arr->Length(), array_length); + CHECK_EQ(arr->ByteOffset(), 0); return arr->Buffer(); } From 8b0bdd931ce2321a8bf3c269889a34d4aa5b0a23 Mon Sep 17 00:00:00 2001 From: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:34:44 +0000 Subject: [PATCH 090/344] ffi: shrink trampoline placement probe window AllocateCodeNear() searched 1024 pages in each direction for a free page next to the native target, one MAP_FIXED_NOREPLACE mmap per candidate. Every probe is expected to fail, so an exhausted window cost up to 2048 failing syscalls before taking the plain-mmap fallback that was already there, all to enable a jmp rel32 instead of movabs+jmp. Resolving a fast-eligible signature plateaued at ~790us, against ~28us for one that allocates no trampoline. Reduce the window to 16 pages per direction, bounding the exhausted case to 32 probes while still covering typical per-library symbol counts. Placement stays opportunistic: the caller already emits the absolute form when EmitJmpRel32() reports the target is out of range. Signed-off-by: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Assisted-by: claude:opus-5 PR-URL: https://github.com/nodejs/node/pull/64969 Fixes: https://github.com/nodejs/node/issues/64968 Reviewed-By: Paolo Insogna --- benchmark/ffi/get-function.js | 48 +++++++++++++++++++++++++++++++++++ src/ffi/platforms/x64.cc | 11 +++++++- 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 benchmark/ffi/get-function.js diff --git a/benchmark/ffi/get-function.js b/benchmark/ffi/get-function.js new file mode 100644 index 000000000000..3c1e2e974ce6 --- /dev/null +++ b/benchmark/ffi/get-function.js @@ -0,0 +1,48 @@ +'use strict'; + +// Measures symbol resolution rather than call throughput. Creating a callable +// for a fast-eligible signature emits a native trampoline, so this benchmark +// covers the trampoline allocation path that the call benchmarks never reach. +// +// The `fast` variant is eligible for a generated trampoline; `slow` exceeds the +// x86_64 register budget and falls back, so it resolves without allocating one. +// Comparing the two isolates trampoline creation cost from the rest of symbol +// resolution. + +const common = require('../common.js'); +const { DynamicLibrary } = require('node:ffi'); +const { libraryPath, ensureFixtureLibrary } = require('./common.js'); + +const bench = common.createBenchmark(main, { + signature: ['fast', 'slow'], + n: [1e3], +}, { + flags: ['--experimental-ffi'], +}); + +ensureFixtureLibrary(); + +const signatures = { + fast: { name: 'add_i32', return: 'i32', arguments: ['i32', 'i32'] }, + slow: { + name: 'sum_8_i32', + return: 'i32', + arguments: ['i32', 'i32', 'i32', 'i32', 'i32', 'i32', 'i32', 'i32'], + }, +}; + +function main({ n, signature }) { + const { name, ...definition } = signatures[signature]; + const lib = new DynamicLibrary(libraryPath); + + // Warm up one-time initialization (libffi setup, executable memory probe) so + // it is not attributed to the measured resolutions. + lib.getFunction(name, definition); + + bench.start(); + for (let i = 0; i < n; ++i) + lib.getFunction(name, definition); + bench.end(n); + + lib.close(); +} diff --git a/src/ffi/platforms/x64.cc b/src/ffi/platforms/x64.cc index e105eabc13db..d5c15c381574 100644 --- a/src/ffi/platforms/x64.cc +++ b/src/ffi/platforms/x64.cc @@ -294,7 +294,16 @@ void* AllocateCodeNear(uintptr_t target_address, size_t code_size) { const uintptr_t base = target_address & ~(page_size - 1); // Search a small window around the target first. Shared libraries usually // leave nearby holes, and keeping the trampoline close enables jmp rel32. - constexpr uintptr_t kMaxPages = 1024; + // + // The window doubles as the capacity of the near-text region: every + // trampoline keeps one page in it, so about 2 * kMaxPages trampolines per + // library can use jmp rel32 before the window is full and later ones take + // the far placement below. Each candidate costs an mmap syscall that is + // expected to fail, and a layout with no hole within a few pages of the + // text rarely has one further out either, so a wide window mostly buys + // failed probes. Keep it small enough that an exhausted window costs a few + // microseconds while still covering typical per-library symbol counts. + constexpr uintptr_t kMaxPages = 16; for (uintptr_t i = 1; i <= kMaxPages; i++) { const uintptr_t delta = i * page_size; const uintptr_t candidates[] = { From 3e6688c5e34dc377d90e701925e061a9ef906f4e Mon Sep 17 00:00:00 2001 From: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:08:03 +0000 Subject: [PATCH 091/344] ffi: reuse the callable created per symbol CreateFunction() ran on every getFunction() call, every getFunctions() call, and every read of the functions accessor, each time emitting a trampoline, allocating an FFIFunctionInfo, and on the SharedBuffer path an ArrayBuffer. lib.functions.foo was therefore a different function on each read, and calling through the accessor in a loop leaked a page per iteration until GC: 20000 calls grew RSS by 58 MiB. Cache the created callable per symbol in function_wrappers_, and memoize the JS wrapper composed around it. Both entries are weak, so dropping the last user reference still releases the wrapper and its trampoline. The JS side stores a WeakRef because V8 can keep a raw function alive after the wrapper is gone, and a strong value would pin every wrapper for the lifetime of the library. Signed-off-by: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Assisted-by: claude:opus-5 PR-URL: https://github.com/nodejs/node/pull/64971 Fixes: https://github.com/nodejs/node/issues/64970 Reviewed-By: Paolo Insogna --- doc/api/ffi.md | 4 ++- lib/ffi.js | 37 +++++++++++++++++-------- src/node_ffi.cc | 28 +++++++++++++++++++ src/node_ffi.h | 6 +++++ test/ffi/test-ffi-dynamic-library.js | 40 ++++++++++++++++++++++++++++ 5 files changed, 103 insertions(+), 12 deletions(-) diff --git a/doc/api/ffi.md b/doc/api/ffi.md index 09b78d64fc68..158aadffa3e3 100644 --- a/doc/api/ffi.md +++ b/doc/api/ffi.md @@ -363,7 +363,8 @@ The returned function has a `.pointer` property containing the native function address as a `bigint`. If the same symbol has already been resolved, requesting it again with a -different signature throws. +different signature throws. Requesting it again with the same signature returns +the same function, as does reading it from [`library.functions`][]. ```cjs const { DynamicLibrary, suffix } = require('node:ffi'); @@ -766,5 +767,6 @@ and keep callback and pointer lifetimes explicit on the native side. [Permission Model]: permissions.md#permission-model [`--allow-ffi`]: cli.md#--allow-ffi [`ffi.toBuffer(pointer, length, copy)`]: #ffitobufferpointer-length-copy +[`library.functions`]: #libraryfunctions [`using`]: https://tc39.es/proposal-explicit-resource-management/#sec-using-declarations [type names]: #type-names diff --git a/lib/ffi.js b/lib/ffi.js index cde6cca7a86e..edb12ed4ed45 100644 --- a/lib/ffi.js +++ b/lib/ffi.js @@ -7,6 +7,8 @@ const { ObjectGetOwnPropertyDescriptor, ObjectKeys, ObjectPrototypeToString, + SafeWeakMap, + SafeWeakRef, SymbolDispose, } = primordials; const { Buffer } = require('buffer'); @@ -80,23 +82,36 @@ function makeSignature(argumentTypes, returnType) { }; } +// The native layer hands out one raw function per resolved symbol, so the +// wrapper composed around it is reused too, otherwise every read of +// `library.functions` would return callables that are not identical to the +// previous read's. The entry holds a WeakRef because V8 can keep a raw function +// alive after user code drops the wrapper, and a strong value would then pin +// every wrapper for the lifetime of the library. +const wrappedFunctions = new SafeWeakMap(); + function wrapFFIFunction(rawFn, owner) { - let argumentTypes; + if (rawFn === undefined || rawFn === null) { + return rawFn; + } + const cached = wrappedFunctions.get(rawFn)?.deref(); + if (cached !== undefined) { + return cached; + } let returnType; - if (rawFn !== undefined && rawFn !== null) { - const sbArguments = rawFn[kSbArguments]; - argumentTypes = sbArguments ?? rawFn[kFastArguments]; - if (sbArguments !== undefined) { - returnType = rawFn[kSbReturn]; - } + const sbArguments = rawFn[kSbArguments]; + const argumentTypes = sbArguments ?? rawFn[kFastArguments]; + if (sbArguments !== undefined) { + returnType = rawFn[kSbReturn]; } - const wrapped = wrapWithSharedBuffer( + let wrapped = wrapWithSharedBuffer( rawFn, argumentTypes === undefined ? undefined : makeSignature(argumentTypes, returnType)); - if (wrapped !== rawFn) { - return wrapped; + if (wrapped === rawFn) { + wrapped = wrapWithRawPointerConversions(rawFn, argumentTypes, owner); } - return wrapWithRawPointerConversions(rawFn, argumentTypes, owner); + wrappedFunctions.set(rawFn, new SafeWeakRef(wrapped)); + return wrapped; } const rawGetFunction = DynamicLibrary.prototype.getFunction; diff --git a/src/node_ffi.cc b/src/node_ffi.cc index 638ad4feafdf..1e1fc5654591 100644 --- a/src/node_ffi.cc +++ b/src/node_ffi.cc @@ -83,6 +83,12 @@ void DynamicLibrary::MemoryInfo(MemoryTracker* tracker) const { tracker->TrackFieldWithSize( "symbols", symbols_size, "std::unordered_map"); + tracker->TrackFieldWithSize( + "function_wrappers", + function_wrappers_.size() * + sizeof(decltype(function_wrappers_)::value_type), + "std::unordered_map>"); + // FFIFunctionInfo instances and their sb_backing ArrayBuffers are // owned by V8 function wrappers and reachable only via weak references, // so they are deliberately not counted here. @@ -108,6 +114,7 @@ void DynamicLibrary::Close() { symbols_.clear(); functions_.clear(); + function_wrappers_.clear(); callbacks_.clear(); } @@ -259,6 +266,19 @@ MaybeLocal DynamicLibrary::CreateFunction( Isolate* isolate = env->isolate(); Local context = env->context(); + // Creating a callable emits a trampoline, allocates an FFIFunctionInfo, and + // on the SharedBuffer path allocates an ArrayBuffer, so reuse the one already + // handed out for this symbol. `PrepareFunction()` rejects a request that uses + // a different signature, so a hit always describes the same signature. An + // empty handle means the wrapper was collected; fall through and rebuild. + auto cached = function_wrappers_.find(name); + if (cached != function_wrappers_.end()) { + if (!cached->second.IsEmpty()) { + return cached->second.Get(isolate); + } + function_wrappers_.erase(cached); + } + auto info = FFIFunctionInfo::Create(env, fn, this); DCHECK_EQ(fn->args.size(), fn->arg_type_names.size()); @@ -454,6 +474,14 @@ MaybeLocal DynamicLibrary::CreateFunction( } } + // A strong handle would root the callable, which holds the library object + // through FFIFunctionInfo, so neither could ever be collected. Weaken the + // stored handle instead, so the cache lasts exactly as long as user code + // keeps a reference. SetWeak() runs after the move into the map because + // moving a handle relocates the underlying slot. + function_wrappers_.emplace(name, Global(isolate, ret)) + .first->second.SetWeak(); + return ret; } diff --git a/src/node_ffi.h b/src/node_ffi.h index 07bd0163db75..7758380138fd 100644 --- a/src/node_ffi.h +++ b/src/node_ffi.h @@ -169,6 +169,12 @@ class DynamicLibrary : public BaseObject { std::string path_; std::unordered_map symbols_; std::unordered_map> functions_; + // Callables created for `functions_`, so repeated resolution of the same + // symbol reuses one wrapper instead of emitting another trampoline. The + // handles are weak: an entry disappears once user code drops the wrapper, + // which keeps the map from rooting the library through the wrapper's + // FFIFunctionInfo. + std::unordered_map> function_wrappers_; std::unordered_map> callbacks_; }; diff --git a/test/ffi/test-ffi-dynamic-library.js b/test/ffi/test-ffi-dynamic-library.js index 2a6aa4129e95..82400335a12f 100644 --- a/test/ffi/test-ffi-dynamic-library.js +++ b/test/ffi/test-ffi-dynamic-library.js @@ -176,6 +176,46 @@ test('getFunction caches signatures consistently', () => { } }); +test('resolving the same symbol reuses one function', () => { + const lib = new ffi.DynamicLibrary(libraryPath); + const definitions = { add_i32: fixtureSymbols.add_i32 }; + + try { + // Every resolution used to build a new callable, allocating another + // trampoline and making `lib.functions.add_i32` a different function on + // each read. + const fn = lib.getFunction('add_i32', fixtureSymbols.add_i32); + assert.strictEqual(lib.getFunction('add_i32', fixtureSymbols.add_i32), fn); + assert.strictEqual(lib.functions.add_i32, fn); + assert.strictEqual(lib.getFunctions().add_i32, fn); + assert.strictEqual(lib.getFunctions(definitions).add_i32, fn); + assert.strictEqual(fn(20, 22), 42); + } finally { + lib.close(); + } +}); + +test('a dropped function wrapper is collectable', async () => { + const lib = new ffi.DynamicLibrary(libraryPath); + + try { + // Caching the wrapper must not pin it, so that dropping the last user + // reference still releases the wrapper and the trampoline it owns. + let fn = lib.getFunction('add_i32', fixtureSymbols.add_i32); + const ref = new WeakRef(fn); + fn = null; + + await gcUntil('a dropped function wrapper is collectable', () => { + return ref.deref() === undefined; + }); + + fn = lib.getFunction('add_i32', fixtureSymbols.add_i32); + assert.strictEqual(fn(20, 22), 42); + } finally { + lib.close(); + } +}); + test('FFI functions keep their owning library alive', async () => { let lib = new ffi.DynamicLibrary(libraryPath); const addI32 = lib.getFunction('add_i32', fixtureSymbols.add_i32); From c89878c3c8317d0a9a62a7ae29b968e2440ac8c2 Mon Sep 17 00:00:00 2001 From: "Kamat, Trivikram" <16024985+trivikr@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:45:54 -0700 Subject: [PATCH 092/344] sqlite: reject deserialize() while in a callback deserialize() could be called from a user-defined function invoked during statement execution, tearing down the database connection while sqlite3_step() was still using it. Reuse the existing callback depth check to throw ERR_INVALID_STATE instead, matching the guard already in place for close(). Signed-off-by: Kamat, Trivikram <16024985+trivikr@users.noreply.github.com> Assisted-by: codex:gpt-5.6-sol PR-URL: https://github.com/nodejs/node/pull/64796 Refs: https://github.com/nodejs/node/issues/64795 Reviewed-By: Stephen Belanger --- doc/api/sqlite.md | 7 +++++-- src/node_sqlite.cc | 4 ++++ test/parallel/test-sqlite-serialize.js | 17 +++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/doc/api/sqlite.md b/doc/api/sqlite.md index e1ce547bb6d9..449e88bfb3a5 100644 --- a/doc/api/sqlite.md +++ b/doc/api/sqlite.md @@ -621,8 +621,10 @@ added: v26.1.0 Loads a serialized database into this connection, replacing the current database. The deserialized database is writable. Existing prepared statements are finalized before deserialization is attempted, even if the operation -subsequently fails. This method is a wrapper around -[`sqlite3_deserialize()`][]. +subsequently fails. An [`ERR_INVALID_STATE`][] error is thrown if the method is +called while a database callback is on the stack, for example a user-defined +function, an aggregate function, an authorizer, or a changeset filter or conflict +handler. This method is a wrapper around [`sqlite3_deserialize()`][]. ```mjs import { DatabaseSync } from 'node:sqlite'; @@ -1788,6 +1790,7 @@ callback function to indicate what type of operation is being authorized. [SQL injection]: https://en.wikipedia.org/wiki/SQL_injection [Type conversion between JavaScript and SQLite]: #type-conversion-between-javascript-and-sqlite [`ATTACH DATABASE`]: https://www.sqlite.org/lang_attach.html +[`ERR_INVALID_STATE`]: errors.md#err_invalid_state [`PRAGMA foreign_keys`]: https://www.sqlite.org/pragma.html#pragma_foreign_keys [`SQLITE_DBCONFIG_DEFENSIVE`]: https://www.sqlite.org/c3ref/c_dbconfig_defensive.html#sqlitedbconfigdefensive [`SQLITE_DETERMINISTIC`]: https://www.sqlite.org/c3ref/c_deterministic.html diff --git a/src/node_sqlite.cc b/src/node_sqlite.cc index 027372e42daa..80da5bc0d9bf 100644 --- a/src/node_sqlite.cc +++ b/src/node_sqlite.cc @@ -1858,6 +1858,10 @@ void DatabaseSync::Deserialize(const FunctionCallbackInfo& args) { ASSIGN_OR_RETURN_UNWRAP(&db, args.This()); Environment* env = Environment::GetCurrent(args); THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open"); + THROW_AND_RETURN_ON_BAD_STATE( + env, + db->IsInCallback(), + "database cannot be deserialized while in a callback"); if (!args[0]->IsUint8Array()) { THROW_ERR_INVALID_ARG_TYPE(env->isolate(), diff --git a/test/parallel/test-sqlite-serialize.js b/test/parallel/test-sqlite-serialize.js index 77b9d9c5f483..e54cfa3c6a75 100644 --- a/test/parallel/test-sqlite-serialize.js +++ b/test/parallel/test-sqlite-serialize.js @@ -190,6 +190,23 @@ suite('DatabaseSync.prototype.deserialize()', () => { }); }); + test('throws if called while in a callback', (t) => { + const source = new DatabaseSync(':memory:'); + const serialized = source.serialize(); + source.close(); + + const db = new DatabaseSync(':memory:'); + t.after(() => db.close()); + db.function('deserialize_database', () => db.deserialize(serialized)); + const stmt = db.prepare('SELECT deserialize_database()'); + + t.assert.throws(() => stmt.get(), { + code: 'ERR_INVALID_STATE', + message: 'database cannot be deserialized while in a callback', + }); + t.assert.strictEqual(db.isOpen, true); + }); + test('throws if buffer argument is not a Uint8Array', (t) => { const db = new DatabaseSync(':memory:'); t.assert.throws(() => { From 3f4b80ebb53073de852bc1444361ec552234cc96 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 15 Jul 2026 15:39:34 -0400 Subject: [PATCH 093/344] diagnostics_channel: mark TracingChannel as stable Promotes the diagnostics_channel.tracingChannel() factory and the TracingChannel class from experimental to stable. TracingChannel has been available since v18.19.0 / v19.9.0 with an unchanged surface and now has broad adoption: fastify, pino, mysql2, redis, ioredis, mongoose, graphql, h3, srvx, and the Vercel AI SDK publish TracingChannel events natively, and it is consumed by OpenTelemetry, Sentry, Datadog, and New Relic. The last behavioral wart, tracePromise stripping custom thenables (#59936), was resolved in #61766. This mirrors the scoping in #45290: BoundedChannel, the bindStore / runStores store helpers, and the concrete built-in channels stay experimental. Signed-off-by: Abdelrahman Awad PR-URL: https://github.com/nodejs/node/pull/64525 Reviewed-By: James M Snell Reviewed-By: Stephen Belanger --- doc/api/diagnostics_channel.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/doc/api/diagnostics_channel.md b/doc/api/diagnostics_channel.md index f98d90ab2a19..e3a6a5e3fa98 100644 --- a/doc/api/diagnostics_channel.md +++ b/doc/api/diagnostics_channel.md @@ -237,9 +237,13 @@ diagnostics_channel.unsubscribe('my-channel', onMessage); added: - v19.9.0 - v18.19.0 +changes: + - version: REPLACEME + pr-url: https://github.com/nodejs/node/pull/64525 + description: Marked as stable. --> -> Stability: 1 - Experimental +> Stability: 2 - Stable * `nameOrChannels` {string|TracingChannel} Channel name or object containing all the [TracingChannel Channels][] @@ -744,9 +748,13 @@ The scope must be used with the `using` syntax to ensure proper disposal. added: - v19.9.0 - v18.19.0 +changes: + - version: REPLACEME + pr-url: https://github.com/nodejs/node/pull/64525 + description: Marked as stable. --> -> Stability: 1 - Experimental +> Stability: 2 - Stable The class `TracingChannel` is a collection of [TracingChannel Channels][] which together express a single traceable action. It is used to formalize and From 7f31f6226ee62f472d431b9e6191555eb60fa354 Mon Sep 17 00:00:00 2001 From: "Kamat, Trivikram" <16024985+trivikr@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:07:21 -0700 Subject: [PATCH 094/344] sqlite: isolate applyChangeset filter errors Track filter callback failures within each applyChangeset() invocation. Returning false from xFilter is not a SQLite error. This previously left the database-wide suppression flag set, which could hide the next unrelated SQLite error. Signed-off-by: Kamat, Trivikram <16024985+trivikr@users.noreply.github.com> Assisted-by: codex:gpt-5.6-sol PR-URL: https://github.com/nodejs/node/pull/64823 Fixes: https://github.com/nodejs/node/issues/64822 Reviewed-By: Stephen Belanger --- src/node_sqlite.cc | 11 ++++++----- test/parallel/test-sqlite-session.js | 7 +++++++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/node_sqlite.cc b/src/node_sqlite.cc index 80da5bc0d9bf..0898e450a503 100644 --- a/src/node_sqlite.cc +++ b/src/node_sqlite.cc @@ -2326,6 +2326,7 @@ void DatabaseSync::ApplyChangeset(const FunctionCallbackInfo& args) { Local conflictFunc; Local filterFunc; + bool filterCallbackFailed = false; if (args.Length() > 1 && !args[1]->IsUndefined()) { if (!args[1]->IsObject()) { THROW_ERR_INVALID_ARG_TYPE(env->isolate(), @@ -2387,25 +2388,25 @@ void DatabaseSync::ApplyChangeset(const FunctionCallbackInfo& args) { filterFunc = filterValue.As(); - context.filterCallback = - [env, db, &filterFunc](std::string_view item) -> bool { + context.filterCallback = [env, &filterFunc, &filterCallbackFailed]( + std::string_view item) -> bool { // If there was an error in the previous call to the filter's // callback, we skip calling it again. - if (db->ignore_next_sqlite_error_) { + if (filterCallbackFailed) { return false; } Local argv[1]; if (!ToV8Value(env->context(), item, env->isolate()) .ToLocal(&argv[0])) { - db->SetIgnoreNextSQLiteError(true); + filterCallbackFailed = true; return false; } Local result; if (!filterFunc->Call(env->context(), Null(env->isolate()), 1, argv) .ToLocal(&result)) { - db->SetIgnoreNextSQLiteError(true); + filterCallbackFailed = true; return false; } diff --git a/test/parallel/test-sqlite-session.js b/test/parallel/test-sqlite-session.js index c36b4352a341..2a72740bf899 100644 --- a/test/parallel/test-sqlite-session.js +++ b/test/parallel/test-sqlite-session.js @@ -404,6 +404,13 @@ test('filter handler throws', (t) => { name: 'Error', message: 'Error filtering table data1' }); + + t.assert.throws(() => { + database2.exec('CREATE TABLEEEE'); + }, { + code: 'ERR_SQLITE_ERROR', + message: /syntax error/, + }); }); test('database.createSession() - filter changes', (t) => { From 7074ae30fa7be606139f88da14ff52c426b4566a Mon Sep 17 00:00:00 2001 From: "Kamat, Trivikram" <16024985+trivikr@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:58:25 -0700 Subject: [PATCH 095/344] test: allow half-open CONNECT tunnel sockets CONNECT tunnels are full-duplex. When the upstream socket receives a FIN while the client-to-upstream pipe is still draining, the default socket behavior can produce EPIPE or ECONNRESET errors. Keep the upstream socket half-open so both directions can drain. Separate request logs from transport errors to verify that teardown completes without errors. Signed-off-by: Kamat, Trivikram <16024985+trivikr@users.noreply.github.com> Assisted-by: codex:gpt-5.6-sol PR-URL: https://github.com/nodejs/node/pull/64973 Refs: https://github.com/nodejs/reliability/issues?q=%22test-https-proxy-request-invalid-char-in-url%22 Reviewed-By: Luigi Pinca --- ...-https-proxy-request-invalid-char-in-url.mjs | 17 ++++------------- test/common/proxy-server.js | 9 ++++++++- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/test/client-proxy/test-https-proxy-request-invalid-char-in-url.mjs b/test/client-proxy/test-https-proxy-request-invalid-char-in-url.mjs index 80c7be4931cd..888137b9a5a6 100644 --- a/test/client-proxy/test-https-proxy-request-invalid-char-in-url.mjs +++ b/test/client-proxy/test-https-proxy-request-invalid-char-in-url.mjs @@ -82,19 +82,10 @@ for (const testCase of testCases) { proxy.close(); server.close(); assert.deepStrictEqual(requests, expectedUrls); - const logSet = new Set(logs); - for (const log of logSet) { - if (log.source === 'proxy connect' && log.error?.code === 'EPIPE') { - // There can be a race from eagerly shutting down the servers and severing - // two pipes at the same time but for the purpose of this test, we only - // care about whether the requests are initiated from the client as expected, - // not how the upstream/proxy servers behave. Ignore EPIPE errors from them.. - // Refs: https://github.com/nodejs/node/issues/59741 - console.log('Ignoring EPIPE error from proxy connect', log.error); - logSet.delete(log); - } - } - assert.deepStrictEqual(logSet, expectedProxyLogs); + const requestLogs = logs.filter((log) => !('error' in log)); + const errors = logs.filter((log) => 'error' in log); + assert.deepStrictEqual(new Set(requestLogs), expectedProxyLogs); + assert.deepStrictEqual(errors, []); })); } })); diff --git a/test/common/proxy-server.js b/test/common/proxy-server.js index a2f8bd12e625..723fe0ea5c6b 100644 --- a/test/common/proxy-server.js +++ b/test/common/proxy-server.js @@ -80,7 +80,14 @@ function createProxyServer(options = {}) { const normalizedHostname = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname; - const proxyReq = net.connect(port, normalizedHostname, () => { + // A CONNECT tunnel is full-duplex. Keep the upstream socket writable after + // receiving a FIN so that the client-to-upstream pipe can finish draining. + // The reverse pipe will end `res`, and `res` will in turn end `proxyReq`. + const proxyReq = net.connect({ + port, + host: normalizedHostname, + allowHalfOpen: true, + }, () => { res.write( 'HTTP/1.1 200 Connection Established\r\n' + 'Proxy-agent: Node.js-Proxy\r\n' + From 59d7e22a129dd1685ad2bb3adeae58e62ed73c52 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Sun, 9 Aug 2026 22:38:43 +0200 Subject: [PATCH 096/344] test: avoid deadlock issue in pipeline http2 tests to fix flakiness This does not solve the remaining underlying deadlock issue, but does bound the test behaviour in a way that seems to avoid failures in practice. Deadlock fix to come separately later. Co-authored-by: Filip Skokan Signed-off-by: Tim Perry PR-URL: https://github.com/nodejs/node/pull/65079 Reviewed-By: Yagiz Nizipli Reviewed-By: Matteo Collina Reviewed-By: Filip Skokan --- test/parallel/test-stream-pipeline-http2.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/parallel/test-stream-pipeline-http2.js b/test/parallel/test-stream-pipeline-http2.js index c35cd696bd1e..8ffee7786838 100644 --- a/test/parallel/test-stream-pipeline-http2.js +++ b/test/parallel/test-stream-pipeline-http2.js @@ -27,10 +27,12 @@ const http2 = require('http2'); client.close(); })); - let cnt = 10; + let received = 0; req.on('data', (data) => { - cnt--; - if (cnt === 0) rs.destroy(); + received += data.length; + // Bound the data that flows before teardown - bytes per data event vary + // by platform, and letting this run longer hangs on macOS. + if (received >= 32 * 1024) rs.destroy(); }); })); } From 10b2c8a87309e2153341475c7442841419adf216 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Mon, 10 Aug 2026 14:55:09 +0200 Subject: [PATCH 097/344] tools: fix GITHUB_TOKEN permissions for CQ workflow Signed-off-by: Antoine du Hamel PR-URL: https://github.com/nodejs/node/pull/65192 Refs: https://github.com/nodejs/node/pull/65169 Reviewed-By: Filip Skokan Reviewed-By: Beth Griggs --- .github/workflows/commit-queue.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/commit-queue.yml b/.github/workflows/commit-queue.yml index 641fe2a71742..a4d61132deea 100644 --- a/.github/workflows/commit-queue.yml +++ b/.github/workflows/commit-queue.yml @@ -59,7 +59,10 @@ jobs: needs: get_candidate_prs if: needs.get_candidate_prs.outputs.candidates != '' permissions: + checks: read + contents: read pull-requests: read + statuses: read runs-on: ubuntu-slim steps: # Install dependencies From fc038a0ef5bb0129dda189b100ccac3d3924ced6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:06:56 +0000 Subject: [PATCH 098/344] tools: bump js-yaml from 4.2.0 to 4.3.1 in /tools/eslint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.2.0 to 4.3.1. - [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.1/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...4.3.1) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 4.3.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] PR-URL: https://github.com/nodejs/node/pull/65130 Reviewed-By: Colin Ihrig Reviewed-By: René Reviewed-By: Luigi Pinca --- tools/eslint/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/eslint/package-lock.json b/tools/eslint/package-lock.json index 82bdd3efb8c2..fc7840daff98 100644 --- a/tools/eslint/package-lock.json +++ b/tools/eslint/package-lock.json @@ -1408,9 +1408,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "funding": [ { "type": "github", From 5506f77924308a0822787888784dc676c0cea79c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:07:09 +0000 Subject: [PATCH 099/344] tools: bump js-yaml from 4.2.0 to 4.3.1 in /tools/lint-md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.2.0 to 4.3.1. - [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.1/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...4.3.1) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 4.3.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] PR-URL: https://github.com/nodejs/node/pull/65129 Reviewed-By: Colin Ihrig Reviewed-By: René Reviewed-By: Luigi Pinca --- tools/lint-md/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/lint-md/package-lock.json b/tools/lint-md/package-lock.json index 9f4539b0837e..1ab4268328b2 100644 --- a/tools/lint-md/package-lock.json +++ b/tools/lint-md/package-lock.json @@ -326,9 +326,9 @@ } }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "funding": [ { "type": "github", From 50174cb94a2e099cdef508144cd46a0cf1780b91 Mon Sep 17 00:00:00 2001 From: Chengzhong Wu Date: Mon, 10 Aug 2026 11:07:35 -0400 Subject: [PATCH 100/344] build: deprecate always enabled `--enable-static` `libnode.a` is now always produced, unless configured with flag `--shared`. This deprecates the no-op flag `--enable-static`. Signed-off-by: Chengzhong Wu PR-URL: https://github.com/nodejs/node/pull/65103 Refs: https://github.com/nodejs/node/issues/65026 Refs: https://github.com/nodejs/node/pull/63626 Reviewed-By: Richard Lau Reviewed-By: Joyee Cheung Reviewed-By: Luigi Pinca --- configure.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/configure.py b/configure.py index 6ddc73c301ef..b663ba85f58e 100755 --- a/configure.py +++ b/configure.py @@ -1101,7 +1101,7 @@ action='store_true', dest='enable_static', default=None, - help='build as static library') + help=argparse.SUPPRESS) # Deprecated parser.add_argument('--no-browser-globals', action='store_true', @@ -2075,9 +2075,6 @@ def configure_node(o): if options.v8_options: o['variables']['node_v8_options'] = options.v8_options.replace('"', '\\"') - if options.enable_static: - o['variables']['node_target_type'] = 'static_library' - o['variables']['node_debug_lib'] = b(options.node_debug_lib) if options.debug_nghttp2: @@ -2120,10 +2117,13 @@ def configure_node(o): else: o['variables']['coverage'] = 'false' + if options.enable_static and options.shared: + error('--enable-static must not be set with --shared') + if options.enable_static: + warn('--enable-static is deprecated and libnode.a is always produced') + if options.shared: o['variables']['node_target_type'] = 'shared_library' - elif options.enable_static: - o['variables']['node_target_type'] = 'static_library' else: o['variables']['node_target_type'] = 'executable' From b1aa78a57e2ff482af4ab73a1821798444c8d35f Mon Sep 17 00:00:00 2001 From: Meghan Denny Date: Mon, 10 Aug 2026 08:51:40 -0700 Subject: [PATCH 101/344] test: fix hidden error in test-http-server-stale-close.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-URL: https://github.com/nodejs/node/pull/59357 Reviewed-By: Luigi Pinca Reviewed-By: Ryuhei Shima Reviewed-By: Gürgün Dayıoğlu Reviewed-By: Stefan Stojanovic --- test/parallel/test-http-server-stale-close.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/parallel/test-http-server-stale-close.js b/test/parallel/test-http-server-stale-close.js index 60112f708628..d33c756a4a30 100644 --- a/test/parallel/test-http-server-stale-close.js +++ b/test/parallel/test-http-server-stale-close.js @@ -31,7 +31,7 @@ if (process.env.NODE_TEST_FORK_PORT) { method: 'POST', host: '127.0.0.1', port: +process.env.NODE_TEST_FORK_PORT, - }, process.exit); + }, () => process.exit(0)); req.write('BAM'); req.end(); } else { From 631d3aa37bed2d6bf5249a1ee2dde0890de7435e Mon Sep 17 00:00:00 2001 From: "Node.js GitHub Bot" Date: Mon, 10 Aug 2026 20:35:36 -0400 Subject: [PATCH 102/344] deps: update libffi to 3.8.0 PR-URL: https://github.com/nodejs/node/pull/65154 Reviewed-By: Chemi Atlow Reviewed-By: Colin Ihrig --- deps/libffi/ChangeLog | 607 ++++++++++++++++++ deps/libffi/README.md | 22 + deps/libffi/configure | 28 +- deps/libffi/configure.ac | 2 +- deps/libffi/doc/libffi.info | 132 +++- deps/libffi/doc/libffi.pdf | Bin 178018 -> 210793 bytes deps/libffi/doc/libffi.texi | 98 +++ deps/libffi/doc/stamp-vti | 8 +- deps/libffi/doc/version.texi | 8 +- deps/libffi/generate-headers.py | 4 +- deps/libffi/include/ffi.h.in | 12 +- deps/libffi/libffi.map.in | 9 + deps/libffi/libtool-version | 2 +- deps/libffi/src/aarch64/ffi.c | 120 +++- deps/libffi/src/aarch64/ffitarget.h | 4 + deps/libffi/src/debug.c | 3 +- deps/libffi/src/ia64/ia64_flags.h | 11 + deps/libffi/src/ia64/unix.S | 6 + deps/libffi/src/java_raw_api.c | 3 +- deps/libffi/src/pa/ffitarget.h | 9 +- deps/libffi/src/powerpc/darwin_closure.S | 131 ++-- deps/libffi/src/powerpc/ffi_darwin.c | 23 + deps/libffi/src/powerpc/ffi_linux64.c | 137 +++- deps/libffi/src/powerpc/linux64_closure.S | 15 + deps/libffi/src/prep_cif.c | 110 +++- deps/libffi/src/raw_api.c | 8 +- deps/libffi/src/tramp.c | 12 +- deps/libffi/src/x86/ffi.c | 2 +- deps/libffi/src/x86/ffi64.c | 56 +- deps/libffi/src/x86/ffitarget.h | 19 + deps/libffi/src/x86/win64.S | 5 + deps/libffi/src/x86/win64_intel.S | 5 + deps/libffi/testsuite/Makefile.am | 14 +- deps/libffi/testsuite/Makefile.in | 14 +- .../closure_thiscall_fastcall_pop.c | 131 ++++ .../libffi.call/many_large_structs.c | 88 +++ deps/libffi/testsuite/libffi.call/plan_size.c | 77 +++ .../testsuite/libffi.vector/cls_vector.c | 67 ++ deps/libffi/testsuite/libffi.vector/ffitest.h | 1 + .../libffi/testsuite/libffi.vector/vector.exp | 59 ++ deps/libffi/testsuite/libffi.vector/vector.h | 32 + .../libffi.vector/vector_args_spill.c | 85 +++ .../testsuite/libffi.vector/vector_double2.c | 53 ++ .../testsuite/libffi.vector/vector_double4.c | 81 +++ .../libffi.vector/vector_float32x2.c | 53 ++ .../libffi.vector/vector_float32x4.c | 54 ++ .../testsuite/libffi.vector/vector_hva.c | 74 +++ .../testsuite/libffi.vector/vector_int32x4.c | 54 ++ .../testsuite/libffi.vector/vector_validate.c | 103 +++ .../testsuite/libffi.vector/vector_vec3.c | 73 +++ 50 files changed, 2565 insertions(+), 159 deletions(-) create mode 100644 deps/libffi/testsuite/libffi.call/closure_thiscall_fastcall_pop.c create mode 100644 deps/libffi/testsuite/libffi.call/many_large_structs.c create mode 100644 deps/libffi/testsuite/libffi.call/plan_size.c create mode 100644 deps/libffi/testsuite/libffi.vector/cls_vector.c create mode 100644 deps/libffi/testsuite/libffi.vector/ffitest.h create mode 100644 deps/libffi/testsuite/libffi.vector/vector.exp create mode 100644 deps/libffi/testsuite/libffi.vector/vector.h create mode 100644 deps/libffi/testsuite/libffi.vector/vector_args_spill.c create mode 100644 deps/libffi/testsuite/libffi.vector/vector_double2.c create mode 100644 deps/libffi/testsuite/libffi.vector/vector_double4.c create mode 100644 deps/libffi/testsuite/libffi.vector/vector_float32x2.c create mode 100644 deps/libffi/testsuite/libffi.vector/vector_float32x4.c create mode 100644 deps/libffi/testsuite/libffi.vector/vector_hva.c create mode 100644 deps/libffi/testsuite/libffi.vector/vector_int32x4.c create mode 100644 deps/libffi/testsuite/libffi.vector/vector_validate.c create mode 100644 deps/libffi/testsuite/libffi.vector/vector_vec3.c diff --git a/deps/libffi/ChangeLog b/deps/libffi/ChangeLog index 0dde93e6adad..f64d5d5c46ac 100644 --- a/deps/libffi/ChangeLog +++ b/deps/libffi/ChangeLog @@ -1,3 +1,610 @@ +commit 12ffd1f9dc56fcea79d2f742f424301ae668d663 +Author: Anthony Green +Date: Sat Aug 8 18:08:29 2026 -0400 + + README: order 3.8.0 notes by decreasing importance + + Lead with new capabilities (VECTOR types, ffi_call_plan_size, ppc64 + _Complex long double), then correctness fixes by severity, then the + trampoline caching optimization. + + Co-Authored-By: Claude Opus 4.8 (1M context) + +commit 8f2a41d9d89dd8ee2c2438f1e2f9cf04aa9a53d4 +Author: Anthony Green +Date: Sat Aug 8 18:05:33 2026 -0400 + + Release 3.8.0 + + Bump version to 3.8.0, soname to libffi.so.8.5.0 (libtool 13:0:5) for the + new public interfaces added this cycle (FFI_TYPE_VECTOR, ffi_call_plan_size), + date the README history section, and update doc/version.texi. + + Co-Authored-By: Claude Opus 4.8 (1M context) + +commit d956fe177ccfd4bb91ae7cb3ccaa0f8935a76522 +Author: Anthony Green +Date: Sat Aug 8 17:38:40 2026 -0400 + + testsuite: distribute plan_size.c + + The ffi_call_plan_size test added in #1006 was not listed in EXTRA_DIST, + so it would be omitted from release tarballs (it still runs from a git + checkout, where dejagnu globs *.c). Add it alongside the other plan_*.c + tests. + + Co-Authored-By: Claude Opus 4.8 (1M context) + +commit 670a0327b7d1576712a3cad7b9297f59f23d5430 +Author: Anthony Green +Date: Sat Aug 8 17:09:22 2026 -0400 + + README: note i386 BSD small-struct register return + + Follow-up to #1010, which returns small structs in registers on i386 + FreeBSD/OpenBSD but did not update the History section. + + Co-Authored-By: Claude Opus 4.8 (1M context) + +commit f744bc303fa4c69f1202ce283b866ebc768e0432 +Merge: abc18be0 5b8fa3fe +Author: Anthony Green +Date: Sat Aug 8 17:09:02 2026 -0400 + + Merge pull request #1010 from DTW-Thalion/x86-bsd-small-struct-return + + x86: return small structs in registers on the BSD i386 targets + +commit abc18be0d9ba9cc37c955b317e63cd52fd0d90ee +Merge: ed742112 5f24e6a0 +Author: Anthony Green +Date: Sat Aug 8 16:57:36 2026 -0400 + + Merge pull request #1006 from rvandermeulen/call-plan-size + + call_plan: add ffi_call_plan_size to report a plan's allocation + +commit ed7421122880e4daff87f1c8623d508a2e2c5c9a +Merge: e43f2548 e6db2d38 +Author: Anthony Green +Date: Sat Aug 8 16:41:24 2026 -0400 + + Merge pull request #1009 from libffi/fix-jumptable-desync-family + + Fix FFI_TYPE_LAST/vector jump-table desyncs on ia64, ppc64 (BE ELFv2), and aarch64 + +commit e6db2d38decee8bf6321472dff5147ad311e639a +Author: Anthony Green +Date: Fri Aug 7 17:00:49 2026 -0400 + + aarch64: reject sub-4-byte vector lanes in HVA classification + + is_vfp_type() maps a homogeneous vector aggregate's lane width onto the + S/D/Q register classes via FFI_TYPE_FLOAT + intlog2(reg_size) - 2, and + encodes the result as an AARCH64_RET_* code. A lane narrower than 4 bytes + (e.g. a struct of two 2-byte vectors, which libffi's own initialize_vector + accepts) yields intlog2(reg_size) < 2, producing a code below + AARCH64_RET_S4. extend_hfa_type() then computes a negative jump-table + offset (h - AARCH64_RET_S4) and branches before its table -- a wild + computed branch during ffi_call argument marshalling. + + Such a type has no short-vector register class under AAPCS64, so reject it + in is_vfp_type() (returning 0 routes it through the generic aggregate + path). Fixing it at the source covers both the argument path + (extend_hfa_type) and the return path. Verified on aarch64 (Fedora under + qemu-aarch64): a call passing such an HVA segfaults before the fix and + returns correctly after it. + + Co-Authored-By: Claude Opus 4.8 (1M context) + +commit 159268174ece06f6854c6d1bca1a9b95961f6ae9 +Author: Anthony Green +Date: Fri Aug 7 08:18:51 2026 -0400 + + powerpc64: fix big-endian ELFv2 closure returns of 5/6/7-byte structs + + On big-endian ppc64 ELFv2, ffi_closure_helper_LINUX64 returns the load + codes PPC64_LD_STRUCT_5/6/7 (17/18/19) for closures returning a 5-, 6-, + or 7-byte struct, but linux64_closure.S only defined return jump-table + entries through PPC64_LD_STRUCT_3 (16). The E() macro places each 16-byte + slot with .align 4 (no .org), so codes 17/18/19 fell through into the + .Lmoredouble continuation: the closure loaded FP registers and returned + without writing r3, so the ELFv2 caller read back the computed jump + target -- a libffi code address -- as the struct value (wrong result plus + a code-pointer disclosure). Little-endian ELFv2 is unaffected (those + codes alias PPC_LD_R3/I64); big-endian ELFv1 returns structs by reference + and never emits the codes. + + Add the three missing handlers, loading the struct right-justified into + r3 per the ELFv2 convention. Verified on big-endian ppc64 ELFv2 (Adélie + Linux under qemu-ppc64): testsuite/libffi.closures/cls_{5,6,7}_1_byte.c + abort before the fix and pass after it. + + Co-Authored-By: Claude Opus 4.8 (1M context) + +commit 3fdd99b5d2fb4c8f940d82fc4b1e530e743c685b +Author: Anthony Green +Date: Fri Aug 7 06:11:00 2026 -0400 + + ia64: fix return jump-table desync after FFI_TYPE_LAST bump + + The .Lst_table/.Lld_table return-value dispatch tables in unix.S are + indexed by the FFI_IA64_TYPE_SMALL_STRUCT/HFA_* codes, which are + FFI_TYPE_LAST-relative, but the tables hardcoded 20 entries assuming + FFI_TYPE_LAST == FFI_TYPE_COMPLEX (15). The conditional __int128 + support added in 3.6.0 advanced FFI_TYPE_LAST to SINT128 (17), and + FFI_TYPE_VECTOR advanced it to 18, shifting SMALL_STRUCT to 19 -- so a + small-struct return dispatched to the HFA-ldouble handler's 16-byte + stfe store, an out-of-bounds write past rvalue, and HFA returns indexed + off the end of the table entirely. + + Add the missing UINT128/SINT128/VECTOR slots to both tables (pointing at + the existing not-implemented void handler, matching FFI_TYPE_COMPLEX) + and a FFI_TYPE_LAST tripwire, mirroring the pa and win64 guards. + + Co-Authored-By: Claude Opus 4.8 (1M context) + +commit 5b8fa3fed84ce17768eeb379f5b81663172482e9 +Author: Todd White +Date: Fri Aug 7 19:40:05 2026 -0400 + + x86: return small structs in registers on the BSD i386 targets + + i386 FreeBSD and OpenBSD return a struct of 1, 2, 4 or 8 bytes in eax and + edx, as Darwin and win32 do. ffi_prep_cif_machdep applied the size test + only under X86_WIN32 and X86_DARWIN, so on these targets it recorded + X86_RET_STRUCTPOP and allocated a return pointer the callee never writes, + and a struct return through ffi_call or through a closure read a value + that was never stored. + + configure.host already maps i?86-*-freebsd* and i?86-*-openbsd* to + TARGET=X86_FREEBSD, and include/ffi.h.in defines that name, so extend the + condition to it. Sizes 3, 6 and 12 continue to be returned in memory. + +commit 5f24e6a05574b1aa74cca77b1ecd6413a8105f62 +Author: Ryan VanderMeulen +Date: Wed Aug 5 11:09:51 2026 -0400 + + call_plan: add ffi_call_plan_size to report a plan's allocation + + ffi_call_plan is opaque, so an embedder that tracks the memory a long-lived + plan holds has no way to ask how big it is. The only options are to hardcode + a guess or to hardcode knowledge of the private struct layout, and both go + stale silently on the next release. + + The x86-64 backend records the byte count in ffi_plan at the point it is + passed to malloc, so the reported value cannot drift from the allocation it + describes; ffi_call_plan_size adds that to the handle and treats a signature + with no fast path as owning nothing beyond it. The generic backend's plan is + a bare handle, so it reports sizeof (struct ffi_call_plan). The counter lives + in ffi_plan rather than in the handle so that only plans that actually own a + move-list pay for it, and plans without one pay nothing. + + Computing the size in the query from cif->nargs instead would duplicate + build_plan's allocation formula in a second place, and would report the wrong + number if the cif were re-prepared with a different argument count after the + plan was built. + + The new symbol gets its own version node rather than joining + LIBFFI_CALL_PLAN_8.4, which shipped in 3.7.0: adding to a released node would + let a binary that needs ffi_call_plan_size look satisfiable against a 3.7.x + library that exports the node without the symbol, turning a clean link error + into a runtime failure. libtool-version is left alone, since rule 2 in that + file defers version updates to immediately before a release. + +commit e43f254881f9010a26c48f595928461c0432c7b4 +Merge: 2fd434cd 04d721cc +Author: Anthony Green +Date: Thu Aug 6 00:40:32 2026 -0400 + + Merge pull request #1008 from libffi/fix-win64-vector-small-struct-flags + + x86: fix Win64 small-struct returns broken by FFI_TYPE_VECTOR + +commit 04d721cc448316dbba5af506be46315efabd80e3 +Author: Anthony Green +Date: Wed Aug 5 22:59:29 2026 -0400 + + x86: fix Win64 small-struct returns broken by FFI_TYPE_VECTOR + + Adding FFI_TYPE_VECTOR (#1000) moved FFI_TYPE_LAST from FFI_TYPE_SINT128 + (17) to FFI_TYPE_VECTOR (18). The Win64 return pseudo-types + + FFI_TYPE_SMALL_STRUCT_1B/2B/4B = FFI_TYPE_LAST + 1..3 + + are FFI_TYPE_LAST-relative, so they shifted from 18/19/20 to 19/20/21. + The win64.S / win64_intel.S return-value dispatch is a computed jump + table indexed by cif->flags (base + flags*8) whose handlers are emitted + contiguously right after FFI_TYPE_SINT128, with no slot for value 18. + Under the sequential E() variant used by the MSVC/ml64 build, the + size-1/2/4 small-struct handlers therefore sat one 8-byte slot below the + flag values ffiw64.c now emits, so small structs returned by value were + written with the wrong width (or fell off the table into abort). This + showed up as 14 execution failures in the "Windows 64-bit Visual C++" CI + job (s55, struct3, struct_by_value_small, struct_return_2H, the small + cls_* / single_entry_structs closures, and bhaible DGTEST 47/53/55). + + Add an FFI_TYPE_VECTOR abort stub between SINT128 and SMALL_STRUCT_1B in + both tables so the jump table stays contiguous and the small-struct + entries realign with their (shifted) code values. Win64 does not marshal + vectors -- ffi_prep_cif_core rejects them since FFI_TARGET_HAS_VECTOR_TYPE + is undefined there -- so the slot is never reached at runtime. + + Also add a pa-style compile-time tripwire to src/x86/ffitarget.h so the + next generic type added bumps FFI_TYPE_LAST and #errors until the win64 + tables are updated in step. 32-bit x86 is unaffected: sysv.S indexes its + store table by the independent X86_RET_* enum, not FFI_TYPE_LAST. + + Co-Authored-By: Claude Opus 4.8 (1M context) + +commit 2fd434cd9ada4d3d97b355e62c3ce3a969682230 +Merge: a00279c2 2aa33761 +Author: Anthony Green +Date: Sun Aug 2 10:18:45 2026 -0400 + + Merge pull request #1005 from libffi/tramp-cache-unsupported-verdict + + tramp: cache the static trampoline "unsupported" verdict + +commit 2aa33761c0536339f9f322902b9bb3a981114724 +Merge: 76883c62 a00279c2 +Author: Anthony Green +Date: Sun Aug 2 10:18:33 2026 -0400 + + Merge branch 'master' into tramp-cache-unsupported-verdict + +commit 76883c628a5273f71fb025e75bf1076adae3bb4b +Author: Anthony Green +Date: Sun Aug 2 10:05:22 2026 -0400 + + tramp: cache the static trampoline "unsupported" verdict + + ffi_tramp_init() bailed out with a plain `return 0` when the system page + size exceeds the trampoline code table mapping, without recording the + outcome in tramp_globals.status. Because that early return was the only + failure exit that left status as UNINITIALIZED, every subsequent + ffi_tramp_alloc()/ffi_tramp_is_supported() call re-ran the full + initialization (ffi_tramp_arch(), sysconf(), etc.) instead of + short-circuiting on the cached verdict like the other two failure paths. + + The comparison is between two process-lifetime invariants -- map_size is + a compile-time constant from ffi_tramp_arch(), and page_size is fixed for + the life of the process (and only checked when sysconf() returned a valid + value) -- so it can never flip. Caching FAILED is therefore safe and + matches the intent of the status field. + + Affects hosts with pages larger than the 16K table, in practice 64K-page + aarch64 kernels, where static trampolines are correctly declined but the + decline was recomputed on every closure allocation. No functional change + on 4K/16K-page hosts. + + Co-Authored-By: Claude Opus 4.8 (1M context) + +commit a00279c2dc8e191ae5136b46bf6ae0e7a8da5b7a +Author: Anthony Green +Date: Sat Aug 1 07:17:23 2026 -0400 + + Note unreleased development changes in README history + + Add a "Development source only" History block for changes on master + since 3.7.1: FFI_TYPE_VECTOR SIMD support (#1000), powerpc64 _Complex + long double (#1003), and the powerpc Darwin closure fix (#1002). + + Co-Authored-By: Claude Opus 4.8 (1M context) + +commit ce77ca107a5cb0d10d5525c9422f0207e6c79ebf +Merge: d257b084 19dbdb53 +Author: Anthony Green +Date: Sat Aug 1 07:09:17 2026 -0400 + + Merge pull request #1000 from edusperoni/feat/vector-types + + Add FFI_TYPE_VECTOR: vector (SIMD) type support with libffi-computed layout + +commit d257b08495e95248f66b7bd50dd106ea19124df9 +Merge: 333d87cf b2170647 +Author: Anthony Green +Date: Tue Jul 28 00:47:29 2026 -0400 + + Merge pull request #1004 from libffi/fix-1002-ppc-darwin-closure + + powerpc: fix Darwin closure returns broken by #951 + +commit b2170647583461f42dc2d9f201211fcafda2429f +Author: Anthony Green +Date: Mon Jul 27 20:08:15 2026 -0400 + + powerpc: fix Darwin closure returns broken by #951 + + PR #951 (840add3b) changed the shared PowerPC closure helper, + ffi_closure_helper_common, to return a small PPC_LD_* jump-table index + instead of the ffi_type*, and rewrote aix_closure.S to consume it -- but + left darwin_closure.S expecting the old ffi_type* and dereferencing it. + With the helper now returning a small integer, ffi_closure_ASM + dereferenced e.g. 0 (PPC_LD_NONE, a void return) as a pointer, faulting + on a load from address 0. This crashed essentially every closure call + -- including every gobject-introspection signal handler -- on 32- and + 64-bit PowerPC Darwin (SIGBUS at ffi_closure_ASM, dar=0; issue #1002). + + Convert darwin_closure.S to the PPC_LD_* convention, mirroring + aix_closure.S: drop the ffi_type* dereference, use the returned index + directly, and reorder the return-value jump table into PPC_LD_* order + (NONE, R3, R3R4, F32, F64, F128, U8, S8, U16, S16, and on ppc64 U32, S32). + + Darwin, unlike AIX, returns small structs by value in registers, which + the existing assembly handles (Lsmallstruct/Lfour/Lstructend). The + helper's return code is a single small integer with no room for + cif->rtype, which that assembly needs, so for a by-value struct return + the helper now stashes cif->rtype in the first parameter-save slot (dead + by return time) and returns a new PPC_LD_STRUCT code; the PPC_LD_STRUCT + fragment recovers it and drives the unchanged struct machinery. By- + reference struct returns still return PPC_LD_NONE. + + Based on the approach in a patch by Sergey Fedorov (@barracuda156); the + jump table here is reordered to the PPC_LD_* layout so that float, + double, long double, sub-word and 64-bit returns also dispatch correctly. + + I have no PowerPC Darwin hardware; the jump-table fragment offsets were + checked by assembling for powerpc and powerpc64, but runtime + confirmation on 10.5/10.6 is still needed. + + Fixes #1002. + + Co-Authored-By: Claude Opus 4.8 (1M context) + +commit 333d87cf201ee279c9870fb5ad3e48e3a08aa6e5 +Merge: 46cb2e38 d7cd3a61 +Author: Anthony Green +Date: Mon Jul 27 08:52:34 2026 -0400 + + Merge pull request #1003 from libffi/fix-ppc64le-complex-longdouble + + powerpc64: implement _Complex long double for both IBM-128 and IEEE-128 + +commit d7cd3a6194885c85255c77782a05a153a42b29a5 +Author: Anthony Green +Date: Mon Jul 27 07:06:39 2026 -0400 + + powerpc64: implement _Complex long double for both IBM-128 and IEEE-128 + + Complex support for POWERPC64 ELFv2 (f0ca157, #970) defined + FFI_TARGET_HAS_COMPLEX_TYPE, which flips complex.exp from marking the + libffi.complex suite UNSUPPORTED to running it. _Complex long double + was deliberately deferred with FFI_BAD_TYPEDEF, so ffi_prep_cif failed + and every libffi.complex/*longdouble* test aborted. This was not caught + upstream because an XFAIL entry in the rlgl CI policy masked the FAILs. + + Implement both long double formats: + + - IBM-128 (double-double): each _Complex long double is passed and + returned as four doubles (real hi/lo, imag hi/lo) in f1-f4, with a + GPR shadow doubleword per FPR, and returned as a double homogeneous + aggregate. + + - IEEE binary128: real in v2, imag in v3; each half occupies a vector + register (or a 16-byte-aligned parameter save slot with two GPR + shadow doublewords) and is returned via the vector-homogeneous + small-struct path. + + discover_homogeneous_aggregate now accepts FFI_TYPE_LONGDOUBLE as a + _Complex inner type so struct-of-complex-longdouble is treated as an HFA. + Covers ffi_prep_cif, ffi_prep_args64, and the closure decode/return + paths. + + Fixes #1001. + + Co-Authored-By: Claude Opus 4.8 (1M context) + +commit debc00a0114d8530d6d691862028c607aa17dd6a +Author: Anthony Green +Date: Sun Jul 26 07:28:36 2026 -0400 + + Update doc version + +commit 19dbdb53e869e07fbff05c86d634e8c08c9a7f61 +Author: Eduardo Speroni +Date: Tue Jul 21 20:32:24 2026 -0300 + + testsuite: fix vector suite CI failures on gcc and MSVC + + Two fixes for the libffi.vector suite: + + - vector_double4.c: the non-aarch64 branch built its own void_args + array and never read the already-populated args, tripping gcc's + -Wunused-but-set-variable (an excess-errors FAIL on Linux x86-64 + with gcc; clang does not emit this warning). Use args for the + negative argument-passing check instead. + + - vector.exp: the suite only probed FFI_TARGET_HAS_VECTOR_TYPE, but + the tests are written with the GCC/Clang vector extension. On + Windows ARM64 the aarch64 port enables the feature while MSVC + cannot compile __attribute__ ((vector_size)), so every test failed + to build. Add a compile probe and mark the suite unsupported when + the compiler lacks the syntax. + +commit 71a95a2cd433b151b3fcf83a0a830eb9aa38fa3a +Author: Eduardo Speroni +Date: Tue Jul 21 16:44:26 2026 -0300 + + testsuite: add libffi.vector suite for vector (SIMD) types + + Model a new testsuite/libffi.vector/ directory on testsuite/libffi.complex: + vector.exp reuses the same dg/run-many-tests driver and skips every test as + "unsupported" on ports whose headers do not define + FFI_TARGET_HAS_VECTOR_TYPE (libffi_feature_test), so unsupported targets + still compile the gating cleanly. + + Vector types are built with the portable __attribute__((vector_size)) via a + small make_vector_type() helper (vector.h); each test cross-checks the value + returned through ffi against a direct native call. Coverage: + + - vector_float32x4 / vector_float32x2 / vector_double2 / vector_int32x4: + pass and return 8- and 16-byte float, double and integer vectors + (float32x4 is the vec4 shape of libffi/libffi#773); + - vector_args_spill: ten vectors interleaved with int/double scalars, + exhausting the vector argument registers and spilling to the stack; + - vector_vec3: Clang-only ext_vector_type(3), verifying the 12->16 byte + power-of-two padding matches a natively compiled callee (a no-op on + other compilers); + - vector_double4: on AArch64 a 32-byte vector round-trips (by reference / + in memory); elsewhere ffi_prep_cif must return FFI_BAD_TYPEDEF, checked + for both return and argument; + - vector_hva: a struct of two identical vectors (HVA) passes and returns + on both AArch64 (Q-register pair) and x86-64 (SSE struct classification); + - cls_vector: a closure receiving vector arguments and returning a vector; + - vector_validate: heterogeneous lanes, an empty vector, and a non-scalar + lane are each rejected with FFI_BAD_TYPEDEF, and a well-formed vector is + accepted with the computed power-of-two size and min(size,16) alignment. + + The files are added to testsuite/Makefile.am EXTRA_DIST, matching how + libffi.complex is distributed. + + References: libffi/libffi#414, libffi/libffi#773. + +commit 93b274cec912ac2395575a8bd4dfcb527f61599c +Author: Eduardo Speroni +Date: Tue Jul 21 16:32:04 2026 -0300 + + x86-64: marshal vector (SIMD) types per the System V psABI + + Define FFI_TARGET_HAS_VECTOR_TYPE for the SysV x86-64 backend (ffi64.c; + 32-bit x86 and the Windows ffiw64.c backend are excluded) and integrate + FFI_TYPE_VECTOR into the existing psABI classifier without restructuring + it: + + - classify_argument gains a FFI_TYPE_VECTOR case: an 8-byte vector is + one SSE eightbyte (X86_64_SSE_CLASS); a 16-byte vector is one %xmm + register (X86_64_SSE_CLASS + X86_64_SSEUP_CLASS). The existing + INTEGERSI/SSESF/SSEDF/UINT128 handling is untouched, and the SSE+SSEUP + argument marshalling already merges both eightbytes into one %xmm. + - ffi_prep_cif_machdep classifies vector returns symmetrically: 8 bytes + in %xmm0 (UNIX64_RET_XMM64), 16 bytes in %xmm0 (UNIX64_RET_XMM128). + - Vectors wider than 16 bytes return FFI_BAD_TYPEDEF from + ffi_prep_cif_machdep, for both returns and arguments. Correct + %ymm/%zmm passing needs unix64.S register-save changes and is left as + a v1 limitation rather than silently passing them in memory. + + Closures need no separate change: the closure paths reuse + classify_argument for arguments and cif->flags for the return. + + References: libffi/libffi#414. + +commit 5eaa8a389de61fc3b056f62c48ceade1931b5413 +Author: Eduardo Speroni +Date: Tue Jul 21 16:30:12 2026 -0300 + + aarch64: marshal vector (SIMD) types per AAPCS64 + + Define FFI_TARGET_HAS_VECTOR_TYPE for AArch64 and teach is_vfp_type to + classify FFI_TYPE_VECTOR, so ffi_call and closures pass and return + vectors the way AAPCS64 (and current GCC/Clang) do: + + - 8- and 16-byte vectors travel in a single V/Q register (a Short + Vector), for float, double and integer lane types alike; + - homogeneous vector aggregates -- a struct of up to four identical + 8- or 16-byte vectors -- travel in that many consecutive V/Q + registers (an HVA), e.g. struct{float32x4 a,b} in {q0,q1}; + - a bare vector wider than 16 bytes (e.g. a 32-byte double4) has no + short-vector register class, so is_vfp_type returns 0 and the + existing composite path passes it by invisible reference and returns + it in memory -- exactly what a natively compiled callee expects. + + is_simd() reports the width of one Neon register slot (a bare vector's + whole size, or one lane vector of an HVA); is_vfp_type() encodes + num_registers slots of that width onto the existing AARCH64_RET_{D,Q}* + codes via intlog2. is_hfa0/is_hfa1 recurse through FFI_TYPE_VECTOR so + HVA homogeneity is checked, and the three fundamental-type dispatch + switches (machdep return, ffi_call_int, ffi_closure_SYSV_inner) route + FFI_TYPE_VECTOR through is_vfp_type alongside FFI_TYPE_STRUCT. + + Ported from the battle-tested NativeScript aarch64 vector marshaller, + adapted to the FFI_TYPE_VECTOR API and extended so that integer-lane + vectors (e.g. int32x4) are classified into V registers too -- the + original only handled floating-point lanes. + + References: libffi/libffi#414, libffi/libffi#773 (aarch64 vec4 return). + +commit b6b8be54acc90f7db1dcf3d1c91238a5a9bca185 +Author: Eduardo Speroni +Date: Tue Jul 21 16:26:33 2026 -0300 + + core: add FFI_TYPE_VECTOR fundamental type with computed layout + + Introduce a portable API for marshalling vector (SIMD) types -- the + values produced by GCC's __attribute__((vector_size)) and Clang's + ext_vector_type. This answers the stalled PR #414 and the maintainer's + 2018 design questions + (https://sourceware.org/legacy-ml/libffi-discuss/2018/msg00020.html): + rather than requiring callers to hand-compute a vector's size and + alignment (and gating the feature behind configure), libffi now derives + the layout itself and the type code is defined unconditionally. + + A vector is described exactly like a struct: type == FFI_TYPE_VECTOR and + a NULL-terminated elements[] array, except every element must point to + the SAME fundamental scalar (float, double, or a fixed-width integer + UINT8..SINT64) and the count is the number of lanes. The caller leaves + size and alignment at zero; ffi_prep_cif computes: + + size = lane_size * count, rounded up to the next power of two + (matches Clang ext_vector_type storage: 3 x float -> 16, + 3 x double -> 32; GCC vector_size already requires pow2 + totals so it is identical there); + alignment = min(size, 16). + + Validation (identical scalar lanes, count >= 1, scalar-only) yields + FFI_BAD_TYPEDEF otherwise. + + - include/ffi.h.in: FFI_TYPE_VECTOR = 18 (after SINT128 = 17), + FFI_TYPE_LAST bumped. Defined unconditionally, no configure gating. + - src/prep_cif.c: initialize_vector() computes the layout in + initialize_aggregate; ffi_type_contains_vector() rejects vectors + (including nested in structs, argument or return) with + FFI_BAD_TYPEDEF on any port that does not define + FFI_TARGET_HAS_VECTOR_TYPE -- no aborts. Vector returns reserve the + hidden return-pointer slot like structs. + - src/raw_api.c, src/java_raw_api.c: plumb FFI_TYPE_VECTOR alongside + FFI_TYPE_STRUCT, mirroring how FFI_TYPE_COMPLEX is handled. + - src/debug.c: ffi_type_test requires elements != NULL for vectors. + - src/pa/ffitarget.h: bump the FFI_PA_TYPE_LAST tripwire; PA gates + vectors out in prep_cif so its jump tables are never reached. + - doc/libffi.texi: new "Vector Types" node documenting the API, the + computed-layout rule, the psABI framing, and the per-port support + table. + + No port defines FFI_TARGET_HAS_VECTOR_TYPE yet, so this commit rejects + every vector signature; the per-architecture ports follow. + + References: libffi/libffi#414, libffi/libffi#773. + +commit 46cb2e3871059f7f5113329ddcca818de3a8cfae +Merge: ca86812c 8cd11a77 +Author: Anthony Green +Date: Fri Jul 10 16:51:18 2026 -0400 + + Merge pull request #998 from bgilbert/tests + + testsuite: Remember to distribute tests added for 3.7.1 + +commit 8cd11a772d8a0b687f43390697baa002ae6504d5 +Author: Benjamin Gilbert +Date: Fri Jul 10 11:56:55 2026 -0700 + + testsuite: Remember to distribute tests added for 3.7.1 + +commit ca86812cd430cff3018e491ba75a4f3c9ea969d2 +Author: Anthony Green +Date: Fri Jul 10 10:56:47 2026 -0400 + + ci: Don't publish rlgl reports on tag pushes + + A tag and its commit fire two CI runs at the same SHA. Both run the + publish-reports job, which deploys a fixed-name github-pages artifact + via actions/deploy-pages; the two deployments collide and one fails + with BlobNotFound (seen on the v3.7.1 tag run). The same-SHA branch + push already publishes the reports, so gate the job off tag pushes. + + Co-Authored-By: Claude Fable 5 + commit 5c1c43091ed611fdea774374355eb938c73a9157 Author: Anthony Green Date: Fri Jul 10 09:50:53 2026 -0400 diff --git a/deps/libffi/README.md b/deps/libffi/README.md index 19f78f632b0c..797d0fd9aa8e 100644 --- a/deps/libffi/README.md +++ b/deps/libffi/README.md @@ -201,6 +201,28 @@ History See the git log for details at http://github.com/libffi/libffi. + 3.8.0 August-8-2026 + Add FFI_TYPE_VECTOR (SIMD) type support with libffi-computed + layout, for aarch64 and x86-64 (#1000, closes #773). + Add ffi_call_plan_size to report the total memory a reusable call + plan owns, for embedders that account for the memory held by + long-lived plans. + Add powerpc64 ELFv2 _Complex long double support for both + IBM-128 (double-double) and IEEE-128 formats (#1003, closes #1001). + Fix powerpc64 big-endian ELFv2 closures returning 5-, 6-, or + 7-byte structs: missing return jump-table entries produced a + wrong result and leaked a libffi code pointer. + Fix ia64 return-value jump-table desync after the FFI_TYPE_LAST + bump, which corrupted small-struct and HFA returns. + Fix powerpc Darwin closure returns broken by #951 (#1002). + Return small (1, 2, 4 or 8 byte) structs in registers on the i386 + FreeBSD and OpenBSD targets, matching the platform ABI and + fixing a segfault on struct returns through ffi_call and closures. + Cache the static trampoline "unsupported" result on hosts whose + page size exceeds the trampoline table mapping, avoiding + redundant re-initialization on every closure allocation + (e.g. 64K-page aarch64). + 3.7.1 July-10-2026 Fix aarch64 ffi_call memory corruption when passing many large structs by value. diff --git a/deps/libffi/configure b/deps/libffi/configure index e050ddc8675d..7b8a4cb375d1 100755 --- a/deps/libffi/configure +++ b/deps/libffi/configure @@ -1,6 +1,6 @@ #! /bin/sh # Guess values for system-dependent variables and create Makefiles. -# Generated by GNU Autoconf 2.71 for libffi 3.7.1. +# Generated by GNU Autoconf 2.71 for libffi 3.8.0. # # Report bugs to . # @@ -621,8 +621,8 @@ MAKEFLAGS= # Identity of this package. PACKAGE_NAME='libffi' PACKAGE_TARNAME='libffi' -PACKAGE_VERSION='3.7.1' -PACKAGE_STRING='libffi 3.7.1' +PACKAGE_VERSION='3.8.0' +PACKAGE_STRING='libffi 3.8.0' PACKAGE_BUGREPORT='http://github.com/libffi/libffi/issues' PACKAGE_URL='' @@ -1417,7 +1417,7 @@ if test "$ac_init_help" = "long"; then # Omit some internal or obsolete options to make the list less imposing. # This message is too long to be a string in the A/UX 3.1 sh. cat <<_ACEOF -\`configure' configures libffi 3.7.1 to adapt to many kinds of systems. +\`configure' configures libffi 3.8.0 to adapt to many kinds of systems. Usage: $0 [OPTION]... [VAR=VALUE]... @@ -1489,7 +1489,7 @@ fi if test -n "$ac_init_help"; then case $ac_init_help in - short | recursive ) echo "Configuration of libffi 3.7.1:";; + short | recursive ) echo "Configuration of libffi 3.8.0:";; esac cat <<\_ACEOF @@ -1628,7 +1628,7 @@ fi test -n "$ac_init_help" && exit $ac_status if $ac_init_version; then cat <<\_ACEOF -libffi configure 3.7.1 +libffi configure 3.8.0 generated by GNU Autoconf 2.71 Copyright (C) 2021 Free Software Foundation, Inc. @@ -2259,7 +2259,7 @@ cat >config.log <<_ACEOF This file contains any messages produced by compilers while running configure, to aid debugging if configure makes a mistake. -It was created by libffi $as_me 3.7.1, which was +It was created by libffi $as_me 3.8.0, which was generated by GNU Autoconf 2.71. Invocation command line was $ $0$ac_configure_args_raw @@ -3233,10 +3233,10 @@ ac_compiler_gnu=$ac_cv_c_compiler_gnu ac_config_headers="$ac_config_headers fficonfig.h" -FFI_VERSION_STRING="3.7.1" -ffi_version_major=`echo "3.7.1" | cut -d. -f1` -ffi_version_minor=`echo "3.7.1" | cut -d. -f2 | sed 's/[^0-9].*//'` -ffi_version_micro=`echo "3.7.1" | cut -d. -f3 | sed 's/[^0-9].*//'` +FFI_VERSION_STRING="3.8.0" +ffi_version_major=`echo "3.8.0" | cut -d. -f1` +ffi_version_minor=`echo "3.8.0" | cut -d. -f2 | sed 's/[^0-9].*//'` +ffi_version_micro=`echo "3.8.0" | cut -d. -f3 | sed 's/[^0-9].*//'` FFI_VERSION_NUMBER=`expr ${ffi_version_major:-0} \* 10000 + ${ffi_version_minor:-0} \* 100 + ${ffi_version_micro:-0}` @@ -3986,7 +3986,7 @@ fi # Define the identity of the package. PACKAGE='libffi' - VERSION='3.7.1' + VERSION='3.8.0' printf "%s\n" "#define PACKAGE \"$PACKAGE\"" >>confdefs.h @@ -20661,7 +20661,7 @@ cat >>$CONFIG_STATUS <<\_ACEOF || ac_write_fail=1 # report actual input values of CONFIG_FILES etc. instead of their # values after options handling. ac_log=" -This file was extended by libffi $as_me 3.7.1, which was +This file was extended by libffi $as_me 3.8.0, which was generated by GNU Autoconf 2.71. Invocation command line was CONFIG_FILES = $CONFIG_FILES @@ -20729,7 +20729,7 @@ ac_cs_config_escaped=`printf "%s\n" "$ac_cs_config" | sed "s/^ //; s/'/'\\\\\\\\ cat >>$CONFIG_STATUS <<_ACEOF || ac_write_fail=1 ac_cs_config='$ac_cs_config_escaped' ac_cs_version="\\ -libffi config.status 3.7.1 +libffi config.status 3.8.0 configured by $0, generated by GNU Autoconf 2.71, with options \\"\$ac_cs_config\\" diff --git a/deps/libffi/configure.ac b/deps/libffi/configure.ac index 3370acc3a396..826b453b8353 100644 --- a/deps/libffi/configure.ac +++ b/deps/libffi/configure.ac @@ -2,7 +2,7 @@ dnl Process this with autoconf to create configure AC_PREREQ([2.68]) -AC_INIT([libffi],[3.7.1],[http://github.com/libffi/libffi/issues]) +AC_INIT([libffi],[3.8.0],[http://github.com/libffi/libffi/issues]) AC_CONFIG_HEADERS([fficonfig.h]) dnl Derive the version macros from AC_INIT so they cannot drift when the diff --git a/deps/libffi/doc/libffi.info b/deps/libffi/doc/libffi.info index b43246f8ed4e..4c1abc85f777 100644 --- a/deps/libffi/doc/libffi.info +++ b/deps/libffi/doc/libffi.info @@ -301,6 +301,7 @@ File: libffi.info, Node: Types, Next: Multiple ABIs, Prev: Simple Example, U * Type Example:: Structure type example. * Complex:: Complex types. * Complex Type Example:: Complex type example. +* Vector Types:: Vector (SIMD) types.  File: libffi.info, Node: Primitive Types, Next: Structures, Up: Types @@ -660,7 +661,7 @@ functions ‘ffi_prep_cif’ and ‘ffi_prep_args’ abort the program if they encounter a complex type.  -File: libffi.info, Node: Complex Type Example, Prev: Complex, Up: Types +File: libffi.info, Node: Complex Type Example, Next: Vector Types, Prev: Complex, Up: Types 2.3.7 Complex Type Example -------------------------- @@ -746,6 +747,92 @@ compilers that support them: The new type descriptors can then be used like one of the built-in type descriptors in the previous example. + +File: libffi.info, Node: Vector Types, Prev: Complex Type Example, Up: Types + +2.3.8 Vector Types +------------------ + +‘libffi’ can marshal vector (SIMD) types -- the values produced by GCC's +‘__attribute__((vector_size (N)))’ and Clang's ‘ext_vector_type’ -- on +the platforms listed in the support table below. A vector is described +just like a structure, except that every element pointer refers to the +_same_ fundamental scalar type and the number of elements is the number +of vector lanes. + + -- Data type: ffi_type + ‘size_t size’ + This must be set to ‘0’. ‘libffi’ computes the storage size + (see below) from the element type and lane count. + + ‘unsigned short alignment’ + This must be set to ‘0’. ‘libffi’ computes the alignment. + + ‘unsigned short type’ + For a vector type, this must be set to ‘FFI_TYPE_VECTOR’. + + ‘ffi_type **elements’ + This is a ‘NULL’-terminated array of pointers to ‘ffi_type’ + objects. Every entry must point to the same scalar element + type, and the number of entries is the vector's lane count N + (N >= 1). The element type must be one of ‘ffi_type_float’, + ‘ffi_type_double’, or a fixed-width integer (‘ffi_type_uint8’ + through ‘ffi_type_sint64’); ‘long double’ and aggregate + element types are not permitted. + +Computed layout +............... + +Because the caller leaves ‘size’ and ‘alignment’ at ‘0’, ‘libffi’ +derives them so that applications need not encode compiler- or +platform-specific rules: + + • ‘size’ is lane\_size \times N rounded _up_ to the next power of + two. This matches Clang's ‘ext_vector_type’ storage -- for example + a three-lane ‘float’ vector occupies 16 bytes and a three-lane + ‘double’ vector occupies 32 bytes. GCC's ‘vector_size’ already + requires power-of-two byte totals, so the rule is identical there. + + • ‘alignment’ is ‘min(size, 16)’. + + If the element list is heterogeneous, empty, or uses a disallowed +element type, ‘ffi_prep_cif’ returns ‘FFI_BAD_TYPEDEF’. + +psABI framing +............. + +At the call boundary the platform's processor-specific ABI (AAPCS64 on +AArch64, the System V x86-64 psABI on x86-64) decides how a vector is +passed and returned, independently of which compiler produced it. The +historical divergence between GCC's ‘vector_size’ and Clang's +‘ext_vector_type’ concerns only in-memory _layout_ (notably the padding +of odd-lane vectors such as ‘float3’); the power-of-two size rule above +pins that layout down, so a value marshalled by ‘libffi’ matches what a +natively compiled caller or callee expects. + +Per-port support +................ + +Port Vector support +-------------------------------------------------------------------------- +AArch64 (AAPCS64) 8- and 16-byte vectors in a single V/Q register; + homogeneous vector aggregates (structs of up to + four identical 8- or 16-byte vectors) in + consecutive V/Q registers. A bare vector larger + than 16 bytes (for example a 32-byte ‘double4’) + has no short-vector register class and is passed + and returned in memory, exactly as AAPCS64 and + current compilers do. +x86-64 (System V 8- and 16-byte vectors in an SSE register (‘%xmm0’ +psABI) for returns). A bare vector larger than 16 bytes + needs ‘%ymm’/‘%zmm’ register handling that this + port does not yet implement, so ‘ffi_prep_cif’ + returns ‘FFI_BAD_TYPEDEF’ for it. +Other ports Not supported: ‘ffi_prep_cif’ returns + ‘FFI_BAD_TYPEDEF’ for any signature that mentions + a vector type, including one nested inside a + struct. +  File: libffi.info, Node: Multiple ABIs, Next: Reusable Call Plans, Prev: Types, Up: Using libffi @@ -795,6 +882,14 @@ prepared ‘ffi_cif’. is harmless. The ‘ffi_cif’ the plan was built from is not affected. + -- Function: size_t ffi_call_plan_size (ffi_call_plan *PLAN) + Returns the total number of bytes ‘libffi’ allocated for PLAN, + including any internal argument-placement data it owns. Returns + zero when PLAN is ‘NULL’. The result does not include the + ‘ffi_cif’, which the caller owns. This is intended for embedders + that account for the memory held by long-lived plans and would + otherwise have to guess at the size of an opaque type. +  File: libffi.info, Node: The Closure API, Next: Closure Example, Prev: Reusable Call Plans, Up: Using libffi @@ -1056,6 +1151,7 @@ Index * ffi_call_plan_alloc: Reusable Call Plans. (line 12) * ffi_call_plan_free: Reusable Call Plans. (line 32) * ffi_call_plan_invoke: Reusable Call Plans. (line 22) +* ffi_call_plan_size: Reusable Call Plans. (line 37) * ffi_closure_alloc: The Closure API. (line 19) * ffi_closure_free: The Closure API. (line 26) * FFI_CLOSURES: The Closure API. (line 13) @@ -1075,6 +1171,8 @@ Index * ffi_type <1>: Structures. (line 10) * ffi_type <2>: Complex. (line 15) * ffi_type <3>: Complex. (line 15) +* ffi_type <4>: Vector Types. (line 13) +* ffi_type <5>: Vector Types. (line 13) * ffi_type_complex_double: Primitive Types. (line 82) * ffi_type_complex_float: Primitive Types. (line 79) * ffi_type_complex_longdouble: Primitive Types. (line 85) @@ -1101,6 +1199,7 @@ Index * ffi_type_void: Primitive Types. (line 10) * Foreign Function Interface: Introduction. (line 31) * size_t: The Basics. (line 125) +* size_t <1>: Reusable Call Plans. (line 37) * unsigned int: The Basics. (line 122) * unsigned long: The Basics. (line 117) * void: The Basics. (line 72) @@ -1118,21 +1217,22 @@ Node: Using libffi4569 Node: The Basics5172 Node: Simple Example11346 Node: Types12403 -Node: Primitive Types12914 -Node: Structures15231 -Node: Size and Alignment16342 -Node: Arrays Unions Enums18613 -Node: Type Example21590 -Node: Complex22896 -Node: Complex Type Example24410 -Node: Multiple ABIs27462 -Node: Reusable Call Plans27849 -Node: The Closure API29566 -Node: Closure Example33908 -Node: Thread Safety35552 -Node: Memory Usage36385 -Node: Missing Features37660 -Node: Index38037 +Node: Primitive Types12967 +Node: Structures15284 +Node: Size and Alignment16395 +Node: Arrays Unions Enums18666 +Node: Type Example21643 +Node: Complex22949 +Node: Complex Type Example24463 +Node: Vector Types27536 +Node: Multiple ABIs31575 +Node: Reusable Call Plans31962 +Node: The Closure API34155 +Node: Closure Example38497 +Node: Thread Safety40141 +Node: Memory Usage40974 +Node: Missing Features42249 +Node: Index42626  End Tag Table diff --git a/deps/libffi/doc/libffi.pdf b/deps/libffi/doc/libffi.pdf index 250d34faf71159260bc7d8905a68cd9885767592..75458a8f7d5d231e322b5f49c1af24ca709ae283 100644 GIT binary patch delta 100558 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

FKzd=w=h0 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 diff --git a/deps/libffi/doc/libffi.texi b/deps/libffi/doc/libffi.texi index 251214f890d3..4d2802c5bd97 100644 --- a/deps/libffi/doc/libffi.texi +++ b/deps/libffi/doc/libffi.texi @@ -320,6 +320,7 @@ int main() * Type Example:: Structure type example. * Complex:: Complex types. * Complex Type Example:: Complex type example. +* Vector Types:: Vector (SIMD) types. @end menu @node Primitive Types @@ -802,6 +803,93 @@ FFI_COMPLEX_TYPEDEF(uchar, unsigned char, ffi_type_uint8); The new type descriptors can then be used like one of the built-in type descriptors in the previous example. +@node Vector Types +@subsection Vector Types + +@code{libffi} can marshal vector (SIMD) types --- the values produced +by GCC's @code{__attribute__((vector_size (N)))} and Clang's +@code{ext_vector_type} --- on the platforms listed in the support table +below. A vector is described just like a structure, except that every +element pointer refers to the @emph{same} fundamental scalar type and the +number of elements is the number of vector lanes. + +@tindex ffi_type +@deftp {Data type} ffi_type +@table @code +@item size_t size +This must be set to @code{0}. @code{libffi} computes the storage size +(see below) from the element type and lane count. + +@item unsigned short alignment +This must be set to @code{0}. @code{libffi} computes the alignment. + +@item unsigned short type +For a vector type, this must be set to @code{FFI_TYPE_VECTOR}. + +@item ffi_type **elements +This is a @samp{NULL}-terminated array of pointers to @code{ffi_type} +objects. Every entry must point to the same scalar element type, and the +number of entries is the vector's lane count @math{N} (@math{N >= 1}). The +element type must be one of @code{ffi_type_float}, @code{ffi_type_double}, +or a fixed-width integer (@code{ffi_type_uint8} through +@code{ffi_type_sint64}); @code{long double} and aggregate element types are +not permitted. +@end table +@end deftp + +@subsubheading Computed layout + +Because the caller leaves @code{size} and @code{alignment} at @code{0}, +@code{libffi} derives them so that applications need not encode +compiler- or platform-specific rules: + +@itemize @bullet +@item +@code{size} is @math{lane\_size \times N} rounded @emph{up} to the next +power of two. This matches Clang's @code{ext_vector_type} storage --- for +example a three-lane @code{float} vector occupies 16 bytes and a three-lane +@code{double} vector occupies 32 bytes. GCC's @code{vector_size} already +requires power-of-two byte totals, so the rule is identical there. + +@item +@code{alignment} is @code{min(size, 16)}. +@end itemize + +If the element list is heterogeneous, empty, or uses a disallowed element +type, @code{ffi_prep_cif} returns @code{FFI_BAD_TYPEDEF}. + +@subsubheading psABI framing + +At the call boundary the platform's processor-specific ABI (AAPCS64 on +AArch64, the System V x86-64 psABI on x86-64) decides how a vector is +passed and returned, independently of which compiler produced it. The +historical divergence between GCC's @code{vector_size} and Clang's +@code{ext_vector_type} concerns only in-memory @emph{layout} (notably the +padding of odd-lane vectors such as @code{float3}); the power-of-two size +rule above pins that layout down, so a value marshalled by @code{libffi} +matches what a natively compiled caller or callee expects. + +@subsubheading Per-port support + +@multitable @columnfractions .28 .72 +@headitem Port @tab Vector support +@item AArch64 (AAPCS64) +@tab 8- and 16-byte vectors in a single V/Q register; homogeneous vector +aggregates (structs of up to four identical 8- or 16-byte vectors) in +consecutive V/Q registers. A bare vector larger than 16 bytes (for +example a 32-byte @code{double4}) has no short-vector register class and is +passed and returned in memory, exactly as AAPCS64 and current compilers do. +@item x86-64 (System V psABI) +@tab 8- and 16-byte vectors in an SSE register (@code{%xmm0} for returns). +A bare vector larger than 16 bytes needs @code{%ymm}/@code{%zmm} register +handling that this port does not yet implement, so @code{ffi_prep_cif} +returns @code{FFI_BAD_TYPEDEF} for it. +@item Other ports +@tab Not supported: @code{ffi_prep_cif} returns @code{FFI_BAD_TYPEDEF} for +any signature that mentions a vector type, including one nested inside a +struct. +@end multitable + @node Multiple ABIs @section Multiple ABIs @@ -853,6 +941,16 @@ Releases a plan returned by @code{ffi_call_plan_alloc}. Passing not affected. @end defun +@findex ffi_call_plan_size +@defun size_t ffi_call_plan_size (ffi_call_plan *@var{plan}) +Returns the total number of bytes @code{libffi} allocated for @var{plan}, +including any internal argument-placement data it owns. Returns zero when +@var{plan} is @code{NULL}. The result does not include the +@code{ffi_cif}, which the caller owns. This is intended for embedders that +account for the memory held by long-lived plans and would otherwise have to +guess at the size of an opaque type. +@end defun + @node The Closure API @section The Closure API diff --git a/deps/libffi/doc/stamp-vti b/deps/libffi/doc/stamp-vti index e755454e9c82..dc281e38e0f6 100644 --- a/deps/libffi/doc/stamp-vti +++ b/deps/libffi/doc/stamp-vti @@ -1,4 +1,4 @@ -@set UPDATED 10 July 2026 -@set UPDATED-MONTH July 2026 -@set EDITION 3.7.1 -@set VERSION 3.7.1 +@set UPDATED 8 August 2026 +@set UPDATED-MONTH August 2026 +@set EDITION 3.8.0 +@set VERSION 3.8.0 diff --git a/deps/libffi/doc/version.texi b/deps/libffi/doc/version.texi index e755454e9c82..dc281e38e0f6 100644 --- a/deps/libffi/doc/version.texi +++ b/deps/libffi/doc/version.texi @@ -1,4 +1,4 @@ -@set UPDATED 10 July 2026 -@set UPDATED-MONTH July 2026 -@set EDITION 3.7.1 -@set VERSION 3.7.1 +@set UPDATED 8 August 2026 +@set UPDATED-MONTH August 2026 +@set EDITION 3.8.0 +@set VERSION 3.8.0 diff --git a/deps/libffi/generate-headers.py b/deps/libffi/generate-headers.py index e2d2942deffb..fb58edf17b66 100644 --- a/deps/libffi/generate-headers.py +++ b/deps/libffi/generate-headers.py @@ -7,8 +7,8 @@ from pathlib import Path -LIBFFI_VERSION = '3.7.1' -LIBFFI_VERSION_NUMBER = '30701' +LIBFFI_VERSION = '3.8.0' +LIBFFI_VERSION_NUMBER = '30800' def normalize_arch(target_arch): aliases = { diff --git a/deps/libffi/include/ffi.h.in b/deps/libffi/include/ffi.h.in index 35f09cf43315..cb0a7dbcaaa3 100644 --- a/deps/libffi/include/ffi.h.in +++ b/deps/libffi/include/ffi.h.in @@ -79,9 +79,10 @@ extern "C" { #define FFI_TYPE_COMPLEX 15 #define FFI_TYPE_UINT128 16 #define FFI_TYPE_SINT128 17 +#define FFI_TYPE_VECTOR 18 /* This should always refer to the last type code (for sanity checks). */ -#define FFI_TYPE_LAST FFI_TYPE_SINT128 +#define FFI_TYPE_LAST FFI_TYPE_VECTOR #include @@ -535,7 +536,11 @@ void ffi_call(ffi_cif *cif, ffi_call_plan_alloc returns NULL only on allocation failure; a signature with no fast path is still valid and ffi_call_plan_invoke falls back to ffi_call for it. A plan is immutable once built, so it may be shared and - invoked concurrently from multiple threads. */ + invoked concurrently from multiple threads. + + ffi_call_plan_size reports the total number of bytes libffi allocated for a + plan, so that callers tracking the footprint of long-lived plans do not have + to guess at the size of an opaque type. */ typedef struct ffi_call_plan ffi_call_plan; FFI_API @@ -550,6 +555,9 @@ void ffi_call_plan_invoke (ffi_call_plan *plan, FFI_API void ffi_call_plan_free (ffi_call_plan *plan); +FFI_API +size_t ffi_call_plan_size (ffi_call_plan *plan); + FFI_API ffi_status ffi_get_struct_offsets (ffi_abi abi, ffi_type *struct_type, size_t *offsets); diff --git a/deps/libffi/libffi.map.in b/deps/libffi/libffi.map.in index f4e366eb60bc..6151f10cbdce 100644 --- a/deps/libffi/libffi.map.in +++ b/deps/libffi/libffi.map.in @@ -69,6 +69,15 @@ LIBFFI_CALL_PLAN_8.4 { ffi_call_plan_free; } LIBFFI_BASE_8.1; +/* ---------------------------------------------------------------------- + Call plan footprint query (ffi_call_plan_size). A fresh node because + LIBFFI_CALL_PLAN_8.4 has already shipped. + -------------------------------------------------------------------- */ +LIBFFI_CALL_PLAN_8.5 { + global: + ffi_call_plan_size; +} LIBFFI_CALL_PLAN_8.4; + #ifdef FFI_TARGET_HAS_COMPLEX_TYPE LIBFFI_COMPLEX_8.0 { global: diff --git a/deps/libffi/libtool-version b/deps/libffi/libtool-version index c5545eab8bf6..814c6e21d925 100644 --- a/deps/libffi/libtool-version +++ b/deps/libffi/libtool-version @@ -26,4 +26,4 @@ # release, then set age to 0. # # CURRENT:REVISION:AGE -12:1:4 +13:0:5 diff --git a/deps/libffi/src/aarch64/ffi.c b/deps/libffi/src/aarch64/ffi.c index 2e6a2ad2624c..1eb90dd565ad 100644 --- a/deps/libffi/src/aarch64/ffi.c +++ b/deps/libffi/src/aarch64/ffi.c @@ -92,6 +92,19 @@ ffi_clear_cache (void *start, void *end) #endif +/* Return the base-2 logarithm of N (N assumed to be a power of two). Used + to map a vector register width (8 or 16 bytes) onto the D-/Q-register + AARCH64_RET_* encoding. */ + +static int +intlog2 (int n) +{ + int level = 0; + while (n >>= 1) + ++level; + return level; +} + /* A subroutine of is_vfp_type. Given a structure type, return the type code of the first non-structure element. Recurse for structure elements. Return -1 if the structure is in fact empty, i.e. no nested elements. */ @@ -106,7 +119,8 @@ is_hfa0 (const ffi_type *ty) for (i = 0; elements[i]; ++i) { ret = elements[i]->type; - if (ret == FFI_TYPE_STRUCT || ret == FFI_TYPE_COMPLEX) + if (ret == FFI_TYPE_STRUCT || ret == FFI_TYPE_VECTOR + || ret == FFI_TYPE_COMPLEX) { ret = is_hfa0 (elements[i]); if (ret < 0) @@ -118,6 +132,33 @@ is_hfa0 (const ffi_type *ty) return ret; } +/* A subroutine of is_vfp_type. Return the size in bytes of the vector (SIMD) + member of TY, i.e. the width of a single Neon register slot, or 0 if TY + neither is nor contains a vector. For a bare vector this is its whole size; + for a homogeneous vector aggregate it is the size of one lane vector. */ + +static size_t +is_simd (const ffi_type *ty) +{ + ffi_type **elements; + int i; + + if (ty->type == FFI_TYPE_VECTOR) + return ty->size; + + elements = ty->elements; + if (elements != NULL) + for (i = 0; elements[i]; ++i) + { + int t = elements[i]->type; + if (t == FFI_TYPE_STRUCT || t == FFI_TYPE_COMPLEX + || t == FFI_TYPE_VECTOR) + return is_simd (elements[i]); + } + + return 0; +} + /* A subroutine of is_vfp_type. Given a structure type, return true if all of the non-structure elements are the same as CANDIDATE. */ @@ -131,7 +172,8 @@ is_hfa1 (const ffi_type *ty, int candidate) for (i = 0; elements[i]; ++i) { int t = elements[i]->type; - if (t == FFI_TYPE_STRUCT || t == FFI_TYPE_COMPLEX) + if (t == FFI_TYPE_STRUCT || t == FFI_TYPE_VECTOR + || t == FFI_TYPE_COMPLEX) { if (!is_hfa1 (elements[i], candidate)) return 0; @@ -156,7 +198,7 @@ is_vfp_type (const ffi_type *ty) { ffi_type **elements; int candidate, i; - size_t size, ele_count; + size_t size, ele_count, simd_size; /* Quickest tests first. */ candidate = ty->type; @@ -181,18 +223,24 @@ is_vfp_type (const ffi_type *ty) } return 0; case FFI_TYPE_STRUCT: + case FFI_TYPE_VECTOR: break; } - /* No HFA types are smaller than 4 bytes, or larger than 64 bytes. */ + /* No HFA/HVA types are smaller than 4 bytes, or larger than 64 bytes. */ size = ty->size; if (size < 4 || size > 64) return 0; - /* Find the type of the first non-structure member. */ + /* Determine the width of the vector (SIMD) member, if any: 0 for a plain + floating-point HFA, else the size in bytes of one Neon register slot. */ + simd_size = is_simd (ty); + + /* Find the type of the first non-aggregate member. */ elements = ty->elements; candidate = elements[0]->type; - if (candidate == FFI_TYPE_STRUCT || candidate == FFI_TYPE_COMPLEX) + if (candidate == FFI_TYPE_STRUCT || candidate == FFI_TYPE_VECTOR + || candidate == FFI_TYPE_COMPLEX) { for (i = 0; ; ++i) { @@ -202,6 +250,63 @@ is_vfp_type (const ffi_type *ty) } } + if (simd_size) + { + /* Vector or homogeneous vector aggregate (HVA). A single Neon slot is + at most 16 bytes (a Q register). A bare vector wider than 16 bytes + (e.g. a 32-byte double4) has no short-vector register class under + AAPCS64, so bail and let the generic composite path pass it by + reference / return it in memory -- matching what current compilers do. + The scalar lane type does not affect register selection (an integer + and a floating-point 16-byte vector both occupy one Q register), so, + unlike the floating-point HFA path below, CANDIDATE is used only to + confirm the lanes are homogeneous. */ + size_t reg_size = simd_size; + int num_registers; + int first_level_element_type; + + /* A Neon register slot is an S (4B), D (8B) or Q (16B). A lane narrower + than 4 bytes has no short-vector register class under AAPCS64 and would + map below AARCH64_RET_S4, making extend_hfa_type() branch before its + jump table; reject it and let the generic aggregate path handle it. */ + if (reg_size < 4 || reg_size > 16 || size % reg_size != 0) + return 0; + num_registers = (int) (size / reg_size); + if (num_registers > 4) + return 0; + + /* For an aggregate, every member must itself be a vector (or nested + vector aggregate) of the same register width: this rejects a struct + that mixes a bare scalar with a vector even when the scalar's type + matches the vector's lane type. A bare vector needs no such check -- + its lanes were validated when its layout was computed. */ + if (ty->type != FFI_TYPE_VECTOR) + for (i = 0; elements[i]; ++i) + if (is_simd (elements[i]) != reg_size) + return 0; + + /* Every lane must be the identical scalar type across the whole HVA + (this rejects, e.g., an aggregate mixing float and integer vectors). */ + for (i = 0; elements[i]; ++i) + { + int t = elements[i]->type; + if (t == FFI_TYPE_STRUCT || t == FFI_TYPE_VECTOR + || t == FFI_TYPE_COMPLEX) + { + if (!is_hfa1 (elements[i], candidate)) + return 0; + } + else if (t != candidate) + return 0; + } + + /* Reuse the AARCH64_RET_{S,D,Q}* codes, which are laid out as + (type * 4) + (4 - count) with FLOAT->S(4B), DOUBLE->D(8B), + LONGDOUBLE->Q(16B). Map the register width onto that type axis. */ + first_level_element_type = FFI_TYPE_FLOAT + intlog2 ((int) reg_size) - 2; + return first_level_element_type * 4 + (4 - num_registers); + } + /* If the first member is not a floating point type, it's not an HFA. Also quickly re-check the size of the structure. */ switch (candidate) @@ -614,6 +719,7 @@ ffi_prep_cif_machdep (ffi_cif *cif) case FFI_TYPE_DOUBLE: case FFI_TYPE_LONGDOUBLE: case FFI_TYPE_STRUCT: + case FFI_TYPE_VECTOR: case FFI_TYPE_COMPLEX: flags = is_vfp_type (rtype); if (flags == 0) @@ -802,6 +908,7 @@ ffi_call_int (ffi_cif *cif, void (*fn)(void), void *orig_rvalue, case FFI_TYPE_DOUBLE: case FFI_TYPE_LONGDOUBLE: case FFI_TYPE_STRUCT: + case FFI_TYPE_VECTOR: case FFI_TYPE_COMPLEX: { h = is_vfp_type (ty); @@ -1089,6 +1196,7 @@ ffi_closure_SYSV_inner (ffi_cif *cif, case FFI_TYPE_DOUBLE: case FFI_TYPE_LONGDOUBLE: case FFI_TYPE_STRUCT: + case FFI_TYPE_VECTOR: case FFI_TYPE_COMPLEX: h = is_vfp_type (ty); if (h) diff --git a/deps/libffi/src/aarch64/ffitarget.h b/deps/libffi/src/aarch64/ffitarget.h index 46e2687ae7fe..8ba86799e113 100644 --- a/deps/libffi/src/aarch64/ffitarget.h +++ b/deps/libffi/src/aarch64/ffitarget.h @@ -94,6 +94,10 @@ typedef enum ffi_abi #define FFI_TARGET_HAS_COMPLEX_TYPE #endif +/* AAPCS64 passes 8- and 16-byte vectors in V/Q registers and homogeneous + vector aggregates in consecutive V/Q registers; see is_vfp_type. */ +#define FFI_TARGET_HAS_VECTOR_TYPE + #define FFI_TARGET_HAS_INT128 1 #endif diff --git a/deps/libffi/src/debug.c b/deps/libffi/src/debug.c index 63321dc013cc..cf847f3b1107 100644 --- a/deps/libffi/src/debug.c +++ b/deps/libffi/src/debug.c @@ -54,7 +54,8 @@ void ffi_type_test(ffi_type *a, const char *file, int line) FFI_ASSERT_AT(a->type <= FFI_TYPE_LAST, file, line); FFI_ASSERT_AT(a->type == FFI_TYPE_VOID || a->size > 0, file, line); FFI_ASSERT_AT(a->type == FFI_TYPE_VOID || a->alignment > 0, file, line); - FFI_ASSERT_AT((a->type != FFI_TYPE_STRUCT && a->type != FFI_TYPE_COMPLEX) + FFI_ASSERT_AT((a->type != FFI_TYPE_STRUCT && a->type != FFI_TYPE_COMPLEX + && a->type != FFI_TYPE_VECTOR) || a->elements != NULL, file, line); FFI_ASSERT_AT(a->type != FFI_TYPE_COMPLEX || (a->elements != NULL diff --git a/deps/libffi/src/ia64/ia64_flags.h b/deps/libffi/src/ia64/ia64_flags.h index 9d652cef14ce..bfe102c7d86f 100644 --- a/deps/libffi/src/ia64/ia64_flags.h +++ b/deps/libffi/src/ia64/ia64_flags.h @@ -38,3 +38,14 @@ #define FFI_IA64_TYPE_HFA_FLOAT (FFI_TYPE_LAST + 2) #define FFI_IA64_TYPE_HFA_DOUBLE (FFI_TYPE_LAST + 3) #define FFI_IA64_TYPE_HFA_LDOUBLE (FFI_TYPE_LAST + 4) + +/* Tripwire: the .Lst_table / .Lld_table return-value jump tables in unix.S place + the FFI_IA64_TYPE_* pseudo-types (which are FFI_TYPE_LAST-relative) immediately + after the generic FFI_TYPE_* codes. Adding a new generic type bumps + FFI_TYPE_LAST, shifts those codes, and desyncs the tables -- silently + misdispatching small-struct/HFA returns. When this fires: add a matching slot + for the new type to both tables in unix.S, then bump FFI_IA64_TYPE_LAST. */ +#define FFI_IA64_TYPE_LAST FFI_TYPE_VECTOR +#if FFI_TYPE_LAST != FFI_IA64_TYPE_LAST +# error "new FFI_TYPE_* added: sync the unix.S jump tables and bump FFI_IA64_TYPE_LAST" +#endif diff --git a/deps/libffi/src/ia64/unix.S b/deps/libffi/src/ia64/unix.S index 04908368c3e2..b8e347169e2e 100644 --- a/deps/libffi/src/ia64/unix.S +++ b/deps/libffi/src/ia64/unix.S @@ -553,6 +553,9 @@ ffi_closure_unix: data8 @pcrel(.Lst_void) // FFI_TYPE_STRUCT data8 @pcrel(.Lst_int64) // FFI_TYPE_POINTER data8 @pcrel(.Lst_void) // FFI_TYPE_COMPLEX (not implemented) + data8 @pcrel(.Lst_void) // FFI_TYPE_UINT128 (not implemented) + data8 @pcrel(.Lst_void) // FFI_TYPE_SINT128 (not implemented) + data8 @pcrel(.Lst_void) // FFI_TYPE_VECTOR (rejected in ffi_prep_cif_core) data8 @pcrel(.Lst_small_struct) // FFI_IA64_TYPE_SMALL_STRUCT data8 @pcrel(.Lst_hfa_float) // FFI_IA64_TYPE_HFA_FLOAT data8 @pcrel(.Lst_hfa_double) // FFI_IA64_TYPE_HFA_DOUBLE @@ -575,6 +578,9 @@ ffi_closure_unix: data8 @pcrel(.Lld_void) // FFI_TYPE_STRUCT data8 @pcrel(.Lld_int) // FFI_TYPE_POINTER data8 @pcrel(.Lld_void) // FFI_TYPE_COMPLEX (not implemented) + data8 @pcrel(.Lld_void) // FFI_TYPE_UINT128 (not implemented) + data8 @pcrel(.Lld_void) // FFI_TYPE_SINT128 (not implemented) + data8 @pcrel(.Lld_void) // FFI_TYPE_VECTOR (rejected in ffi_prep_cif_core) data8 @pcrel(.Lld_small_struct) // FFI_IA64_TYPE_SMALL_STRUCT data8 @pcrel(.Lld_hfa_float) // FFI_IA64_TYPE_HFA_FLOAT data8 @pcrel(.Lld_hfa_double) // FFI_IA64_TYPE_HFA_DOUBLE diff --git a/deps/libffi/src/java_raw_api.c b/deps/libffi/src/java_raw_api.c index 114d3e47fcde..e0a02ef27432 100644 --- a/deps/libffi/src/java_raw_api.c +++ b/deps/libffi/src/java_raw_api.c @@ -58,7 +58,8 @@ ffi_java_raw_size (ffi_cif *cif) result += 2 * FFI_SIZEOF_JAVA_RAW; break; case FFI_TYPE_STRUCT: - /* No structure parameters in Java. */ + case FFI_TYPE_VECTOR: + /* No structure or vector parameters in Java. */ abort(); case FFI_TYPE_COMPLEX: /* Not supported yet. */ diff --git a/deps/libffi/src/pa/ffitarget.h b/deps/libffi/src/pa/ffitarget.h index f6f09975cfac..aeaacc167ef2 100644 --- a/deps/libffi/src/pa/ffitarget.h +++ b/deps/libffi/src/pa/ffitarget.h @@ -89,8 +89,13 @@ typedef enum ffi_abi { to the default case and is mapped to FFI_TYPE_INT, so cif->flags never exceeds FFI_TYPE_COMPLEX and the existing tables remain sufficient. Bump FFI_PA_TYPE_LAST to the current FFI_TYPE_LAST once you have confirmed any - newly added generic type is likewise handled (or the tables extended). */ -#define FFI_PA_TYPE_LAST FFI_TYPE_SINT128 + newly added generic type is likewise handled (or the tables extended). + + FFI_TYPE_VECTOR (18) is likewise not reached here: PA does not define + FFI_TARGET_HAS_VECTOR_TYPE, so ffi_prep_cif_core rejects any vector + signature with FFI_BAD_TYPEDEF before machdep runs. Bumping the tripwire + past it is therefore safe. */ +#define FFI_PA_TYPE_LAST FFI_TYPE_VECTOR /* Tripwire: when a new generic type is added FFI_TYPE_LAST changes and this fires, forcing a review of ffi_prep_cif_machdep and the linux.S / hpux32.S diff --git a/deps/libffi/src/powerpc/darwin_closure.S b/deps/libffi/src/powerpc/darwin_closure.S index 3121e6ac26d3..08cbe4bc1389 100644 --- a/deps/libffi/src/powerpc/darwin_closure.S +++ b/deps/libffi/src/powerpc/darwin_closure.S @@ -186,19 +186,17 @@ LCFI1: /* Make the call. */ bl BLCLS_HELP - /* r3 contains the rtype pointer... save it since we will need - it later. */ - sg r3,LINKAGE_SIZE(r1) ; ffi_type * result_type - lg r0,0(r3) ; size => r0 - lhz r3,FFI_TYPE_TYPE(r3) ; type => r3 - - /* The helper will have intercepted structure returns and inserted - the caller`s destination address for structs returned by ref. */ - - /* r3 contains the return type so use it to look up in a table - so we know how to deal with each type. */ - - addi r5,r1,(SAVE_SIZE-RESULT_BYTES) /* Otherwise, our return is here. */ + /* r3 now holds a small PPC_LD_* jump-table index (see the PPC_LD_* + defines in ffi_darwin.c), not an ffi_type* as this file previously + assumed: ffi_closure_helper_common cannot return both an ffi_type* + and the dispatch index through r3, so it returns the index. The + helper has already intercepted by-reference struct returns (writing + the result to the caller`s buffer and returning PPC_LD_NONE); for a + by-value struct return it returns PPC_LD_STRUCT and stashes cif->rtype + in the first parameter-save slot, which the PPC_LD_STRUCT fragment + below recovers. */ + + addi r5,r1,(SAVE_SIZE-RESULT_BYTES) /* Our return value is here. */ bl Lget_ret_type0_addr /* Get pointer to Lret_type0 into LR. */ mflr r4 /* Move to r4. */ slwi r3,r3,4 /* Now multiply return type by 16. */ @@ -218,43 +216,60 @@ LFE1: Lget_ret_type0_addr: blrl -/* case FFI_TYPE_VOID */ +/* The fragments below are indexed by the PPC_LD_* return code that + ffi_closure_helper_common handed back in r3, so their order must match the + PPC_LD_* values in ffi_darwin.c. Each is exactly 16 bytes (four + instructions), except the final PPC_LD_STRUCT fragment. */ + +/* case PPC_LD_NONE (void, or a struct returned by reference) */ Lret_type0: b Lfinish nop nop nop -/* case FFI_TYPE_INT */ +/* case PPC_LD_R3 (one GPR: int, pointer, and on ppc64 also 64-bit ints) */ Lret_type1: lg r3,0(r5) b Lfinish nop nop -/* case FFI_TYPE_FLOAT */ +/* case PPC_LD_R3R4 (two GPRs: the 32-bit ABI`s 64-bit integer) */ Lret_type2: +#if defined(__ppc64__) + lg r3,0(r5) + lg r4,8(r5) +#else + lwz r3,0(r5) + lwz r4,4(r5) +#endif + b Lfinish + nop + +/* case PPC_LD_F32 */ +Lret_type3: lfs f1,0(r5) b Lfinish nop nop -/* case FFI_TYPE_DOUBLE */ -Lret_type3: +/* case PPC_LD_F64 */ +Lret_type4: lfd f1,0(r5) b Lfinish nop nop -/* case FFI_TYPE_LONGDOUBLE */ -Lret_type4: +/* case PPC_LD_F128 (128-bit long double: two doubles) */ +Lret_type5: lfd f1,0(r5) lfd f2,8(r5) b Lfinish nop -/* case FFI_TYPE_UINT8 */ -Lret_type5: +/* case PPC_LD_U8 */ +Lret_type6: #if defined(__ppc64__) lbz r3,7(r5) #else @@ -264,8 +279,8 @@ Lret_type5: nop nop -/* case FFI_TYPE_SINT8 */ -Lret_type6: +/* case PPC_LD_S8 */ +Lret_type7: #if defined(__ppc64__) lbz r3,7(r5) #else @@ -275,8 +290,8 @@ Lret_type6: b Lfinish nop -/* case FFI_TYPE_UINT16 */ -Lret_type7: +/* case PPC_LD_U16 */ +Lret_type8: #if defined(__ppc64__) lhz r3,6(r5) #else @@ -286,8 +301,8 @@ Lret_type7: nop nop -/* case FFI_TYPE_SINT16 */ -Lret_type8: +/* case PPC_LD_S16 */ +Lret_type9: #if defined(__ppc64__) lha r3,6(r5) #else @@ -297,77 +312,43 @@ Lret_type8: nop nop -/* case FFI_TYPE_UINT32 */ -Lret_type9: #if defined(__ppc64__) - lwz r3,4(r5) -#else - lwz r3,0(r5) -#endif - b Lfinish - nop - nop - -/* case FFI_TYPE_SINT32 */ +/* case PPC_LD_U32 (ppc64 only; the 32-bit ABI aliases U32 to PPC_LD_R3) */ Lret_type10: -#if defined(__ppc64__) lwz r3,4(r5) -#else - lwz r3,0(r5) -#endif b Lfinish nop nop -/* case FFI_TYPE_UINT64 */ +/* case PPC_LD_S32 (ppc64 only; the 32-bit ABI aliases S32 to PPC_LD_R3) */ Lret_type11: -#if defined(__ppc64__) - lg r3,0(r5) - b Lfinish - nop -#else - lwz r3,0(r5) - lwz r4,4(r5) + lwa r3,4(r5) b Lfinish -#endif nop - -/* case FFI_TYPE_SINT64 */ -Lret_type12: -#if defined(__ppc64__) - lg r3,0(r5) - b Lfinish nop -#else - lwz r3,0(r5) - lwz r4,4(r5) - b Lfinish #endif - nop -/* case FFI_TYPE_STRUCT */ -Lret_type13: +/* case PPC_LD_STRUCT (a by-value struct return). This is the final, + variable-length fragment, so it need not be padded to 16 bytes. The helper + stashed cif->rtype in the first parameter-save slot (see ffi_darwin.c), + because the small dispatch index in r3 left no room for it. */ +Lret_type_struct: + lg r6,PARENT_PARM_BASE(r1) ; cif->rtype + sg r6,LINKAGE_SIZE(r1) ; where the struct code below expects it + lg r0,0(r6) ; size => r0 #if defined(__ppc64__) lg r3,0(r5) ; we need at least this... cmpi 0,r0,4 bgt Lstructend ; not a special small case b Lsmallstruct ; see if we need more. #else - cmpwi 0,r0,4 - bgt Lfinish ; not by value - lg r3,0(r5) + lg r3,0(r5) ; a <=4-byte struct, returned in r3 b Lfinish #endif -/* case FFI_TYPE_POINTER */ -Lret_type14: - lg r3,0(r5) - b Lfinish - nop - nop #if defined(__ppc64__) Lsmallstruct: - beq Lfour ; continuation of Lret13. + beq Lfour ; continuation of Lret_type_struct. cmpi 0,r0,3 beq Lfinish ; don`t adjust this - can`t be any floats here... srdi r3,r3,48 diff --git a/deps/libffi/src/powerpc/ffi_darwin.c b/deps/libffi/src/powerpc/ffi_darwin.c index 01e2a43701d7..64449c38e156 100644 --- a/deps/libffi/src/powerpc/ffi_darwin.c +++ b/deps/libffi/src/powerpc/ffi_darwin.c @@ -60,11 +60,13 @@ struct ffi_aix_trampoline_struct { # define PPC_LD_S32 PPC_LD_R3 # define PPC_LD_PTR PPC_LD_R3 # define PPC_LD_I64 PPC_LD_R3R4 +# define PPC_LD_STRUCT 10 #else # define PPC_LD_U32 10 # define PPC_LD_S32 11 # define PPC_LD_PTR PPC_LD_R3 # define PPC_LD_I64 PPC_LD_R3 +# define PPC_LD_STRUCT 12 #endif extern void ffi_closure_ASM (void); @@ -1260,6 +1262,13 @@ ffi_closure_helper_common (ffi_cif* cif, long i, avn; ffi_dblfl * end_pfr = pfr + NUM_FPR_ARG_REGISTERS; unsigned size_al; + int struct_ret_by_value = 0; + /* When a struct is returned by value, ffi_closure_ASM's jump-table + dispatch carries only a small integer return code (see PPC_LD_* above), + with no room for cif->rtype. We hand cif->rtype back in the first + parameter-save slot -- which is dead by the time we return -- for the + PPC_LD_STRUCT fragment in darwin_closure.S to recover. */ + unsigned long * pgr0 = pgr; #if defined(POWERPC_DARWIN64) unsigned fpsused = 0; #endif @@ -1275,12 +1284,16 @@ ffi_closure_helper_common (ffi_cif* cif, rvalue = (void *) *pgr; pgr++; } + else + struct_ret_by_value = 1; #elif defined(DARWIN_PPC) if (cif->rtype->size > 4) { rvalue = (void *) *pgr; pgr++; } + else + struct_ret_by_value = 1; #else /* assume we return by ref. */ rvalue = (void *) *pgr; pgr++; @@ -1480,7 +1493,17 @@ ffi_closure_helper_common (ffi_cif* cif, switch (cif->rtype->type) { case FFI_TYPE_VOID: + return PPC_LD_NONE; case FFI_TYPE_STRUCT: + /* A by-reference struct return needs nothing further here: the result + was written straight to the caller's buffer. A by-value struct + return is loaded into registers by darwin_closure.S, which needs + cif->rtype -- hand it back in the first parameter-save slot. */ + if (struct_ret_by_value) + { + *pgr0 = (unsigned long) cif->rtype; + return PPC_LD_STRUCT; + } return PPC_LD_NONE; case FFI_TYPE_FLOAT: return PPC_LD_F32; diff --git a/deps/libffi/src/powerpc/ffi_linux64.c b/deps/libffi/src/powerpc/ffi_linux64.c index b1f1468ed5f8..e92f88c46973 100644 --- a/deps/libffi/src/powerpc/ffi_linux64.c +++ b/deps/libffi/src/powerpc/ffi_linux64.c @@ -107,8 +107,13 @@ discover_homogeneous_aggregate (ffi_abi abi, unsigned int inner_elnum = 0; unsigned int inner = discover_homogeneous_aggregate (abi, t->elements[0], &inner_elnum); - if (inner == FFI_TYPE_FLOAT || inner == FFI_TYPE_DOUBLE) + if (inner == FFI_TYPE_FLOAT || inner == FFI_TYPE_DOUBLE + || inner == FFI_TYPE_LONGDOUBLE) { + /* A _Complex of an FP base counts as two of that base: an + FP-HFA struct member. For IBM-128 long double each half is + itself two FPRs (inner_elnum == 2), so a _Complex long double + contributes four FPRs. */ *elnum = 2 * inner_elnum; return inner; } @@ -257,11 +262,17 @@ ffi_prep_cif_linux64_core (ffi_cif *cif) goto homogeneous; #if FFI_TYPE_LONGDOUBLE != FFI_TYPE_DOUBLE case FFI_TYPE_LONGDOUBLE: - /* Only the 64-bit long double case is wired up; IBM-128 and - IEEE-binary128 _Complex are left as a follow-up. */ - if ((cif->abi & (FFI_LINUX_LONG_DOUBLE_128 - | FFI_LINUX_LONG_DOUBLE_IEEE128)) != 0) - return FFI_BAD_TYPEDEF; + if ((cif->abi & FFI_LINUX_LONG_DOUBLE_IEEE128) != 0) + { + /* IEEE-128 _Complex long double: real in v2, imag in v3. + Return via the vector-homogeneous small-struct path. */ + flags |= FLAG_RETURNS_SMST | FLAG_RETURNS_VEC; + break; + } + /* IBM-128 _Complex long double is returned like a homogeneous + aggregate of doubles: real in f1:f2, imag in f3:f4. (For a + 64-bit long double this reduces to the FFI_TYPE_DOUBLE case, + real in f1 and imag in f2.) */ flags |= FLAG_RETURNS_SMST; rtype = FFI_TYPE_DOUBLE; goto homogeneous; @@ -393,11 +404,21 @@ ffi_prep_cif_linux64_core (ffi_cif *cif) break; #if FFI_TYPE_LONGDOUBLE != FFI_TYPE_DOUBLE case FFI_TYPE_LONGDOUBLE: - if ((cif->abi & (FFI_LINUX_LONG_DOUBLE_128 - | FFI_LINUX_LONG_DOUBLE_IEEE128)) != 0) - return FFI_BAD_TYPEDEF; - fparg_count += 2; - intarg_count += 2; + if ((cif->abi & FFI_LINUX_LONG_DOUBLE_IEEE128) != 0) + { + /* Two IEEE-128 halves: each occupies a vector register plus + two GPR shadow doublewords, the pair 16-byte aligned. */ + vecarg_count += 2; + intarg_count = (intarg_count + 1) & ~0x1; + intarg_count += 4; + if (vecarg_count > NUM_VEC_ARG_REGISTERS64) + flags |= FLAG_ARG_NEEDS_PSAVE; + break; + } + /* IBM-128: each half is a pair of FPRs, and each FPR half + consumes a GPR shadow doubleword -- four of each in total. */ + fparg_count += 4; + intarg_count += 4; if (fparg_count > NUM_FPR_ARG_REGISTERS64) flags |= FLAG_ARG_NEEDS_PSAVE; break; @@ -755,10 +776,51 @@ ffi_prep_args64 (extended_cif *ecif, unsigned long *const stack) case FFI_TYPE_COMPLEX: elt = (*ptr)->elements[0]->type; #if FFI_TYPE_LONGDOUBLE != FFI_TYPE_DOUBLE - /* 64-bit long double is equivalent to double; the IBM-128 and - IEEE-binary128 variants were rejected in prep_cif. */ + if (elt == FFI_TYPE_LONGDOUBLE + && (ecif->cif->abi & FFI_LINUX_LONG_DOUBLE_IEEE128) != 0) + { + /* IEEE-128 _Complex long double: each half goes in its own + vector register (or the parameter save area), 16-byte + aligned, consuming two GPR shadow doublewords. */ + float128 *cval = (float128 *) *p_argv.v; + unsigned int j; + for (j = 0; j < 2; j++) + { + next_arg.p = FFI_ALIGN (next_arg.p, 16); + if (next_arg.ul == gpr_end.ul) + next_arg.ul = rest.ul; + if (vecarg_count < NUM_VEC_ARG_REGISTERS64 && i < nfixedargs) + memcpy (vec_base.f128++, cval + j, sizeof (float128)); + else + memcpy (next_arg.f128, cval + j, sizeof (float128)); + if (++next_arg.f128 == gpr_end.f128) + next_arg.f128 = rest.f128; + vecarg_count++; + } + FFI_ASSERT (flags & FLAG_VEC_ARGUMENTS); + break; + } if (elt == FFI_TYPE_LONGDOUBLE) - elt = FFI_TYPE_DOUBLE; + { + /* IBM-128 _Complex long double: four doubles (real hi/lo, + imag hi/lo) into consecutive FPRs, each with a GPR shadow + doubleword. */ + double *cval = (double *) *p_argv.v; + unsigned int j; + for (j = 0; j < 4; j++) + { + double_tmp = cval[j]; + if (fparg_count < NUM_FPR_ARG_REGISTERS64 && i < nfixedargs) + *fpr_base.d++ = double_tmp; + else + *next_arg.d = double_tmp; + if (++next_arg.ul == gpr_end.ul) + next_arg.ul = rest.ul; + fparg_count++; + } + FFI_ASSERT (flags & FLAG_FP_ARGUMENTS); + break; + } #endif if (elt == FFI_TYPE_FLOAT) { @@ -1336,8 +1398,45 @@ ffi_closure_helper_LINUX64 (ffi_cif *cif, unsigned int j; elt = arg_types[i]->elements[0]->type; #if FFI_TYPE_LONGDOUBLE != FFI_TYPE_DOUBLE + if (elt == FFI_TYPE_LONGDOUBLE + && (cif->abi & FFI_LINUX_LONG_DOUBLE_IEEE128) != 0) + { + /* IEEE-128: each half arrives in a vector register (or the + 16-byte-aligned parameter save area) with two GPR shadow + doublewords. */ + float128 *cval = alloca (2 * sizeof (float128)); + if (((unsigned long) pst & 0xF) != 0) + ++pst; + for (j = 0; j < 2; j++) + { + if (pvec < end_pvec && i < nfixedargs) + memcpy (&cval[j], pvec++, sizeof (float128)); + else + memcpy (&cval[j], pst, sizeof (float128)); + pst += 2; + } + avalue[i] = cval; + break; + } if (elt == FFI_TYPE_LONGDOUBLE) - elt = FFI_TYPE_DOUBLE; + { + /* IBM-128: four doubles, each in an FPR (or one GPR shadow + doubleword) -- real hi/lo then imag hi/lo. */ + double *cval = alloca (4 * sizeof (double)); + for (j = 0; j < 4; j++) + { + if (pfr < end_pfr && i < nfixedargs) + { + cval[j] = pfr->d; + pfr++; + } + else + cval[j] = *(double *) pst; + pst++; + } + avalue[i] = cval; + break; + } #endif if (elt == FFI_TYPE_FLOAT) { @@ -1448,7 +1547,13 @@ ffi_closure_helper_LINUX64 (ffi_cif *cif, int inner = cif->rtype->elements[0]->type; #if FFI_TYPE_LONGDOUBLE != FFI_TYPE_DOUBLE if (inner == FFI_TYPE_LONGDOUBLE) - inner = FFI_TYPE_DOUBLE; + { + /* IEEE-128 _Complex long double returns in v2:v3; IBM-128 in + f1:f2 (real) and f3:f4 (imag), i.e. as a double HFA. */ + if ((cif->abi & FFI_LINUX_LONG_DOUBLE_IEEE128) != 0) + return PPC64_LD_VECTOR_HOMOG; + inner = FFI_TYPE_DOUBLE; + } #endif if (inner == FFI_TYPE_FLOAT) return PPC64_LD_FLOAT_HOMOG; diff --git a/deps/libffi/src/powerpc/linux64_closure.S b/deps/libffi/src/powerpc/linux64_closure.S index 405b2cfc47a1..3071bec0d22a 100644 --- a/deps/libffi/src/powerpc/linux64_closure.S +++ b/deps/libffi/src/powerpc/linux64_closure.S @@ -345,6 +345,21 @@ E PPC64_LD_STRUCT_3 lwz %r3, RETVAL+4(%r1) srd %r3, %r3, 8 epilogue + +E PPC64_LD_STRUCT_5 + ld %r3, RETVAL+0(%r1) + srdi %r3, %r3, 24 + epilogue + +E PPC64_LD_STRUCT_6 + ld %r3, RETVAL+0(%r1) + srdi %r3, %r3, 16 + epilogue + +E PPC64_LD_STRUCT_7 + ld %r3, RETVAL+0(%r1) + srdi %r3, %r3, 8 + epilogue #endif .Lmoredouble: diff --git a/deps/libffi/src/prep_cif.c b/deps/libffi/src/prep_cif.c index 1836270d1a9c..8a448ebbf81f 100644 --- a/deps/libffi/src/prep_cif.c +++ b/deps/libffi/src/prep_cif.c @@ -32,6 +32,72 @@ #define STACK_ARG_SIZE(x) FFI_ALIGN(x, FFI_SIZEOF_ARG) +/* Compute the machine-independent layout of a vector (SIMD) type. + + A vector is described exactly like a struct -- arg->elements is a + NULL-terminated array of pointers -- but every element must point to the + SAME fundamental scalar type, and the count is the number of lanes. The + caller leaves arg->size and arg->alignment as zero; libffi derives them: + + size = lane_size * lane_count, rounded UP to the next power of two + (matching Clang's ext_vector_type storage, e.g. 3 x float + -> 16; GCC's vector_size already requires power-of-two totals + so the rule is identical there); + alignment = min(size, 16). + + Only float, double and the fixed-width integer scalars (UINT8..SINT64) are + valid lane types. Anything else -- a heterogeneous element list, an + aggregate lane, long double, or a zero-length vector -- is FFI_BAD_TYPEDEF. */ + +static ffi_status +initialize_vector (ffi_type *arg) +{ + ffi_type **ptr = arg->elements; + ffi_type *elem; + size_t count = 0; + size_t total, p2; + + if (UNLIKELY (ptr == NULL || *ptr == NULL)) + return FFI_BAD_TYPEDEF; + + elem = *ptr; + switch (elem->type) + { + case FFI_TYPE_FLOAT: + case FFI_TYPE_DOUBLE: + case FFI_TYPE_UINT8: + case FFI_TYPE_SINT8: + case FFI_TYPE_UINT16: + case FFI_TYPE_SINT16: + case FFI_TYPE_UINT32: + case FFI_TYPE_SINT32: + case FFI_TYPE_UINT64: + case FFI_TYPE_SINT64: + break; + default: + return FFI_BAD_TYPEDEF; + } + + /* Every lane must be the identical scalar type. */ + for (; *ptr != NULL; ptr++) + { + if ((*ptr)->type != elem->type || (*ptr)->size != elem->size) + return FFI_BAD_TYPEDEF; + count++; + } + + if (UNLIKELY (count < 1 || elem->size == 0)) + return FFI_BAD_TYPEDEF; + + total = elem->size * count; + for (p2 = 1; p2 < total; p2 <<= 1) + ; + + arg->size = p2; + arg->alignment = p2 < 16 ? p2 : 16; + return FFI_OK; +} + /* Perform machine independent initialization of aggregate type specifications. */ @@ -42,6 +108,9 @@ static ffi_status initialize_aggregate(ffi_type *arg, size_t *offsets) if (UNLIKELY(arg == NULL || arg->elements == NULL)) return FFI_BAD_TYPEDEF; + if (arg->type == FFI_TYPE_VECTOR) + return initialize_vector (arg); + arg->size = 0; arg->alignment = 0; @@ -92,6 +161,28 @@ static ffi_status initialize_aggregate(ffi_type *arg, size_t *offsets) return FFI_OK; } +#ifndef FFI_TARGET_HAS_VECTOR_TYPE +/* Recursively test whether TY is, or contains, a vector (SIMD) type. Ports + that do not define FFI_TARGET_HAS_VECTOR_TYPE cannot marshal vectors, so + ffi_prep_cif_core rejects any signature that mentions one (directly or + nested inside a struct) with FFI_BAD_TYPEDEF rather than aborting. */ +static int +ffi_type_contains_vector (ffi_type *ty) +{ + ffi_type **p; + + if (ty == NULL) + return 0; + if (ty->type == FFI_TYPE_VECTOR) + return 1; + if (ty->type == FFI_TYPE_STRUCT && ty->elements != NULL) + for (p = ty->elements; *p != NULL; p++) + if (ffi_type_contains_vector (*p)) + return 1; + return 0; +} +#endif /* !FFI_TARGET_HAS_VECTOR_TYPE */ + #ifndef __CRIS__ /* The CRIS ABI specifies structure elements to have byte alignment only, so it completely overrides this functions, @@ -129,6 +220,15 @@ ffi_status FFI_HIDDEN ffi_prep_cif_core(ffi_cif *cif, ffi_abi abi, cif->nargs = ntotalargs; cif->rtype = rtype; +#ifndef FFI_TARGET_HAS_VECTOR_TYPE + /* Vector (SIMD) types are only marshalled on ports that opt in. */ + if (ffi_type_contains_vector (rtype)) + return FFI_BAD_TYPEDEF; + for (i = 0; i < ntotalargs; i++) + if (ffi_type_contains_vector (atypes[i])) + return FFI_BAD_TYPEDEF; +#endif + cif->flags = 0; #if (defined(_M_ARM64) || defined(__aarch64__)) && defined(_WIN32) cif->is_variadic = isvariadic; @@ -152,7 +252,8 @@ ffi_status FFI_HIDDEN ffi_prep_cif_core(ffi_cif *cif, ffi_abi abi, /* x86, x86-64 and s390 stack space allocation is handled in prep_machdep. */ #if !defined FFI_TARGET_SPECIFIC_STACK_SPACE_ALLOCATION /* Make space for the return structure pointer */ - if (cif->rtype->type == FFI_TYPE_STRUCT + if ((cif->rtype->type == FFI_TYPE_STRUCT + || cif->rtype->type == FFI_TYPE_VECTOR) #ifdef TILE && (cif->rtype->size > 10 * FFI_SIZEOF_ARG) #endif @@ -316,4 +417,11 @@ ffi_call_plan_free (ffi_call_plan *plan) free (plan); } +size_t +ffi_call_plan_size (ffi_call_plan *plan) +{ + /* The generic plan is a bare handle; there is no separate move-list. */ + return plan != NULL ? sizeof (struct ffi_call_plan) : 0; +} + #endif /* generic ffi_call_plan fallback */ diff --git a/deps/libffi/src/raw_api.c b/deps/libffi/src/raw_api.c index be156116cb0d..670d56d948a3 100644 --- a/deps/libffi/src/raw_api.c +++ b/deps/libffi/src/raw_api.c @@ -42,7 +42,7 @@ ffi_raw_size (ffi_cif *cif) for (i = cif->nargs-1; i >= 0; i--, at++) { #if !FFI_NO_STRUCTS - if ((*at)->type == FFI_TYPE_STRUCT) + if ((*at)->type == FFI_TYPE_STRUCT || (*at)->type == FFI_TYPE_VECTOR) result += FFI_ALIGN (sizeof (void*), FFI_SIZEOF_ARG); else #endif @@ -82,8 +82,9 @@ ffi_raw_to_ptrarray (ffi_cif *cif, ffi_raw *raw, void **args) break; #endif -#if !FFI_NO_STRUCTS +#if !FFI_NO_STRUCTS case FFI_TYPE_STRUCT: + case FFI_TYPE_VECTOR: *args = (raw++)->ptr; break; #endif @@ -110,7 +111,7 @@ ffi_raw_to_ptrarray (ffi_cif *cif, ffi_raw *raw, void **args) for (i = 0; i < cif->nargs; i++, tp++, args++) { #if !FFI_NO_STRUCTS - if ((*tp)->type == FFI_TYPE_STRUCT) + if ((*tp)->type == FFI_TYPE_STRUCT || (*tp)->type == FFI_TYPE_VECTOR) { *args = (raw++)->ptr; } @@ -172,6 +173,7 @@ ffi_ptrarray_to_raw (ffi_cif *cif, void **args, ffi_raw *raw) #if !FFI_NO_STRUCTS case FFI_TYPE_STRUCT: + case FFI_TYPE_VECTOR: (raw++)->ptr = *args; break; #endif diff --git a/deps/libffi/src/tramp.c b/deps/libffi/src/tramp.c index 525f81547156..a04188858af6 100644 --- a/deps/libffi/src/tramp.c +++ b/deps/libffi/src/tramp.c @@ -417,9 +417,19 @@ ffi_tramp_init (void) &tramp_globals.map_size); tramp_globals.ntramp = tramp_globals.map_size / tramp_globals.size; + /* + * The trampoline code table is a single, fixed-size mapping. If the + * system page size is larger than that mapping, the static trampoline + * mechanism cannot be used. Both values are invariant for the life of + * the process, so cache the FAILED verdict rather than re-running the + * whole initialization on every allocation. + */ page_size = sysconf (_SC_PAGESIZE); if (page_size >= 0 && (size_t)page_size > tramp_globals.map_size) - return 0; + { + tramp_globals.status = TRAMP_GLOBALS_FAILED; + return 0; + } if (ffi_tramp_init_os ()) { diff --git a/deps/libffi/src/x86/ffi.c b/deps/libffi/src/x86/ffi.c index 27f17b0c8849..a953891362d5 100644 --- a/deps/libffi/src/x86/ffi.c +++ b/deps/libffi/src/x86/ffi.c @@ -118,7 +118,7 @@ ffi_prep_cif_machdep(ffi_cif *cif) break; case FFI_TYPE_STRUCT: { -#if defined(X86_WIN32) || defined(X86_DARWIN) +#if defined(X86_WIN32) || defined(X86_DARWIN) || defined(X86_FREEBSD) size_t size = cif->rtype->size; if (size == 1) flags = X86_RET_STRUCT_1B; diff --git a/deps/libffi/src/x86/ffi64.c b/deps/libffi/src/x86/ffi64.c index c24db38c4364..c2c78f3fbbfb 100644 --- a/deps/libffi/src/x86/ffi64.c +++ b/deps/libffi/src/x86/ffi64.c @@ -330,6 +330,25 @@ classify_argument (ffi_type *type, enum x86_64_reg_class classes[], } return words; } + case FFI_TYPE_VECTOR: + /* A Short Vector occupies SSE registers: an 8-byte vector is a single + SSE eightbyte; a 16-byte vector is one %xmm register (SSE + SSEUP). + Wider vectors would need %ymm/%zmm handling this port does not + implement; classify them as memory here and reject them outright in + ffi_prep_cif_machdep so the caller gets FFI_BAD_TYPEDEF, not a + silently wrong in-memory pass. */ + if (type->size == 8) + { + classes[0] = X86_64_SSE_CLASS; + return 1; + } + else if (type->size == 16) + { + classes[0] = X86_64_SSE_CLASS; + classes[1] = X86_64_SSEUP_CLASS; + return 2; + } + return 0; case FFI_TYPE_COMPLEX: { ffi_type *inner = type->elements[0]; @@ -533,6 +552,16 @@ ffi_prep_cif_machdep (ffi_cif *cif) } } break; + case FFI_TYPE_VECTOR: + /* An 8-byte vector returns in the low half of %xmm0; a 16-byte vector + fills %xmm0 (SSE + SSEUP). Wider vectors are unsupported here. */ + if (rtype_size == 8) + flags = UNIX64_RET_XMM64; + else if (rtype_size == 16) + flags = UNIX64_RET_XMM128; + else + return FFI_BAD_TYPEDEF; + break; case FFI_TYPE_COMPLEX: switch (rtype->elements[0]->type) { @@ -577,6 +606,15 @@ ffi_prep_cif_machdep (ffi_cif *cif) return FFI_BAD_TYPEDEF; } + /* Reject vectors wider than 16 bytes as arguments: correct %ymm/%zmm + passing needs unix64.S register-save changes that are out of scope for + this port, and classify_argument would otherwise silently treat them as + an in-memory aggregate. */ + for (i = 0, avn = cif->nargs; i < avn; i++) + if (cif->arg_types[i]->type == FFI_TYPE_VECTOR + && cif->arg_types[i]->size > 16) + return FFI_BAD_TYPEDEF; + /* Go over all arguments and determine the way they should be passed. If it's in a register and there is space for it, let that be so. If not, add it's size to the stack byte count. */ @@ -782,6 +820,7 @@ typedef struct unsigned fast; /* nonzero -> lean trampoline eligible */ unsigned retcode; /* UNIX64_RET_* (low byte of flags) for the store */ int thunk_n; /* >=0 -> ffi_gp_thunks[thunk_n], else -1 */ + unsigned alloc_bytes; /* malloc'd size, reported by ffi_call_plan_size */ ffi_move moves[]; } ffi_plan; @@ -828,7 +867,7 @@ build_plan (ffi_cif *cif) unsigned i, avn = cif->nargs; enum x86_64_reg_class classes[MAX_CLASSES]; unsigned nm, gprcount, ssecount; - size_t argp_off; + size_t argp_off, nbytes; ffi_plan *plan; int all_gp64 = 1; /* every arg is exactly one 64-bit GP move? */ @@ -848,9 +887,11 @@ build_plan (ffi_cif *cif) } /* One self-contained allocation: header + moves, released with plain free(). */ - plan = malloc (sizeof (ffi_plan) + sizeof (ffi_move) * (2 * avn + 1)); + nbytes = sizeof (ffi_plan) + sizeof (ffi_move) * (2 * avn + 1); + plan = malloc (nbytes); if (plan == NULL) return NULL; + plan->alloc_bytes = (unsigned) nbytes; nm = gprcount = ssecount = 0; argp_off = 0; @@ -1070,6 +1111,17 @@ ffi_call_plan_free (ffi_call_plan *plan) } } +size_t +ffi_call_plan_size (ffi_call_plan *plan) +{ + if (plan == NULL) + return 0; + /* The move-list carries its own size; a signature with no fast path owns + nothing beyond the handle. */ + return sizeof (struct ffi_call_plan) + + (plan->fast != NULL ? plan->fast->alloc_bytes : 0); +} + extern void ffi_call_efi64(ffi_cif *cif, void (*fn)(void), void *rvalue, void **avalue); #endif diff --git a/deps/libffi/src/x86/ffitarget.h b/deps/libffi/src/x86/ffitarget.h index d702235f90fe..eaf6a910a4f5 100644 --- a/deps/libffi/src/x86/ffitarget.h +++ b/deps/libffi/src/x86/ffitarget.h @@ -58,6 +58,13 @@ #define FFI_TARGET_HAS_INT128 #endif +/* The System V x86-64 psABI passes 8- and 16-byte vectors in SSE registers; + this is implemented by the ffi64.c (FFI_UNIX64) backend only. 32-bit x86 + and the Windows x86-64 backend (ffiw64.c) do not marshal vectors. */ +#if defined(X86_64) && !defined(X86_WIN64) +#define FFI_TARGET_HAS_VECTOR_TYPE +#endif + /* ---- Generic type definitions ----------------------------------------- */ #ifndef LIBFFI_ASM @@ -138,6 +145,18 @@ typedef enum ffi_abi { #define FFI_TYPE_SMALL_STRUCT_4B (FFI_TYPE_LAST + 3) #define FFI_TYPE_MS_STRUCT (FFI_TYPE_LAST + 4) +/* Tripwire: the win64.S / win64_intel.S return-value jump tables use one + 8-byte slot per code value and place the FFI_TYPE_SMALL_STRUCT_* pseudo-types + (which are FFI_TYPE_LAST-relative) immediately after the generic FFI_TYPE_* + codes. Adding a new generic type bumps FFI_TYPE_LAST, shifts those codes, + and opens a gap in the tables that silently misaligns small-struct returns. + When this fires: add a matching E() slot for the new type in both win64.S + and win64_intel.S, then bump FFI_X86_TYPE_LAST to match. */ +#define FFI_X86_TYPE_LAST FFI_TYPE_VECTOR +#if FFI_TYPE_LAST != FFI_X86_TYPE_LAST +# error "new FFI_TYPE_* added: sync the win64.S/win64_intel.S jump tables and bump FFI_X86_TYPE_LAST" +#endif + #if defined (X86_64) || defined(X86_WIN64) \ || (defined (__x86_64__) && defined (X86_DARWIN)) /* 4 bytes of ENDBR64 + 7 bytes of LEA + 6 bytes of JMP + 7 bytes of NOP diff --git a/deps/libffi/src/x86/win64.S b/deps/libffi/src/x86/win64.S index 185f0a3048fb..f23a5fa29e8e 100644 --- a/deps/libffi/src/x86/win64.S +++ b/deps/libffi/src/x86/win64.S @@ -151,6 +151,11 @@ E(0b, FFI_TYPE_UINT128) E(0b, FFI_TYPE_SINT128) movdqu %xmm0, (%r8) epilogue +/* Win64 does not marshal vectors (ffi_prep_cif_core rejects them), but the + FFI_TYPE_SMALL_STRUCT_* codes are FFI_TYPE_LAST-relative, so this slot must + exist to keep the table contiguous and the small-struct entries aligned. */ +E(0b, FFI_TYPE_VECTOR) + call PLT(C(abort)) E(0b, FFI_TYPE_SMALL_STRUCT_1B) movb %al, (%r8) epilogue diff --git a/deps/libffi/src/x86/win64_intel.S b/deps/libffi/src/x86/win64_intel.S index e9eff00da3ce..807f5b3e98f7 100644 --- a/deps/libffi/src/x86/win64_intel.S +++ b/deps/libffi/src/x86/win64_intel.S @@ -152,6 +152,11 @@ E(0b, FFI_TYPE_UINT128) E(0b, FFI_TYPE_SINT128) movdqu xmmword ptr [r8], xmm0 epilogue +/* Win64 does not marshal vectors (ffi_prep_cif_core rejects them), but the + FFI_TYPE_SMALL_STRUCT_* codes are FFI_TYPE_LAST-relative, so this slot must + exist to keep the table contiguous and the small-struct entries aligned. */ +E(0b, FFI_TYPE_VECTOR) + call PLT(C(abort)) E(0b, FFI_TYPE_SMALL_STRUCT_1B) mov byte ptr [r8], al ; movb %al, (%r8) epilogue diff --git a/deps/libffi/testsuite/Makefile.am b/deps/libffi/testsuite/Makefile.am index c14a880959d8..702461d02418 100644 --- a/deps/libffi/testsuite/Makefile.am +++ b/deps/libffi/testsuite/Makefile.am @@ -13,15 +13,17 @@ EXTRA_DIST = config/default.exp emscripten/build.sh emscripten/conftest.py \ libffi.bhaible/alignof.h libffi.bhaible/bhaible.exp libffi.bhaible/test-call.c \ libffi.bhaible/test-callback.c libffi.bhaible/testcases.c libffi.call/align_mixed.c \ libffi.call/align_stdcall.c libffi.call/bpo_38748.c libffi.call/call.exp \ + libffi.call/closure_thiscall_fastcall_pop.c \ libffi.call/err_bad_typedef.c libffi.call/ffitest.h libffi.call/float.c \ libffi.call/float1.c libffi.call/float2.c libffi.call/float3.c \ libffi.call/float4.c libffi.call/float_va.c libffi.call/i128-1.c \ libffi.call/large_struct_by_value.c libffi.call/many.c \ - libffi.call/many2.c libffi.call/many_double.c libffi.call/many_mixed.c \ + libffi.call/many2.c libffi.call/many_double.c \ + libffi.call/many_large_structs.c libffi.call/many_mixed.c \ libffi.call/many_small_structs.c \ libffi.call/negint.c libffi.call/offsets.c libffi.call/overread.c \ libffi.call/plan.c libffi.call/plan_mixed.c libffi.call/plan_spill.c \ - libffi.call/plan_struct.c libffi.call/plan_var.c \ + libffi.call/plan_struct.c libffi.call/plan_size.c libffi.call/plan_var.c \ libffi.call/pr1172638.c libffi.call/promotion.c libffi.call/pyobjc_tc.c libffi.call/return_dbl.c \ libffi.call/return_dbl1.c libffi.call/return_dbl2.c libffi.call/return_fl.c \ libffi.call/return_fl1.c libffi.call/return_fl2.c libffi.call/return_fl3.c \ @@ -90,4 +92,10 @@ EXTRA_DIST = config/default.exp emscripten/build.sh emscripten/conftest.py \ libffi.complex/return_complex_float.c libffi.complex/return_complex_longdouble.c libffi.go/aa-direct.c \ libffi.go/closure1.c libffi.go/ffitest.h libffi.go/go.exp \ libffi.go/static-chain.h Makefile.am Makefile.in \ - libffi.threads/ffitest.h libffi.threads/threads.exp libffi.threads/tsan.c + libffi.threads/ffitest.h libffi.threads/threads.exp libffi.threads/tsan.c \ + libffi.vector/vector.exp libffi.vector/ffitest.h libffi.vector/vector.h \ + libffi.vector/vector_float32x4.c libffi.vector/vector_float32x2.c \ + libffi.vector/vector_double2.c libffi.vector/vector_int32x4.c \ + libffi.vector/vector_args_spill.c libffi.vector/vector_vec3.c \ + libffi.vector/vector_double4.c libffi.vector/vector_hva.c \ + libffi.vector/cls_vector.c libffi.vector/vector_validate.c diff --git a/deps/libffi/testsuite/Makefile.in b/deps/libffi/testsuite/Makefile.in index 1b29b90d3633..30b417735d5e 100644 --- a/deps/libffi/testsuite/Makefile.in +++ b/deps/libffi/testsuite/Makefile.in @@ -301,15 +301,17 @@ EXTRA_DIST = config/default.exp emscripten/build.sh emscripten/conftest.py \ libffi.bhaible/alignof.h libffi.bhaible/bhaible.exp libffi.bhaible/test-call.c \ libffi.bhaible/test-callback.c libffi.bhaible/testcases.c libffi.call/align_mixed.c \ libffi.call/align_stdcall.c libffi.call/bpo_38748.c libffi.call/call.exp \ + libffi.call/closure_thiscall_fastcall_pop.c \ libffi.call/err_bad_typedef.c libffi.call/ffitest.h libffi.call/float.c \ libffi.call/float1.c libffi.call/float2.c libffi.call/float3.c \ libffi.call/float4.c libffi.call/float_va.c libffi.call/i128-1.c \ libffi.call/large_struct_by_value.c libffi.call/many.c \ - libffi.call/many2.c libffi.call/many_double.c libffi.call/many_mixed.c \ + libffi.call/many2.c libffi.call/many_double.c \ + libffi.call/many_large_structs.c libffi.call/many_mixed.c \ libffi.call/many_small_structs.c \ libffi.call/negint.c libffi.call/offsets.c libffi.call/overread.c \ libffi.call/plan.c libffi.call/plan_mixed.c libffi.call/plan_spill.c \ - libffi.call/plan_struct.c libffi.call/plan_var.c \ + libffi.call/plan_struct.c libffi.call/plan_size.c libffi.call/plan_var.c \ libffi.call/pr1172638.c libffi.call/promotion.c libffi.call/pyobjc_tc.c libffi.call/return_dbl.c \ libffi.call/return_dbl1.c libffi.call/return_dbl2.c libffi.call/return_fl.c \ libffi.call/return_fl1.c libffi.call/return_fl2.c libffi.call/return_fl3.c \ @@ -378,7 +380,13 @@ EXTRA_DIST = config/default.exp emscripten/build.sh emscripten/conftest.py \ libffi.complex/return_complex_float.c libffi.complex/return_complex_longdouble.c libffi.go/aa-direct.c \ libffi.go/closure1.c libffi.go/ffitest.h libffi.go/go.exp \ libffi.go/static-chain.h Makefile.am Makefile.in \ - libffi.threads/ffitest.h libffi.threads/threads.exp libffi.threads/tsan.c + libffi.threads/ffitest.h libffi.threads/threads.exp libffi.threads/tsan.c \ + libffi.vector/vector.exp libffi.vector/ffitest.h libffi.vector/vector.h \ + libffi.vector/vector_float32x4.c libffi.vector/vector_float32x2.c \ + libffi.vector/vector_double2.c libffi.vector/vector_int32x4.c \ + libffi.vector/vector_args_spill.c libffi.vector/vector_vec3.c \ + libffi.vector/vector_double4.c libffi.vector/vector_hva.c \ + libffi.vector/cls_vector.c libffi.vector/vector_validate.c all: all-am diff --git a/deps/libffi/testsuite/libffi.call/closure_thiscall_fastcall_pop.c b/deps/libffi/testsuite/libffi.call/closure_thiscall_fastcall_pop.c new file mode 100644 index 000000000000..9cc0b091943f --- /dev/null +++ b/deps/libffi/testsuite/libffi.call/closure_thiscall_fastcall_pop.c @@ -0,0 +1,131 @@ +/* Area: closure, ffi_prep_closure_loc + Purpose: Check i386 THISCALL/FASTCALL closures pop the stack correctly. + Limitations: i386 + GNU inline asm only; a no-op elsewhere. + PR: none. + Originator: i386 closure stack-pop accounting regression. + + THISCALL and FASTCALL are callee-clean: the closure must remove its + stack-resident arguments on return (ret $n). When a 64-bit integer or + a struct argument is placed on the stack, the closure return path used + to compute the pop as cif->bytes - narg_reg*4 with narg_reg force-bumped + to 2, discounting register slots that were never used and under-popping + the stack. A caller that relies on callee cleanup is then left with the + argument bytes where its return address should be. + + This test invokes the generated closure through a minimal callee-clean + call site and checks that ESP is balanced across the call (delta 0). + Without the fix the delta is 8 (FASTCALL uint64) or 4 (THISCALL). */ + +/* { dg-do run } */ +#include "ffitest.h" + +#if defined(__i386__) && defined(__GNUC__) && !defined(__APPLE__) + +static uint64_t received; +static int ran; + +static void +cb (ffi_cif *cif, void *resp, void **args, void *userdata) +{ + (void) cif; (void) resp; (void) userdata; + received = *(uint64_t *) args[cif->nargs - 1]; + ran++; +} + +/* Push an 8-byte stack argument, load ECX (the thiscall "this" register, + ignored by the fastcall callee), call the closure, and return how many + bytes the callee under-popped (0 == it popped exactly what was pushed). + + Every operand is read into a register up front, while ESP is still at + its incoming value, so nothing is referenced through an ESP-relative + memory operand after we start moving ESP (which would otherwise read a + stale slot, on clang at -O2 in particular). The stack is then 16-byte + aligned at the call as the i386 psABI requires, so the -O2-built closure + body may use aligned SSE without faulting; the alignment cancels out of + the delta. ESP is restored to its exact incoming value before the delta + is stored, so a wrong pop cannot corrupt our frame. Not using EBX keeps + this compatible with -fPIC; the delta is returned via memory so no free + register is needed for it. */ +static int +esp_delta (void *code, uint64_t stackarg, unsigned ecxv) +{ + unsigned delta; + unsigned lo = (unsigned) stackarg; + unsigned hi = (unsigned) (stackarg >> 32); + __asm__ volatile ( + "movl %[lo], %%eax\n\t" /* stash all operands in registers */ + "movl %[hi], %%edx\n\t" /* before ESP moves */ + "movl %[code], %%edi\n\t" + "movl %[ecxv], %%ecx\n\t" /* thiscall 'this' */ + "movl %%esp, %%esi\n\t" /* remember the real esp */ + "andl $-16, %%esp\n\t" /* 16-byte align, then bias by the */ + "subl $8, %%esp\n\t" /* 8 arg bytes so 'call' is 0 mod 16 */ + "pushl %%edx\n\t" /* high dword */ + "pushl %%eax\n\t" /* low dword */ + "calll *%%edi\n\t" + "movl %%esi, %%eax\n\t" /* recompute esp just before the */ + "andl $-16, %%eax\n\t" /* pushes... */ + "subl $8, %%eax\n\t" + "subl %%esp, %%eax\n\t" /* eax = under-popped byte count */ + "movl %%esi, %%esp\n\t" /* restore before touching memory */ + "movl %%eax, %[delta]\n\t" + : [delta] "=m" (delta) + : [lo] "m" (lo), [hi] "m" (hi), [code] "m" (code), [ecxv] "m" (ecxv) + : "memory", "cc", "eax", "ecx", "edx", "esi", "edi"); + return (int) delta; +} + +static int +check_abi (ffi_abi abi, unsigned nargs, ffi_type **atypes, unsigned ecx) +{ + ffi_cif cif; + ffi_closure *closure; + void *code; + int delta; + + closure = ffi_closure_alloc (sizeof (ffi_closure), &code); + CHECK (closure != NULL); + CHECK (ffi_prep_cif (&cif, abi, nargs, &ffi_type_void, atypes) == FFI_OK); + CHECK (ffi_prep_closure_loc (closure, &cif, cb, NULL, code) == FFI_OK); + + ran = 0; + received = 0; + delta = esp_delta (code, 0x1122334455667788ULL, ecx); + + CHECK (ran == 1); + CHECK (received == 0x1122334455667788ULL); + ffi_closure_free (closure); + return delta; +} + +int +main (void) +{ + ffi_type *fastcall_args[1] = { &ffi_type_uint64 }; + ffi_type *thiscall_args[2] = { &ffi_type_pointer, &ffi_type_uint64 }; + int d; + + /* FASTCALL void cb(uint64_t): the uint64 is stack-resident; pop must be 8. */ + d = check_abi (FFI_FASTCALL, 1, fastcall_args, 0); + printf ("FASTCALL uint64 esp delta: %d\n", d); + CHECK (d == 0); + + /* THISCALL void cb(void*, uint64_t): 'this' in ECX, uint64 on the stack; + pop must be 8 (not 4). */ + d = check_abi (FFI_THISCALL, 2, thiscall_args, 0xdeadbeef); + printf ("THISCALL this+uint64 esp delta: %d\n", d); + CHECK (d == 0); + + exit (0); +} + +#else + +int +main (void) +{ + /* Not an i386 GNU target: nothing to check here. */ + exit (0); +} + +#endif diff --git a/deps/libffi/testsuite/libffi.call/many_large_structs.c b/deps/libffi/testsuite/libffi.call/many_large_structs.c new file mode 100644 index 000000000000..9f766a9113c6 --- /dev/null +++ b/deps/libffi/testsuite/libffi.call/many_large_structs.c @@ -0,0 +1,88 @@ +/* Area: ffi_call + Purpose: Pass many large by-value structs on AArch64. + Limitations: none. + PR: none. + Originator: AArch64 large-struct stack accounting regression. + + Regression test: on AArch64, composites larger than 16 bytes are passed + by invisible reference. ffi_call copies each payload into the argument + slab (growing down from the top) and, once X0-X7 are exhausted, also + spills the by-ref pointer into the same slab (the NSAA, growing up). + The generic prep_cif budget in cif->bytes only charged the payload copy, + not the 8-byte pointer slot, so with enough large structs the two regions + collided and a later payload copy overwrote an already-spilled pointer, + leaving the callee with a corrupt pointer for a by-value argument. + Passing sixteen 32-byte (non-HFA) structs by value -- eight more than the + argument registers -- must marshal every argument intact. */ + +/* { dg-do run } */ +#include "ffitest.h" + +#define NARGS 16 +#define SSIZE 32 + +typedef struct { unsigned char b[SSIZE]; } big_struct; + +/* Sum every byte of every argument. A corrupted by-ref pointer makes the + callee read the wrong memory, so the sum no longer matches. */ +static int ABI_ATTR +sum_bytes (big_struct s0, big_struct s1, big_struct s2, big_struct s3, + big_struct s4, big_struct s5, big_struct s6, big_struct s7, + big_struct s8, big_struct s9, big_struct s10, big_struct s11, + big_struct s12, big_struct s13, big_struct s14, big_struct s15) +{ + big_struct *all[NARGS]; + int i, j, sum = 0; + + all[0] = &s0; all[1] = &s1; all[2] = &s2; all[3] = &s3; + all[4] = &s4; all[5] = &s5; all[6] = &s6; all[7] = &s7; + all[8] = &s8; all[9] = &s9; all[10] = &s10; all[11] = &s11; + all[12] = &s12; all[13] = &s13; all[14] = &s14; all[15] = &s15; + + for (i = 0; i < NARGS; i++) + for (j = 0; j < SSIZE; j++) + sum += all[i]->b[j]; + + return sum; +} + +int main (void) +{ + ffi_cif cif; + ffi_type *args[NARGS]; + void *values[NARGS]; + ffi_type bs_type; + ffi_type *bs_elements[SSIZE + 1]; + big_struct in[NARGS]; + ffi_arg result = 0; + int i, j, expected = 0; + + bs_type.size = 0; + bs_type.alignment = 0; + bs_type.type = FFI_TYPE_STRUCT; + for (i = 0; i < SSIZE; i++) + bs_elements[i] = &ffi_type_uchar; + bs_elements[SSIZE] = NULL; + bs_type.elements = bs_elements; + + /* Fill struct i with the distinct byte value (i + 1) so any pointer + mix-up between arguments changes the total. */ + for (i = 0; i < NARGS; i++) + { + for (j = 0; j < SSIZE; j++) + { + in[i].b[j] = (unsigned char) (i + 1); + expected += (i + 1); + } + args[i] = &bs_type; + values[i] = &in[i]; + } + + CHECK(ffi_prep_cif(&cif, ABI_NUM, NARGS, &ffi_type_sint, args) == FFI_OK); + + ffi_call(&cif, FFI_FN(sum_bytes), &result, values); + + CHECK((int) result == expected); + + exit(0); +} diff --git a/deps/libffi/testsuite/libffi.call/plan_size.c b/deps/libffi/testsuite/libffi.call/plan_size.c new file mode 100644 index 000000000000..b8398fbee991 --- /dev/null +++ b/deps/libffi/testsuite/libffi.call/plan_size.c @@ -0,0 +1,77 @@ +/* Area: ffi_call_plan_size + Purpose: Check that a plan reports its own allocation size, that the + size is stable across invocations, and that a NULL plan has + no footprint. + Limitations: The exact byte count is implementation defined, so this only + checks the invariants callers may rely on. + PR: none. + Originator: ffi_call_plan tests */ + +/* { dg-do run } */ +#include "ffitest.h" + +static uint64_t gp2(uint64_t a, uint64_t b) +{ + return a + b * 2; +} + +static uint64_t gp6(uint64_t a, uint64_t b, uint64_t c, + uint64_t d, uint64_t e, uint64_t f) +{ + return a + b * 2 + c * 3 + d * 4 + e * 5 + f * 6; +} + +int main (void) +{ + ffi_cif cif2, cif6; + ffi_type *args[6]; + void *values[6]; + ffi_call_plan *plan2, *plan6; + size_t size2, size6; + uint64_t a[6], r; + int i; + + for (i = 0; i < 6; i++) + { + args[i] = &ffi_type_uint64; + a[i] = (uint64_t) (i + 1); + values[i] = &a[i]; + } + + CHECK(ffi_prep_cif(&cif2, FFI_DEFAULT_ABI, 2, &ffi_type_uint64, args) + == FFI_OK); + CHECK(ffi_prep_cif(&cif6, FFI_DEFAULT_ABI, 6, &ffi_type_uint64, args) + == FFI_OK); + + /* A NULL plan has no footprint, mirroring ffi_call_plan_free(NULL). */ + CHECK(ffi_call_plan_size(NULL) == 0); + + plan2 = ffi_call_plan_alloc(&cif2); + CHECK(plan2 != NULL); + plan6 = ffi_call_plan_alloc(&cif6); + CHECK(plan6 != NULL); + + size2 = ffi_call_plan_size(plan2); + size6 = ffi_call_plan_size(plan6); + + /* Every plan owns at least its handle, and a wider signature never needs + less memory than a narrower one of the same shape. Targets without a + fast path report the same constant for both. */ + CHECK(size2 > 0); + CHECK(size6 >= size2); + + /* The plan is immutable, so querying it must not disturb invocation and + the reported size must not drift across calls. */ + ffi_call_plan_invoke(plan6, FFI_FN(gp6), &r, values); + CHECK(r == gp6(a[0], a[1], a[2], a[3], a[4], a[5])); + CHECK(ffi_call_plan_size(plan6) == size6); + + ffi_call_plan_invoke(plan2, FFI_FN(gp2), &r, values); + CHECK(r == gp2(a[0], a[1])); + CHECK(ffi_call_plan_size(plan2) == size2); + + ffi_call_plan_free(plan2); + ffi_call_plan_free(plan6); + + exit(0); +} diff --git a/deps/libffi/testsuite/libffi.vector/cls_vector.c b/deps/libffi/testsuite/libffi.vector/cls_vector.c new file mode 100644 index 000000000000..18d8806b51b5 --- /dev/null +++ b/deps/libffi/testsuite/libffi.vector/cls_vector.c @@ -0,0 +1,67 @@ +/* Area: closure_call + Purpose: A closure that receives two vector arguments (and a scalar) and + returns a vector. Exercises the closure argument-extraction and + vector return paths. + Limitations: none. + PR: none. + Originator: libffi vector support. */ + +/* { dg-do run } */ + +#include "vector.h" + +typedef float f32x4 __attribute__((vector_size (16))); + +static void +cls_vector_fn (ffi_cif *cif __UNUSED__, void *resp, void **args, + void *userdata __UNUSED__) +{ + f32x4 a = *(f32x4 *) args[0]; + f32x4 b = *(f32x4 *) args[1]; + int scale = *(int *) args[2]; + f32x4 *r = (f32x4 *) resp; + + *r = (a + b) * (float) scale; +} + +typedef f32x4 (*cls_vector_t) (f32x4, f32x4, int); + +int +main (void) +{ + ffi_cif cif; + void *code; + ffi_closure *pcl = ffi_closure_alloc (sizeof (ffi_closure), &code); + ffi_type vec_type; + ffi_type *vec_elems[5]; + ffi_type *arg_types[3]; + f32x4 a = { 1, 2, 3, 4 }; + f32x4 b = { 10, 20, 30, 40 }; + f32x4 res; + int scale = 2; + int i; + + CHECK (pcl != NULL); + + make_vector_type (&vec_type, vec_elems, &ffi_type_float, 4); + + arg_types[0] = &vec_type; + arg_types[1] = &vec_type; + arg_types[2] = &ffi_type_sint; + + CHECK (ffi_prep_cif (&cif, FFI_DEFAULT_ABI, 3, &vec_type, arg_types) + == FFI_OK); + CHECK (ffi_prep_closure_loc (pcl, &cif, cls_vector_fn, NULL, code) + == FFI_OK); + + res = ((cls_vector_t) code) (a, b, scale); + + for (i = 0; i < 4; i++) + { + float want = (a[i] + b[i]) * (float) scale; + printf ("res[%d] = %g (want %g)\n", i, (double) res[i], (double) want); + CHECK (res[i] == want); + } + + exit (0); +} diff --git a/deps/libffi/testsuite/libffi.vector/ffitest.h b/deps/libffi/testsuite/libffi.vector/ffitest.h new file mode 100644 index 000000000000..d27d362d6a6e --- /dev/null +++ b/deps/libffi/testsuite/libffi.vector/ffitest.h @@ -0,0 +1 @@ +#include "../libffi.call/ffitest.h" diff --git a/deps/libffi/testsuite/libffi.vector/vector.exp b/deps/libffi/testsuite/libffi.vector/vector.exp new file mode 100644 index 000000000000..a76957ee4d33 --- /dev/null +++ b/deps/libffi/testsuite/libffi.vector/vector.exp @@ -0,0 +1,59 @@ +# Copyright (C) 2026 Free Software Foundation, Inc. + +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING3. If not see +# . + +dg-init +libffi-init + +global srcdir subdir + +# The tests are written with the GCC/Clang vector extension +# (__attribute__ ((vector_size (N)))). A target port can support +# FFI_TYPE_VECTOR at the ABI level while the compiler under test (e.g. +# MSVC) cannot compile that syntax, so probe the compiler with an actual +# compilation, not just a preprocessor check. +proc libffi_vector_syntax_test { } { + set src "vecprobe[pid].c" + set obj "vecprobe[pid].o" + + set f [open $src "w"] + puts $f "typedef float probe_v4 __attribute__ ((vector_size (16)));" + puts $f "probe_v4 probe_var;" + puts $f "int main (void) { return 0; }" + close $f + + set lines [libffi_target_compile $src $obj object ""] + file delete $src + file delete $obj + + return [string match "" $lines] +} + +set tlist [lsort [glob -nocomplain -- $srcdir/$subdir/*.{c,cc}]] + +if { [libffi_feature_test "#ifdef FFI_TARGET_HAS_VECTOR_TYPE"] + && [libffi_vector_syntax_test] } { + run-many-tests $tlist "" +} else { + foreach test $tlist { + unsupported "$test" + } +} + +dg-finish + +# Local Variables: +# tcl-indent-level:4 +# End: diff --git a/deps/libffi/testsuite/libffi.vector/vector.h b/deps/libffi/testsuite/libffi.vector/vector.h new file mode 100644 index 000000000000..7baf832d37e4 --- /dev/null +++ b/deps/libffi/testsuite/libffi.vector/vector.h @@ -0,0 +1,32 @@ +/* -*-c-*- */ +/* Shared helpers for the libffi vector (SIMD) tests. + + Vectors are built with the portable GCC/Clang spelling + __attribute__((vector_size (N))) so the tests compile on both compilers. + A vector ffi_type is described exactly like a struct, except every element + points at the SAME scalar ffi_type and the count is the lane count; the + caller leaves size and alignment at zero and libffi computes them. */ + +#ifndef LIBFFI_VECTOR_H +#define LIBFFI_VECTOR_H + +#include "ffitest.h" + +/* Build (into the caller-provided ELEMS array of length COUNT + 1 and the + ffi_type object TY) a vector type descriptor of COUNT lanes of scalar type + ELEM. ELEMS must have room for COUNT + 1 pointers (NULL terminator). */ +static inline void +make_vector_type (ffi_type *ty, ffi_type **elems, ffi_type *elem, + unsigned count) +{ + unsigned i; + for (i = 0; i < count; i++) + elems[i] = elem; + elems[count] = NULL; + ty->size = 0; + ty->alignment = 0; + ty->type = FFI_TYPE_VECTOR; + ty->elements = elems; +} + +#endif /* LIBFFI_VECTOR_H */ diff --git a/deps/libffi/testsuite/libffi.vector/vector_args_spill.c b/deps/libffi/testsuite/libffi.vector/vector_args_spill.c new file mode 100644 index 000000000000..dec6ab2e62af --- /dev/null +++ b/deps/libffi/testsuite/libffi.vector/vector_args_spill.c @@ -0,0 +1,85 @@ +/* Area: ffi_call + Purpose: Pass many vector arguments interleaved with scalars, enough to + exhaust the vector argument registers and spill onto the stack. + Limitations: none. + PR: none. + Originator: libffi vector support. */ + +/* { dg-do run } */ + +#include "vector.h" + +typedef float f32x4 __attribute__((vector_size (16))); + +/* Ten vectors exceeds the 8 vector argument registers on both AArch64 and + x86-64, so v8/v9 are passed on the stack. The scalars are interleaved to + make sure the two register files advance independently. */ +static float +mix (int i0, f32x4 v0, f32x4 v1, double d0, f32x4 v2, f32x4 v3, + f32x4 v4, int i1, f32x4 v5, f32x4 v6, f32x4 v7, double d1, + f32x4 v8, f32x4 v9) +{ + float acc = 0; + acc += 1 * v0[0] + v0[3]; + acc += 2 * v1[0] + v1[3]; + acc += 3 * v2[0] + v2[3]; + acc += 4 * v3[0] + v3[3]; + acc += 5 * v4[0] + v4[3]; + acc += 6 * v5[0] + v5[3]; + acc += 7 * v6[0] + v6[3]; + acc += 8 * v7[0] + v7[3]; + acc += 9 * v8[0] + v8[3]; + acc += 10 * v9[0] + v9[3]; + acc += i0 + i1 + (float) d0 + (float) d1; + return acc; +} + +int +main (void) +{ + ffi_cif cif; + ffi_type vec_type; + ffi_type *vec_elems[5]; + ffi_type *args[14]; + void *values[14]; + f32x4 v[10]; + int i0 = 100, i1 = 7; + double d0 = 3.5, d1 = 0.25; + float r, ref; + unsigned k; + + make_vector_type (&vec_type, vec_elems, &ffi_type_float, 4); + + for (k = 0; k < 10; k++) + { + f32x4 t = { (float) (k + 1), 0, 0, (float) (100 + k) }; + v[k] = t; + } + + args[0] = &ffi_type_sint; values[0] = &i0; + args[1] = &vec_type; values[1] = &v[0]; + args[2] = &vec_type; values[2] = &v[1]; + args[3] = &ffi_type_double; values[3] = &d0; + args[4] = &vec_type; values[4] = &v[2]; + args[5] = &vec_type; values[5] = &v[3]; + args[6] = &vec_type; values[6] = &v[4]; + args[7] = &ffi_type_sint; values[7] = &i1; + args[8] = &vec_type; values[8] = &v[5]; + args[9] = &vec_type; values[9] = &v[6]; + args[10] = &vec_type; values[10] = &v[7]; + args[11] = &ffi_type_double; values[11] = &d1; + args[12] = &vec_type; values[12] = &v[8]; + args[13] = &vec_type; values[13] = &v[9]; + + CHECK (ffi_prep_cif (&cif, FFI_DEFAULT_ABI, 14, &ffi_type_float, args) + == FFI_OK); + + ffi_call (&cif, FFI_FN (mix), &r, values); + + ref = mix (i0, v[0], v[1], d0, v[2], v[3], v[4], i1, v[5], v[6], v[7], + d1, v[8], v[9]); + printf ("r = %g (want %g)\n", (double) r, (double) ref); + CHECK (r == ref); + + exit (0); +} diff --git a/deps/libffi/testsuite/libffi.vector/vector_double2.c b/deps/libffi/testsuite/libffi.vector/vector_double2.c new file mode 100644 index 000000000000..dd45878b5afe --- /dev/null +++ b/deps/libffi/testsuite/libffi.vector/vector_double2.c @@ -0,0 +1,53 @@ +/* Area: ffi_call + Purpose: Pass and return a 16-byte double2 vector (single Q/SSE reg). + Limitations: none. + PR: none. + Originator: libffi vector support. */ + +/* { dg-do run } */ + +#include "vector.h" + +typedef double d2 __attribute__((vector_size (16))); + +static d2 +add_d2 (d2 a, d2 b) +{ + return a + b; +} + +int +main (void) +{ + ffi_cif cif; + ffi_type vec_type; + ffi_type *vec_elems[3]; + ffi_type *args[2]; + void *values[2]; + d2 a = { 1.5, 2.5 }; + d2 b = { 10.0, 20.0 }; + d2 r, ref; + int i; + + make_vector_type (&vec_type, vec_elems, &ffi_type_double, 2); + + args[0] = &vec_type; + args[1] = &vec_type; + values[0] = &a; + values[1] = &b; + + CHECK (ffi_prep_cif (&cif, FFI_DEFAULT_ABI, 2, &vec_type, args) == FFI_OK); + CHECK (vec_type.size == 16); + CHECK (vec_type.alignment == 16); + + ffi_call (&cif, FFI_FN (add_d2), &r, values); + + ref = add_d2 (a, b); + for (i = 0; i < 2; i++) + { + printf ("r[%d] = %g (want %g)\n", i, r[i], ref[i]); + CHECK (r[i] == ref[i]); + } + + exit (0); +} diff --git a/deps/libffi/testsuite/libffi.vector/vector_double4.c b/deps/libffi/testsuite/libffi.vector/vector_double4.c new file mode 100644 index 000000000000..9f4473935929 --- /dev/null +++ b/deps/libffi/testsuite/libffi.vector/vector_double4.c @@ -0,0 +1,81 @@ +/* Area: ffi_call + Purpose: A 32-byte double4 vector. On AArch64 a bare vector wider than + 16 bytes is passed by reference and returned in memory (no + short-vector register class), so the call must round-trip. On + x86-64 wider-than-16-byte vectors are not implemented, so + ffi_prep_cif must report FFI_BAD_TYPEDEF. + Limitations: none. + PR: none. + Originator: libffi vector support. */ + +/* { dg-do run } */ + +#include "vector.h" + +typedef double d4 __attribute__((vector_size (32))); + +/* Only called on ports that can actually marshal a 32-byte vector. */ +static d4 add_d4 (d4 a, d4 b) __UNUSED__; + +static d4 +add_d4 (d4 a, d4 b) +{ + return a + b; +} + +int +main (void) +{ + ffi_cif cif; + ffi_type vec_type; + ffi_type *vec_elems[5]; + ffi_type *args[2]; + + make_vector_type (&vec_type, vec_elems, &ffi_type_double, 4); + args[0] = &vec_type; + args[1] = &vec_type; + +#if defined(__aarch64__) || defined(_M_ARM64) + { + void *values[2]; + d4 a = { 1, 2, 3, 4 }; + d4 b = { 10, 20, 30, 40 }; + d4 r, ref; + int i; + + values[0] = &a; + values[1] = &b; + + CHECK (ffi_prep_cif (&cif, FFI_DEFAULT_ABI, 2, &vec_type, args) == FFI_OK); + CHECK (vec_type.size == 32); + CHECK (vec_type.alignment == 16); + + ffi_call (&cif, FFI_FN (add_d4), &r, values); + + ref = add_d4 (a, b); + for (i = 0; i < 4; i++) + { + printf ("r[%d] = %g (want %g)\n", i, r[i], ref[i]); + CHECK (r[i] == ref[i]); + } + } +#else + { + /* x86-64 (and any other opted-in port without >16B support): the >16-byte + vector must be rejected, both as a return type and as an argument. */ + ffi_status s_ret, s_arg; + + s_ret = ffi_prep_cif (&cif, FFI_DEFAULT_ABI, 0, &vec_type, NULL); + printf ("32-byte vector return: status %d (want %d = FFI_BAD_TYPEDEF)\n", + s_ret, FFI_BAD_TYPEDEF); + CHECK (s_ret == FFI_BAD_TYPEDEF); + + s_arg = ffi_prep_cif (&cif, FFI_DEFAULT_ABI, 1, &ffi_type_void, args); + printf ("32-byte vector argument: status %d (want %d = FFI_BAD_TYPEDEF)\n", + s_arg, FFI_BAD_TYPEDEF); + CHECK (s_arg == FFI_BAD_TYPEDEF); + } +#endif + + exit (0); +} diff --git a/deps/libffi/testsuite/libffi.vector/vector_float32x2.c b/deps/libffi/testsuite/libffi.vector/vector_float32x2.c new file mode 100644 index 000000000000..f613687a2988 --- /dev/null +++ b/deps/libffi/testsuite/libffi.vector/vector_float32x2.c @@ -0,0 +1,53 @@ +/* Area: ffi_call + Purpose: Pass and return an 8-byte float32x2 vector (single D/SSE reg). + Limitations: none. + PR: none. + Originator: libffi vector support. */ + +/* { dg-do run } */ + +#include "vector.h" + +typedef float f32x2 __attribute__((vector_size (8))); + +static f32x2 +add_f32x2 (f32x2 a, f32x2 b) +{ + return a + b; +} + +int +main (void) +{ + ffi_cif cif; + ffi_type vec_type; + ffi_type *vec_elems[3]; + ffi_type *args[2]; + void *values[2]; + f32x2 a = { 3, 4 }; + f32x2 b = { 5, 6 }; + f32x2 r, ref; + int i; + + make_vector_type (&vec_type, vec_elems, &ffi_type_float, 2); + + args[0] = &vec_type; + args[1] = &vec_type; + values[0] = &a; + values[1] = &b; + + CHECK (ffi_prep_cif (&cif, FFI_DEFAULT_ABI, 2, &vec_type, args) == FFI_OK); + CHECK (vec_type.size == 8); + CHECK (vec_type.alignment == 8); + + ffi_call (&cif, FFI_FN (add_f32x2), &r, values); + + ref = add_f32x2 (a, b); + for (i = 0; i < 2; i++) + { + printf ("r[%d] = %g (want %g)\n", i, (double) r[i], (double) ref[i]); + CHECK (r[i] == ref[i]); + } + + exit (0); +} diff --git a/deps/libffi/testsuite/libffi.vector/vector_float32x4.c b/deps/libffi/testsuite/libffi.vector/vector_float32x4.c new file mode 100644 index 000000000000..971814aaf66c --- /dev/null +++ b/deps/libffi/testsuite/libffi.vector/vector_float32x4.c @@ -0,0 +1,54 @@ +/* Area: ffi_call + Purpose: Pass and return a 16-byte float32x4 vector (the vec4 shape of + libffi/libffi#773). + Limitations: none. + PR: libffi/libffi#773. + Originator: libffi vector support. */ + +/* { dg-do run } */ + +#include "vector.h" + +typedef float f32x4 __attribute__((vector_size (16))); + +static f32x4 +add_f32x4 (f32x4 a, f32x4 b) +{ + return a + b; +} + +int +main (void) +{ + ffi_cif cif; + ffi_type vec_type; + ffi_type *vec_elems[5]; + ffi_type *args[2]; + void *values[2]; + f32x4 a = { 1, 2, 3, 4 }; + f32x4 b = { 10, 20, 30, 40 }; + f32x4 r, ref; + int i; + + make_vector_type (&vec_type, vec_elems, &ffi_type_float, 4); + + args[0] = &vec_type; + args[1] = &vec_type; + values[0] = &a; + values[1] = &b; + + CHECK (ffi_prep_cif (&cif, FFI_DEFAULT_ABI, 2, &vec_type, args) == FFI_OK); + CHECK (vec_type.size == 16); + CHECK (vec_type.alignment == 16); + + ffi_call (&cif, FFI_FN (add_f32x4), &r, values); + + ref = add_f32x4 (a, b); + for (i = 0; i < 4; i++) + { + printf ("r[%d] = %g (want %g)\n", i, (double) r[i], (double) ref[i]); + CHECK (r[i] == ref[i]); + } + + exit (0); +} diff --git a/deps/libffi/testsuite/libffi.vector/vector_hva.c b/deps/libffi/testsuite/libffi.vector/vector_hva.c new file mode 100644 index 000000000000..5f80da07dfde --- /dev/null +++ b/deps/libffi/testsuite/libffi.vector/vector_hva.c @@ -0,0 +1,74 @@ +/* Area: ffi_call + Purpose: Pass and return a homogeneous vector aggregate: a struct of two + identical 16-byte vectors. On AArch64 this is an HVA carried in + a pair of Q registers; on x86-64 the existing SSE struct + classification handles it (four SSE eightbytes). Both round-trip. + Limitations: none. + PR: none. + Originator: libffi vector support. */ + +/* { dg-do run } */ + +#include "vector.h" + +typedef float f32x4 __attribute__((vector_size (16))); + +struct hva2 +{ + f32x4 a; + f32x4 b; +}; + +static struct hva2 +bump (struct hva2 s) +{ + s.a = s.a + 1; + s.b = s.b + 2; + return s; +} + +int +main (void) +{ + ffi_cif cif; + ffi_type vec_type; + ffi_type *vec_elems[5]; + ffi_type struct_type; + ffi_type *struct_elems[3]; + ffi_type *args[1]; + void *values[1]; + struct hva2 in = { { 1, 2, 3, 4 }, { 10, 20, 30, 40 } }; + struct hva2 out, ref; + int i; + + make_vector_type (&vec_type, vec_elems, &ffi_type_float, 4); + + struct_elems[0] = &vec_type; + struct_elems[1] = &vec_type; + struct_elems[2] = NULL; + struct_type.size = 0; + struct_type.alignment = 0; + struct_type.type = FFI_TYPE_STRUCT; + struct_type.elements = struct_elems; + + args[0] = &struct_type; + values[0] = ∈ + + CHECK (ffi_prep_cif (&cif, FFI_DEFAULT_ABI, 1, &struct_type, args) + == FFI_OK); + CHECK (struct_type.size == 32); + + ffi_call (&cif, FFI_FN (bump), &out, values); + + ref = bump (in); + for (i = 0; i < 4; i++) + { + printf ("a[%d] = %g (want %g), b[%d] = %g (want %g)\n", + i, (double) out.a[i], (double) ref.a[i], + i, (double) out.b[i], (double) ref.b[i]); + CHECK (out.a[i] == ref.a[i]); + CHECK (out.b[i] == ref.b[i]); + } + + exit (0); +} diff --git a/deps/libffi/testsuite/libffi.vector/vector_int32x4.c b/deps/libffi/testsuite/libffi.vector/vector_int32x4.c new file mode 100644 index 000000000000..eaa6b802bab5 --- /dev/null +++ b/deps/libffi/testsuite/libffi.vector/vector_int32x4.c @@ -0,0 +1,54 @@ +/* Area: ffi_call + Purpose: Pass and return a 16-byte int32x4 integer vector. Integer + lanes still travel in a vector register, unlike an HFA of ints. + Limitations: none. + PR: none. + Originator: libffi vector support. */ + +/* { dg-do run } */ + +#include "vector.h" + +typedef int i32x4 __attribute__((vector_size (16))); + +static i32x4 +add_i32x4 (i32x4 a, i32x4 b) +{ + return a + b; +} + +int +main (void) +{ + ffi_cif cif; + ffi_type vec_type; + ffi_type *vec_elems[5]; + ffi_type *args[2]; + void *values[2]; + i32x4 a = { 1, 2, 3, 4 }; + i32x4 b = { 5, 6, 7, 8 }; + i32x4 r, ref; + int i; + + make_vector_type (&vec_type, vec_elems, &ffi_type_sint32, 4); + + args[0] = &vec_type; + args[1] = &vec_type; + values[0] = &a; + values[1] = &b; + + CHECK (ffi_prep_cif (&cif, FFI_DEFAULT_ABI, 2, &vec_type, args) == FFI_OK); + CHECK (vec_type.size == 16); + CHECK (vec_type.alignment == 16); + + ffi_call (&cif, FFI_FN (add_i32x4), &r, values); + + ref = add_i32x4 (a, b); + for (i = 0; i < 4; i++) + { + printf ("r[%d] = %d (want %d)\n", i, r[i], ref[i]); + CHECK (r[i] == ref[i]); + } + + exit (0); +} diff --git a/deps/libffi/testsuite/libffi.vector/vector_validate.c b/deps/libffi/testsuite/libffi.vector/vector_validate.c new file mode 100644 index 000000000000..d923ab465fe4 --- /dev/null +++ b/deps/libffi/testsuite/libffi.vector/vector_validate.c @@ -0,0 +1,103 @@ +/* Area: ffi_prep_cif + Purpose: Validate that malformed vector type descriptors are rejected + with FFI_BAD_TYPEDEF, and that a well-formed vector is accepted + with the computed power-of-two size and min(size,16) alignment. + Limitations: none. + PR: none. + Originator: libffi vector support. */ + +/* { dg-do run } */ + +#include "vector.h" + +int +main (void) +{ + ffi_cif cif; + + /* Heterogeneous lanes (float mixed with double) -> FFI_BAD_TYPEDEF. */ + { + ffi_type vt; + ffi_type *elems[3]; + ffi_type *args[1]; + elems[0] = &ffi_type_float; + elems[1] = &ffi_type_double; + elems[2] = NULL; + vt.size = 0; + vt.alignment = 0; + vt.type = FFI_TYPE_VECTOR; + vt.elements = elems; + args[0] = &vt; + CHECK (ffi_prep_cif (&cif, FFI_DEFAULT_ABI, 1, &ffi_type_void, args) + == FFI_BAD_TYPEDEF); + } + + /* An empty (zero-lane) vector -> FFI_BAD_TYPEDEF. */ + { + ffi_type vt; + ffi_type *elems[1]; + ffi_type *args[1]; + elems[0] = NULL; + vt.size = 0; + vt.alignment = 0; + vt.type = FFI_TYPE_VECTOR; + vt.elements = elems; + args[0] = &vt; + CHECK (ffi_prep_cif (&cif, FFI_DEFAULT_ABI, 1, &ffi_type_void, args) + == FFI_BAD_TYPEDEF); + } + + /* A non-scalar (struct) lane type -> FFI_BAD_TYPEDEF. */ + { + ffi_type inner; + ffi_type *inner_elems[2]; + ffi_type vt; + ffi_type *elems[3]; + ffi_type *args[1]; + inner_elems[0] = &ffi_type_float; + inner_elems[1] = NULL; + inner.size = 0; + inner.alignment = 0; + inner.type = FFI_TYPE_STRUCT; + inner.elements = inner_elems; + elems[0] = &inner; + elems[1] = &inner; + elems[2] = NULL; + vt.size = 0; + vt.alignment = 0; + vt.type = FFI_TYPE_VECTOR; + vt.elements = elems; + args[0] = &vt; + CHECK (ffi_prep_cif (&cif, FFI_DEFAULT_ABI, 1, &ffi_type_void, args) + == FFI_BAD_TYPEDEF); + } + + /* A well-formed 3 x float vector is accepted with computed layout. */ + { + ffi_type vt; + ffi_type *elems[4]; + ffi_type *args[1]; + make_vector_type (&vt, elems, &ffi_type_float, 3); + args[0] = &vt; + CHECK (ffi_prep_cif (&cif, FFI_DEFAULT_ABI, 1, &ffi_type_void, args) + == FFI_OK); + CHECK (vt.size == 16); /* 12 rounded up to 16 */ + CHECK (vt.alignment == 16); /* min(16, 16) */ + } + + /* An 8-byte vector gets alignment 8 = min(8, 16). */ + { + ffi_type vt; + ffi_type *elems[3]; + ffi_type *args[1]; + make_vector_type (&vt, elems, &ffi_type_float, 2); + args[0] = &vt; + CHECK (ffi_prep_cif (&cif, FFI_DEFAULT_ABI, 1, &ffi_type_void, args) + == FFI_OK); + CHECK (vt.size == 8); + CHECK (vt.alignment == 8); + } + + printf ("vector validation ok\n"); + exit (0); +} diff --git a/deps/libffi/testsuite/libffi.vector/vector_vec3.c b/deps/libffi/testsuite/libffi.vector/vector_vec3.c new file mode 100644 index 000000000000..5a0367283023 --- /dev/null +++ b/deps/libffi/testsuite/libffi.vector/vector_vec3.c @@ -0,0 +1,73 @@ +/* Area: ffi_call + Purpose: Pass and return a three-lane float vector. Clang's + ext_vector_type(3) has 12 bytes of data padded to 16-byte + storage; libffi's power-of-two size rule must reproduce that + layout so a natively compiled callee agrees. + Limitations: Clang only (GCC's vector_size requires power-of-two totals and + rejects a 12-byte vector). A no-op on other compilers. + PR: none. + Originator: libffi vector support. */ + +/* { dg-do run } */ + +#include "vector.h" + +#ifdef __clang__ + +typedef float f3 __attribute__((ext_vector_type (3))); + +static f3 +scale3 (f3 v) +{ + f3 r; + r[0] = v[0] + 1; + r[1] = v[1] + 2; + r[2] = v[2] + 3; + return r; +} + +int +main (void) +{ + ffi_cif cif; + ffi_type vec_type; + ffi_type *vec_elems[4]; + ffi_type *args[1]; + void *values[1]; + f3 a = { 10, 20, 30 }; + f3 r, ref; + int i; + + make_vector_type (&vec_type, vec_elems, &ffi_type_float, 3); + + args[0] = &vec_type; + values[0] = &a; + + CHECK (ffi_prep_cif (&cif, FFI_DEFAULT_ABI, 1, &vec_type, args) == FFI_OK); + /* 3 x float = 12, rounded up to 16 (matches ext_vector_type storage). */ + CHECK (vec_type.size == 16); + CHECK (vec_type.alignment == 16); + CHECK (sizeof (f3) == 16); + + ffi_call (&cif, FFI_FN (scale3), &r, values); + + ref = scale3 (a); + for (i = 0; i < 3; i++) + { + printf ("r[%d] = %g (want %g)\n", i, (double) r[i], (double) ref[i]); + CHECK (r[i] == ref[i]); + } + + exit (0); +} + +#else + +int +main (void) +{ + /* ext_vector_type is a Clang extension; nothing to test elsewhere. */ + exit (0); +} + +#endif From 4160dfdf811553112d19151afa26535bc385b166 Mon Sep 17 00:00:00 2001 From: "Node.js GitHub Bot" Date: Mon, 10 Aug 2026 20:35:47 -0400 Subject: [PATCH 103/344] deps: update googletest to d89aac5f0dd4021198d903d39de16f896726de21 PR-URL: https://github.com/nodejs/node/pull/65153 Reviewed-By: Colin Ihrig Reviewed-By: Antoine du Hamel Reviewed-By: Luigi Pinca --- .../googletest/include/gtest/gtest-matchers.h | 50 ++++++++++++------- .../googletest/include/gtest/gtest-printers.h | 12 ++--- .../internal/gtest-death-test-internal.h | 5 ++ .../include/gtest/internal/gtest-internal.h | 14 +++--- .../include/gtest/internal/gtest-port.h | 31 ++++-------- deps/googletest/src/gtest-matchers.cc | 2 - deps/googletest/src/gtest-printers.cc | 4 +- deps/googletest/src/gtest.cc | 2 +- 8 files changed, 62 insertions(+), 58 deletions(-) diff --git a/deps/googletest/include/gtest/gtest-matchers.h b/deps/googletest/include/gtest/gtest-matchers.h index d7bdd047f1a6..b5950425cc69 100644 --- a/deps/googletest/include/gtest/gtest-matchers.h +++ b/deps/googletest/include/gtest/gtest-matchers.h @@ -45,6 +45,7 @@ #include #include #include +#include #include #include "gtest/gtest-printers.h" @@ -543,9 +544,8 @@ Matcher : public internal::MatcherBase { Matcher(const char* s); // NOLINT }; -#if GTEST_INTERNAL_HAS_STRING_VIEW // The following two specializations allow the user to write str -// instead of Eq(str) and "foo" instead of Eq("foo") when a absl::string_view +// instead of Eq(str) and "foo" instead of Eq("foo") when a std::string_view // matcher is expected. template <> class GTEST_API_ [[nodiscard]] Matcher @@ -569,7 +569,7 @@ class GTEST_API_ [[nodiscard]] Matcher // Allows the user to write "foo" instead of Eq("foo") sometimes. Matcher(const char* s); // NOLINT - // Allows the user to pass absl::string_views or std::string_views directly. + // Allows the user to pass std::string_views directly. Matcher(internal::StringView s); // NOLINT }; @@ -596,10 +596,9 @@ class GTEST_API_ [[nodiscard]] Matcher // Allows the user to write "foo" instead of Eq("foo") sometimes. Matcher(const char* s); // NOLINT - // Allows the user to pass absl::string_views or std::string_views directly. + // Allows the user to pass std::string_views directly. Matcher(internal::StringView s); // NOLINT }; -#endif // GTEST_INTERNAL_HAS_STRING_VIEW // Prints a matcher in a human-readable format. template @@ -812,9 +811,26 @@ class [[nodiscard]] ImplicitCastEqMatcher { StoredRhs stored_rhs_; }; -template >> -using StringLike = T; +// Dummy function (never defined) whose return type evaluates to std::string if +// the given type is a string-like type that can be converted to std::string, +// either directly or through an intermediate std::string_view. +template +extern std::enable_if_t, std::string> +ResolveAsString(const void* /* preferred */); + +#if GTEST_HAS_STD_WSTRING +// Same as above, but for std::wstring. In cases where both conversions are +// possible, this overload takes lower priority. +template +extern std::enable_if_t, std::wstring> +ResolveAsString(... /* fallback */); +#endif + +// Evaluates to the std::basic_string type that the given string-like type can +// be converted to. Prefers std::string over std::wstring if both are possible. +// Fails in a SFINAE-friendly way if no conversion was viable. +template +using StringType = decltype(ResolveAsString(nullptr)); // Implements polymorphic matchers MatchesRegex(regex) and // ContainsRegex(regex), which can be used as a Matcher as long as @@ -824,12 +840,10 @@ class [[nodiscard]] MatchesRegexMatcher { MatchesRegexMatcher(const RE* regex, bool full_match) : regex_(regex), full_match_(full_match) {} -#if GTEST_INTERNAL_HAS_STRING_VIEW bool MatchAndExplain(const internal::StringView& s, MatchResultListener* listener) const { return MatchAndExplain(std::string(s), listener); } -#endif // GTEST_INTERNAL_HAS_STRING_VIEW // Accepts pointer types, particularly: // const char* @@ -844,7 +858,7 @@ class [[nodiscard]] MatchesRegexMatcher { // Matches anything that can convert to std::string. // // This is a template, not just a plain function with const std::string&, - // because absl::string_view has some interfering non-explicit constructors. + // because std::string_view has some interfering non-explicit constructors. template bool MatchAndExplain(const MatcheeStringType& s, MatchResultListener* /* listener */) const { @@ -877,9 +891,10 @@ inline PolymorphicMatcher MatchesRegex( return MakePolymorphicMatcher(internal::MatchesRegexMatcher(regex, true)); } template -PolymorphicMatcher MatchesRegex( - const internal::StringLike& regex) { - return MatchesRegex(new internal::RE(std::string(regex))); +std::enable_if_t>, + PolymorphicMatcher> +MatchesRegex(const T& regex) { + return MatchesRegex(new internal::RE(internal::StringType(regex))); } // Matches a string that contains regular expression 'regex'. @@ -889,9 +904,10 @@ inline PolymorphicMatcher ContainsRegex( return MakePolymorphicMatcher(internal::MatchesRegexMatcher(regex, false)); } template -PolymorphicMatcher ContainsRegex( - const internal::StringLike& regex) { - return ContainsRegex(new internal::RE(std::string(regex))); +std::enable_if_t>, + PolymorphicMatcher> +ContainsRegex(const T& regex) { + return ContainsRegex(new internal::RE(internal::StringType(regex))); } // Creates a polymorphic matcher that matches anything equal to x. diff --git a/deps/googletest/include/gtest/gtest-printers.h b/deps/googletest/include/gtest/gtest-printers.h index fc0913ff0094..315ce0a51016 100644 --- a/deps/googletest/include/gtest/gtest-printers.h +++ b/deps/googletest/include/gtest/gtest-printers.h @@ -291,11 +291,9 @@ struct ConvertibleToIntegerPrinter { }; struct ConvertibleToStringViewPrinter { -#if GTEST_INTERNAL_HAS_STRING_VIEW static void PrintValue(internal::StringView value, ::std::ostream* os) { internal::UniversalPrint(value, os); } -#endif }; #ifdef GTEST_HAS_ABSL @@ -703,12 +701,12 @@ void PrintRawArrayTo(const T a[], size_t count, ::std::ostream* os) { } } -// Overloads for ::std::string and ::std::string_view -GTEST_API_ void PrintStringTo(::std::string_view s, ::std::ostream* os); +// Overloads for ::std::string and std::string_view +GTEST_API_ void PrintStringTo(std::string_view s, ::std::ostream* os); inline void PrintTo(const ::std::string& s, ::std::ostream* os) { PrintStringTo(s, os); } -inline void PrintTo(::std::string_view s, ::std::ostream* os) { +inline void PrintTo(std::string_view s, ::std::ostream* os) { PrintStringTo(s, os); } @@ -752,16 +750,14 @@ inline void PrintTo(::std::wstring_view s, ::std::ostream* os) { } #endif // GTEST_HAS_STD_WSTRING -#if GTEST_INTERNAL_HAS_STRING_VIEW // Overload for internal::StringView. Needed for build configurations where // internal::StringView is an alias for absl::string_view, but absl::string_view // is a distinct type from std::string_view. template , int> = 0> + std::enable_if_t, int> = 0> inline void PrintTo(internal::StringView sp, ::std::ostream* os) { PrintStringTo(sp, os); } -#endif // GTEST_INTERNAL_HAS_STRING_VIEW inline void PrintTo(std::nullptr_t, ::std::ostream* os) { *os << "(nullptr)"; } diff --git a/deps/googletest/include/gtest/internal/gtest-death-test-internal.h b/deps/googletest/include/gtest/internal/gtest-death-test-internal.h index f88e2049c249..f0f93e520b7b 100644 --- a/deps/googletest/include/gtest/internal/gtest-death-test-internal.h +++ b/deps/googletest/include/gtest/internal/gtest-death-test-internal.h @@ -43,6 +43,7 @@ #include #include +#include #include "gtest/gtest-matchers.h" #include "gtest/internal/gtest-internal.h" @@ -63,6 +64,10 @@ inline Matcher MakeDeathTestMatcher( ::testing::internal::RE regex) { return ContainsRegex(regex.pattern()); } +inline Matcher MakeDeathTestMatcher( + std::string_view regex) { + return ContainsRegex(regex); +} inline Matcher MakeDeathTestMatcher(const char* regex) { return ContainsRegex(regex); } diff --git a/deps/googletest/include/gtest/internal/gtest-internal.h b/deps/googletest/include/gtest/internal/gtest-internal.h index 2b048c5dc098..55e9966720bf 100644 --- a/deps/googletest/include/gtest/internal/gtest-internal.h +++ b/deps/googletest/include/gtest/internal/gtest-internal.h @@ -1451,13 +1451,13 @@ class [[nodiscard]] NeverThrown { // Implements Boolean test assertions such as EXPECT_TRUE. expression can be // either a boolean expression or an AssertionResult. text is a textual // representation of expression as it was passed into the EXPECT_TRUE. -#define GTEST_TEST_BOOLEAN_(expression, text, actual, expected, fail) \ - GTEST_AMBIGUOUS_ELSE_BLOCKER_ \ - if (::testing::internal::AssertionResultExpectation gtest_are_ = { \ - ::testing::AssertionResult(expression), expected}) \ - ; \ - else \ - fail(::testing::internal::GetBoolAssertionFailureMessage( \ +#define GTEST_TEST_BOOLEAN_(expression, text, actual, expected, fail) \ + GTEST_AMBIGUOUS_ELSE_BLOCKER_ \ + if (const ::testing::internal::AssertionResultExpectation gtest_are_ = { \ + ::testing::AssertionResult(expression), expected}) \ + ; \ + else /* NOLINT */ \ + fail(::testing::internal::GetBoolAssertionFailureMessage( \ gtest_are_.assertion_result, text, #actual, #expected)) #define GTEST_TEST_NO_FATAL_FAILURE_(statement, fail) \ diff --git a/deps/googletest/include/gtest/internal/gtest-port.h b/deps/googletest/include/gtest/internal/gtest-port.h index 31654b09c1dc..92e6591d2cec 100644 --- a/deps/googletest/include/gtest/internal/gtest-port.h +++ b/deps/googletest/include/gtest/internal/gtest-port.h @@ -293,9 +293,10 @@ #include #include #include +// #include // Guarded by GTEST_IS_THREADSAFE below #include #include -// #include // Guarded by GTEST_IS_THREADSAFE below +#include #include #include #include @@ -949,21 +950,21 @@ GTEST_API_ bool IsTrue(bool condition); #ifdef GTEST_USES_RE2 // This is almost `using RE = ::RE2`, except it is copy-constructible, and it -// needs to disambiguate the `std::string`, `absl::string_view`, and `const +// needs to disambiguate the `std::string`, `std::string_view`, and `const // char*` constructors. class GTEST_API_ [[nodiscard]] RE { public: - RE(absl::string_view regex) : regex_(regex) {} // NOLINT - RE(const char* regex) : RE(absl::string_view(regex)) {} // NOLINT - RE(const std::string& regex) : RE(absl::string_view(regex)) {} // NOLINT + RE(std::string_view regex) : regex_(regex) {} // NOLINT + RE(const char* regex) : RE(std::string_view(regex)) {} // NOLINT + RE(const std::string& regex) : RE(std::string_view(regex)) {} // NOLINT RE(const RE& other) : RE(other.pattern()) {} const std::string& pattern() const { return regex_.pattern(); } - static bool FullMatch(absl::string_view str, const RE& re) { + static bool FullMatch(std::string_view str, const RE& re) { return RE2::FullMatch(str, re.regex_); } - static bool PartialMatch(absl::string_view str, const RE& re) { + static bool PartialMatch(std::string_view str, const RE& re) { return RE2::PartialMatch(str, re.regex_); } @@ -2396,7 +2397,6 @@ const char* StringFromGTestEnv(const char* flag, const char* default_val); #ifdef GTEST_HAS_ABSL // Always use absl::string_view for Matcher<> specializations if googletest // is built with absl support. -#define GTEST_INTERNAL_HAS_STRING_VIEW 1 #include "absl/strings/string_view.h" namespace testing { namespace internal { @@ -2404,26 +2404,15 @@ using StringView = ::absl::string_view; } // namespace internal } // namespace testing #else -#if defined(__cpp_lib_string_view) || \ - (GTEST_INTERNAL_HAS_INCLUDE() && \ - GTEST_INTERNAL_CPLUSPLUS_LANG >= 201703L) // Otherwise for C++17 and higher use std::string_view for Matcher<> // specializations. -#define GTEST_INTERNAL_HAS_STRING_VIEW 1 -#include namespace testing { namespace internal { -using StringView = ::std::string_view; +using StringView = std::string_view; } // namespace internal } // namespace testing -// The case where absl is configured NOT to alias std::string_view is not -// supported. -#endif // __cpp_lib_string_view #endif // GTEST_HAS_ABSL - -#ifndef GTEST_INTERNAL_HAS_STRING_VIEW -#define GTEST_INTERNAL_HAS_STRING_VIEW 0 -#endif +#define GTEST_INTERNAL_HAS_STRING_VIEW 1 #if defined(__cpp_lib_three_way_comparison) #define GTEST_INTERNAL_HAS_COMPARE_LIB 1 diff --git a/deps/googletest/src/gtest-matchers.cc b/deps/googletest/src/gtest-matchers.cc index 7e3bcc0cff38..626019e2389f 100644 --- a/deps/googletest/src/gtest-matchers.cc +++ b/deps/googletest/src/gtest-matchers.cc @@ -59,7 +59,6 @@ Matcher::Matcher(const std::string& s) { *this = Eq(s); } // s. Matcher::Matcher(const char* s) { *this = Eq(std::string(s)); } -#if GTEST_INTERNAL_HAS_STRING_VIEW // Constructs a matcher that matches a const StringView& whose value is // equal to s. Matcher::Matcher(const std::string& s) { @@ -93,6 +92,5 @@ Matcher::Matcher(const char* s) { Matcher::Matcher(internal::StringView s) { *this = Eq(std::string(s)); } -#endif // GTEST_INTERNAL_HAS_STRING_VIEW } // namespace testing diff --git a/deps/googletest/src/gtest-printers.cc b/deps/googletest/src/gtest-printers.cc index 6d1de6d9506f..7c0ecc6ad1c9 100644 --- a/deps/googletest/src/gtest-printers.cc +++ b/deps/googletest/src/gtest-printers.cc @@ -515,13 +515,13 @@ bool IsValidUTF8(const char* str, size_t length) { void ConditionalPrintAsText(const char* str, size_t length, ostream* os) { if (!ContainsUnprintableControlCodes(str, length) && IsValidUTF8(str, length)) { - *os << "\n As Text: \"" << ::std::string_view(str, length) << "\""; + *os << "\n As Text: \"" << std::string_view(str, length) << "\""; } } } // anonymous namespace -void PrintStringTo(::std::string_view s, ostream* os) { +void PrintStringTo(std::string_view s, ostream* os) { if (PrintCharsAsStringTo(s.data(), s.size(), os) == kHexEscape) { if (GTEST_FLAG_GET(print_utf8)) { ConditionalPrintAsText(s.data(), s.size(), os); diff --git a/deps/googletest/src/gtest.cc b/deps/googletest/src/gtest.cc index 307ecc6f0b9c..3c855468268f 100644 --- a/deps/googletest/src/gtest.cc +++ b/deps/googletest/src/gtest.cc @@ -6930,7 +6930,7 @@ void ParseGoogleTestFlagsOnly(int* argc, char** argv) { std::vector positional_args; std::vector unrecognized_flags; absl::ParseAbseilFlagsOnly(*argc, argv, positional_args, unrecognized_flags); - absl::flat_hash_set unrecognized; + absl::flat_hash_set unrecognized; for (const auto& flag : unrecognized_flags) { unrecognized.insert(flag.flag_name); } From 69e4fabb29641566a3de03e3b8cbb4e8463bbe9a Mon Sep 17 00:00:00 2001 From: "Node.js GitHub Bot" Date: Mon, 10 Aug 2026 20:35:58 -0400 Subject: [PATCH 104/344] test: update WPT for urlpattern to 4832db4761 PR-URL: https://github.com/nodejs/node/pull/65151 Reviewed-By: Luigi Pinca Reviewed-By: Antoine du Hamel --- test/fixtures/wpt/urlpattern/WEB_FEATURES.yml | 5 ++--- test/fixtures/wpt/versions.json | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/test/fixtures/wpt/urlpattern/WEB_FEATURES.yml b/test/fixtures/wpt/urlpattern/WEB_FEATURES.yml index cb82ba5cda29..f6d02d253e41 100644 --- a/test/fixtures/wpt/urlpattern/WEB_FEATURES.yml +++ b/test/fixtures/wpt/urlpattern/WEB_FEATURES.yml @@ -1,3 +1,2 @@ -features: -- name: urlpattern - files: "**" +rules: +- "**": [urlpattern] diff --git a/test/fixtures/wpt/versions.json b/test/fixtures/wpt/versions.json index 4c01b3e53663..c423893c7a9a 100644 --- a/test/fixtures/wpt/versions.json +++ b/test/fixtures/wpt/versions.json @@ -76,7 +76,7 @@ "path": "url" }, "urlpattern": { - "commit": "5847ee5cfa4f710cbb78ab9ef5cc66f74433ee03", + "commit": "4832db47614f5f48cc57374cbf5c1f70937fad48", "path": "urlpattern" }, "user-timing": { From cc19107e5da1fdf715139231bb32473e0f14917a Mon Sep 17 00:00:00 2001 From: "Node.js GitHub Bot" Date: Mon, 10 Aug 2026 20:36:14 -0400 Subject: [PATCH 105/344] test: update WPT for WebCryptoAPI to 4c2fd05ed5 PR-URL: https://github.com/nodejs/node/pull/65150 Reviewed-By: Filip Skokan Reviewed-By: Chemi Atlow Reviewed-By: Luigi Pinca Reviewed-By: Colin Ihrig --- .../wpt/WebCryptoAPI/generateKey/failures.js | 140 +++++++++--------- .../failures_bad_algorithm.https.any.js | 5 + .../wpt/WebCryptoAPI/generateKey/successes.js | 21 +++ test/fixtures/wpt/versions.json | 2 +- 4 files changed, 98 insertions(+), 70 deletions(-) create mode 100644 test/fixtures/wpt/WebCryptoAPI/generateKey/failures_bad_algorithm.https.any.js diff --git a/test/fixtures/wpt/WebCryptoAPI/generateKey/failures.js b/test/fixtures/wpt/WebCryptoAPI/generateKey/failures.js index 2a618ce4ab25..e4a75c065152 100644 --- a/test/fixtures/wpt/WebCryptoAPI/generateKey/failures.js +++ b/test/fixtures/wpt/WebCryptoAPI/generateKey/failures.js @@ -1,6 +1,3 @@ -function run_test(algorithmNames) { - var subtle = crypto.subtle; // Change to test prefixed implementations - // These tests check that generateKey throws an error, and that // the error is of the right type, for a wide set of incorrect parameters. // @@ -19,42 +16,83 @@ function run_test(algorithmNames) { // helper functions that generate all possible test parameters for // different situations. - var testVectors = getGenerateKeyTestVectors(algorithmNames); +function parameterString(algorithm, extractable, usages) { + if (typeof algorithm !== "object" && typeof algorithm !== "string") { + alert(algorithm); + } + var result = "(" + + objectToString(algorithm) + ", " + + objectToString(extractable) + ", " + + objectToString(usages) + + ")"; - function parameterString(algorithm, extractable, usages) { - if (typeof algorithm !== "object" && typeof algorithm !== "string") { - alert(algorithm); - } + return result; +} - var result = "(" + - objectToString(algorithm) + ", " + - objectToString(extractable) + ", " + - objectToString(usages) + - ")"; +// Test that a given combination of parameters results in an error, +// AND that it is the correct kind of error. +// +// Expected error is either a number, tested against the error code, +// or a string, tested against the error name. +function testError(algorithm, extractable, usages, expectedError, testTag) { + promise_test(function(test) { + return crypto.subtle.generateKey(algorithm, extractable, usages) + .then(function(result) { + assert_unreached("Operation succeeded, but should not have"); + }, function(err) { + if (typeof expectedError === "number") { + assert_equals(err.code, expectedError, testTag + " not supported"); + } else { + assert_equals(err.name, expectedError, testTag + " not supported"); + } + }); + }, testTag + ": generateKey" + parameterString(algorithm, extractable, usages)); +} - return result; - } - // Test that a given combination of parameters results in an error, - // AND that it is the correct kind of error. - // - // Expected error is either a number, tested against the error code, - // or a string, tested against the error name. - function testError(algorithm, extractable, usages, expectedError, testTag) { - promise_test(function(test) { - return crypto.subtle.generateKey(algorithm, extractable, usages) - .then(function(result) { - assert_unreached("Operation succeeded, but should not have"); - }, function(err) { - if (typeof expectedError === "number") { - assert_equals(err.code, expectedError, testTag + " not supported"); - } else { - assert_equals(err.name, expectedError, testTag + " not supported"); - } +// Algorithm normalization happens before generateKey looks at any other +// argument, so these cases are independent of the algorithm under test and +// only need to run once for the whole suite. +function run_bad_algorithm_test() { + // Algorithm normalization should fail with "Not supported" + var badAlgorithmNames = [ + "AES", + {name: "AES"}, + {name: "AES", length: 128}, + {name: "AES-CMAC", length: 128}, // Removed after CR + {name: "AES-CFB", length: 128}, // Removed after CR + {name: "HMAC", hash: "MD5"}, + {name: "RSA", hash: "SHA-256", modulusLength: 2048, publicExponent: new Uint8Array([1,0,1])}, + {name: "RSA-PSS", hash: "SHA", modulusLength: 2048, publicExponent: new Uint8Array([1,0,1])}, + {name: "EC", namedCurve: "P521"} + ]; + + + // Algorithm normalization failures should be found first + // - all other parameters can be good or bad, should fail + // due to NotSupportedError. + badAlgorithmNames.forEach(function(algorithm) { + allValidUsages(["decrypt", "sign", "deriveBits"], true, []) // Small search space, shouldn't matter because should fail before used + .forEach(function(usages) { + [false, true, "RED", 7].forEach(function(extractable){ + testError(algorithm, extractable, usages, "NotSupportedError", "Bad algorithm"); }); - }, testTag + ": generateKey" + parameterString(algorithm, extractable, usages)); - } + }); + }); + + // Empty algorithm should fail with TypeError + allValidUsages(["decrypt", "sign", "deriveBits"], true, []) // Small search space, shouldn't matter because should fail before used + .forEach(function(usages) { + [false, true, "RED", 7].forEach(function(extractable){ + testError({}, extractable, usages, "TypeError", "Empty algorithm"); + }); + }); +} + + +function run_test(algorithmNames) { + var testVectors = getGenerateKeyTestVectors(algorithmNames); // Given an algorithm name, create several invalid parameters. @@ -108,45 +146,9 @@ function run_test(algorithmNames) { // Now test for properly handling errors -// - Unsupported algorithm // - Bad usages for algorithm // - Bad key lengths - // Algorithm normalization should fail with "Not supported" - var badAlgorithmNames = [ - "AES", - {name: "AES"}, - {name: "AES", length: 128}, - {name: "AES-CMAC", length: 128}, // Removed after CR - {name: "AES-CFB", length: 128}, // Removed after CR - {name: "HMAC", hash: "MD5"}, - {name: "RSA", hash: "SHA-256", modulusLength: 2048, publicExponent: new Uint8Array([1,0,1])}, - {name: "RSA-PSS", hash: "SHA", modulusLength: 2048, publicExponent: new Uint8Array([1,0,1])}, - {name: "EC", namedCurve: "P521"} - ]; - - - // Algorithm normalization failures should be found first - // - all other parameters can be good or bad, should fail - // due to NotSupportedError. - badAlgorithmNames.forEach(function(algorithm) { - allValidUsages(["decrypt", "sign", "deriveBits"], true, []) // Small search space, shouldn't matter because should fail before used - .forEach(function(usages) { - [false, true, "RED", 7].forEach(function(extractable){ - testError(algorithm, extractable, usages, "NotSupportedError", "Bad algorithm"); - }); - }); - }); - - // Empty algorithm should fail with TypeError - allValidUsages(["decrypt", "sign", "deriveBits"], true, []) // Small search space, shouldn't matter because should fail before used - .forEach(function(usages) { - [false, true, "RED", 7].forEach(function(extractable){ - testError({}, extractable, usages, "TypeError", "Empty algorithm"); - }); - }); - - // Algorithms normalize okay, but usages bad (though not empty). // It shouldn't matter what other extractable is. Should fail // due to SyntaxError diff --git a/test/fixtures/wpt/WebCryptoAPI/generateKey/failures_bad_algorithm.https.any.js b/test/fixtures/wpt/WebCryptoAPI/generateKey/failures_bad_algorithm.https.any.js new file mode 100644 index 000000000000..5fe0b15784e3 --- /dev/null +++ b/test/fixtures/wpt/WebCryptoAPI/generateKey/failures_bad_algorithm.https.any.js @@ -0,0 +1,5 @@ +// META: title=WebCryptoAPI: generateKey() for Failures +// META: timeout=long +// META: script=../util/helpers.js +// META: script=failures.js +run_bad_algorithm_test(); diff --git a/test/fixtures/wpt/WebCryptoAPI/generateKey/successes.js b/test/fixtures/wpt/WebCryptoAPI/generateKey/successes.js index 5006cd35ad9c..c70ecb331873 100644 --- a/test/fixtures/wpt/WebCryptoAPI/generateKey/successes.js +++ b/test/fixtures/wpt/WebCryptoAPI/generateKey/successes.js @@ -110,6 +110,27 @@ function run_test(algorithmNames, slowTest) { assert_unreached("exportKey threw an unexpected error: " + err.toString()); }) }, testTag + ": generateKey" + parameterString(algorithm, extractable, usages)); + + // Special case for ECDH and ECDSA: check that the generated key length is consistent. + // Particularly for P-521, there is a high risk of the generated key being one byte short + // if the implementation isn't careful. + if (algorithm.namedCurve && extractable) { + promise_test(async function(test) { + // We run about 20 variants of this test, times 10 key generations below, + // so this should have a decent chance of catching issues. + await Promise.all(Array.from({ length: 10 }).map(async () => { + const { privateKey, publicKey } = await subtle.generateKey(algorithm, extractable, usages); + const [jwkPub, jwkPriv] = await Promise.all([ + subtle.exportKey('jwk', publicKey), + subtle.exportKey('jwk', privateKey), + ]); + const expectedLength = Math.ceil(Math.ceil(parseInt(algorithm.namedCurve.substring(2)) / 8) * 4/3); + assert_equals(jwkPub.x.length, expectedLength, "Public key value x has correct length"); + assert_equals(jwkPub.y.length, expectedLength, "Public key value y has correct length"); + assert_equals(jwkPriv.d.length, expectedLength, "Private key value d has correct length"); + })); + }, testTag + ": generateKey" + parameterString(algorithm, extractable, usages) + " produces consistent length key"); + } } // Test all valid sets of parameters for successful diff --git a/test/fixtures/wpt/versions.json b/test/fixtures/wpt/versions.json index c423893c7a9a..b659c868d498 100644 --- a/test/fixtures/wpt/versions.json +++ b/test/fixtures/wpt/versions.json @@ -96,7 +96,7 @@ "path": "web-locks" }, "WebCryptoAPI": { - "commit": "82c3d9069cf2e93e5528a1f428fa122bd9af651d", + "commit": "4c2fd05ed5d0b90a9e1fcdcb35f6671bd461de0d", "path": "WebCryptoAPI" }, "webidl": { From f45dc929136261392a9139addc08712be4619200 Mon Sep 17 00:00:00 2001 From: "Node.js GitHub Bot" Date: Mon, 10 Aug 2026 20:36:25 -0400 Subject: [PATCH 106/344] deps: update undici to 8.10.0 PR-URL: https://github.com/nodejs/node/pull/65155 Reviewed-By: Chemi Atlow Reviewed-By: Filip Skokan Reviewed-By: Colin Ihrig Reviewed-By: Trivikram Kamat --- deps/undici/src/docs/docs/api/Client.md | 29 +- deps/undici/src/lib/api/readable.js | 76 ++--- deps/undici/src/lib/core/connect.js | 18 +- deps/undici/src/lib/core/symbols.js | 1 + deps/undici/src/lib/dispatcher/client-h1.js | 6 +- deps/undici/src/lib/dispatcher/client-h2.js | 189 +++++++++- deps/undici/src/lib/dispatcher/client.js | 103 ++++-- .../lib/dispatcher/env-http-proxy-agent.js | 34 +- .../src/lib/dispatcher/socks5-proxy-agent.js | 16 +- deps/undici/src/lib/handler/retry-handler.js | 9 +- deps/undici/src/lib/interceptor/cache.js | 7 +- .../undici/src/lib/interceptor/deduplicate.js | 2 +- deps/undici/src/lib/llhttp/wasm_build_env.txt | 2 +- deps/undici/src/lib/mock/mock-utils.js | 237 +++++++++++-- deps/undici/src/lib/util/cache.js | 6 +- .../undici/src/lib/web/websocket/websocket.js | 22 ++ deps/undici/src/package-lock.json | 4 +- deps/undici/src/package.json | 2 +- deps/undici/src/types/client.d.ts | 40 +++ deps/undici/undici.js | 323 ++++++++++++++---- src/undici_version.h | 2 +- 21 files changed, 916 insertions(+), 212 deletions(-) diff --git a/deps/undici/src/docs/docs/api/Client.md b/deps/undici/src/docs/docs/api/Client.md index dc6ab7a6d5f9..d48b2303f287 100644 --- a/deps/undici/src/docs/docs/api/Client.md +++ b/deps/undici/src/docs/docs/api/Client.md @@ -111,22 +111,35 @@ added: v1.0.0 `autoSelectFamily` is enabled. **Default:** `250`. * `allowH2` {boolean} Enables HTTP/2 support when the server assigns it a higher priority through ALPN negotiation. **Default:** `true`. - * `useH2c` {boolean} Enforces h2c (HTTP/2 cleartext) for non-HTTPS - connections. **Default:** `false`. - * `maxConcurrentStreams` {number} The maximum number of concurrent HTTP/2 + * `useH2c` {boolean} _Deprecated: use h2Options.useH2c instead_ Enforces h2c (HTTP/2 cleartext) for non-HTTPS + connections. **Default:** `false`. + * `maxConcurrentStreams` {number} _Deprecated: use h2Options.useH2c instead_ The maximum number of concurrent HTTP/2 streams for a single session. Once h2 is negotiated this — not `pipelining`, which is HTTP/1.1 only — is the ceiling used to dispatch in-flight requests. It may be overridden by the server's `SETTINGS_MAX_CONCURRENT_STREAMS` frame. **Default:** `100`. - * `initialWindowSize` {number} The HTTP/2 stream-level flow-control window - size (`SETTINGS_INITIAL_WINDOW_SIZE`). Must be a positive integer. - **Default:** `262144`. - * `connectionWindowSize` {number} The HTTP/2 connection-level flow-control + * `connectionWindowSize` {number} _Deprecated: use h2Options.connectionWindowSize instead_ The HTTP/2 connection-level flow-control window size set via `ClientHttp2Session.setLocalWindowSize()`. Must be a positive integer. **Default:** `524288`. - * `pingInterval` {number} The time interval, in milliseconds, between HTTP/2 + * `pingInterval` {number} _Deprecated: use h2Options.pingInterval instead_ The time interval, in milliseconds, between HTTP/2 PING frames. Set to `0` to disable PING frames. Applies only to HTTP/2 connections and emits a `ping` event on the client. **Default:** `60e3`. + * `h2Options` {object} Set of options for HTTP/2 sessions + * `useH2c` {boolean} Enforces h2c (HTTP/2 cleartext) for non-HTTPS + connections. **Default:** `false`. + * `maxConcurrentStreams` {number} The maximum number of concurrent HTTP/2 + streams for a single session. Once h2 is negotiated this — not `pipelining`, + which is HTTP/1.1 only — is the ceiling used to dispatch in-flight requests. + It may be overridden by the server's `SETTINGS_MAX_CONCURRENT_STREAMS` + frame. **Default:** `100`. + * `connectionWindowSize` {number} The HTTP/2 connection-level flow-control + window size set via `ClientHttp2Session.setLocalWindowSize()`. Must be a + positive integer. **Default:** `524288`. + * `pingInterval` {number} The time interval, in milliseconds, between HTTP/2 + PING frames. Set to `0` to disable PING frames. Applies only to HTTP/2 + connections and emits a `ping` event on the client. **Default:** `60e3`. + * `settings` {object} `SETTINGS` frame options. For full reference, take a + look to [HTTP/2#Settings Object](https://nodejs.org/api/http2.html#settings-object) * `webSocket` {Object} (optional) WebSocket-specific configuration. * `maxFragments` {number} The maximum number of fragments in a message. Set to `0` to disable the limit. **Default:** `131072`. diff --git a/deps/undici/src/lib/api/readable.js b/deps/undici/src/lib/api/readable.js index 71d90d457b34..e3dd3dcce4b7 100644 --- a/deps/undici/src/lib/api/readable.js +++ b/deps/undici/src/lib/api/readable.js @@ -15,7 +15,6 @@ const kContentType = Symbol('kContentType') const kContentLength = Symbol('kContentLength') const kUsed = Symbol('kUsed') const kBytesRead = Symbol('kBytesRead') -const kPreservedBuffer = Symbol('kPreservedBuffer') const noop = () => {} @@ -326,36 +325,14 @@ class BodyReadable extends Readable { */ setEncoding (encoding) { if (Buffer.isEncoding(encoding)) { - // Preserve raw Buffer chunks for the consume path (body.text(), - // body.json(), etc.) before super.setEncoding() replaces them - // with decoded strings. Without this, the consume path would - // lose access to the original bytes — some of which may be held - // by the decoder for incomplete multi-byte sequences, and the - // rest converted to strings that can't be safely concatenated - // byte-wise. - const state = this._readableState - const buffer = state.buffer - if (buffer && state.length > 0) { - const bufferIndex = state.bufferIndex ?? 0 - const preserved = [] - const source = typeof buffer.slice === 'function' - ? buffer.slice(bufferIndex) - : buffer - for (const data of source) { - if (Buffer.isBuffer(data)) { - preserved.push(data) - } - } - if (preserved.length > 0) { - this[kPreservedBuffer] = (this[kPreservedBuffer] || []).concat(preserved) - } - } - // Delegate to Node.js Readable.setEncoding() which initializes a // StringDecoder and re-encodes already-buffered chunks. This properly // handles multi-byte sequences split at chunk boundaries for the // for-await / on('data') paths. Without this, Node.js uses // buf.toString(encoding) on each chunk, producing U+FFFD for split chars. + // + // The consume path (body.text(), body.json(), ...) copes with the + // decoded strings this leaves in state.buffer, see consumeStart(). super.setEncoding(encoding) } return this @@ -464,17 +441,7 @@ function consumeStart (consume) { const { _readableState: state } = consume.stream - // If setEncoding() was called, state.buffer may contain decoded strings - // (which would break Buffer.concat in chunksDecode). Use the preserved - // raw Buffers (saved before super.setEncoding() in setEncoding()) for - // byte-level accurate consumption. Otherwise read from state.buffer. - const preserved = consume.stream[kPreservedBuffer] - if (preserved && preserved.length > 0) { - for (const chunk of preserved) { - consumePush(consume, chunk) - } - consume.stream[kPreservedBuffer] = null - } else if (state.bufferIndex) { + if (state.bufferIndex) { const start = state.bufferIndex const end = state.buffer.length for (let n = start; n < end; n++) { @@ -486,14 +453,29 @@ function consumeStart (consume) { } } + // If setEncoding() was called, state.buffer holds decoded strings, which + // consumePush() turns back into bytes. The trailing bytes of a multi-byte + // sequence split across a chunk boundary are not part of any of those + // strings, they are held inside the decoder until the rest arrives, so + // take them from there. + const decoder = state.decoder + if (decoder != null && decoder.lastNeed > 0) { + consumePush(consume, Buffer.from(decoder.lastChar.subarray(0, decoder.lastTotal - decoder.lastNeed))) + } + if (state.endEmitted) { - consumeEnd(this[kConsume], this._readableState.encoding) - } else { - consume.stream.on('end', function () { - consumeEnd(this[kConsume], this._readableState.encoding) - }) + // No `this` to read the consume off here: consumeStart is a free function, called from + // the queueMicrotask above. The callback below does have one, because the emitter passes + // the stream as its receiver. Returning matters too - consumeEnd() clears consume.stream, + // which the resume() below would then dereference. + consumeEnd(consume, state.encoding) + return } + consume.stream.on('end', function () { + consumeEnd(this[kConsume], this._readableState.encoding) + }) + consume.stream.resume() while (consume.stream.read() != null) { @@ -583,7 +565,7 @@ function consumeEnd (consume, encoding) { /** * @param {Consume} consume - * @param {Buffer} chunk + * @param {Buffer|string} chunk * @returns {void} */ function consumePush (consume, chunk) { @@ -591,6 +573,14 @@ function consumePush (consume, chunk) { return } + if (typeof chunk === 'string') { + // Buffered before the consume started, while an encoding was set. + // consume.length has to stay a byte count and chunksDecode()/chunksConcat() + // only work on bytes, so re-encode. A string's own length is in UTF-16 code + // units and Uint8Array.prototype.set() ignores a string argument entirely. + chunk = Buffer.from(chunk, consume.stream._readableState.encoding) + } + consume.length += chunk.length consume.body.push(chunk) } diff --git a/deps/undici/src/lib/core/connect.js b/deps/undici/src/lib/core/connect.js index ad962c31944a..f729dfb0526d 100644 --- a/deps/undici/src/lib/core/connect.js +++ b/deps/undici/src/lib/core/connect.js @@ -105,13 +105,27 @@ function buildConnector ({ allowH2, preferH2, useH2c, maxCachedSessions, socketP port = port || 80 - socket = net.connect({ + const connectOptions = { highWaterMark: 64 * 1024, // Same as nodejs fs streams. ...options, localAddress, port, host: hostname - }) + } + + const family = net.isIP(hostname) + if (family !== 0 && servername && servername !== hostname) { + connectOptions.host = servername + connectOptions.lookup = (_hostname, lookupOptions, cb) => { + if (lookupOptions.all) { + cb(null, [{ address: hostname, family }]) + } else { + cb(null, hostname, family) + } + } + } + + socket = net.connect(connectOptions) if (useH2c === true) { socket.alpnProtocol = 'h2' } diff --git a/deps/undici/src/lib/core/symbols.js b/deps/undici/src/lib/core/symbols.js index 8bad25eed9fd..badecb709086 100644 --- a/deps/undici/src/lib/core/symbols.js +++ b/deps/undici/src/lib/core/symbols.js @@ -56,6 +56,7 @@ module.exports = { kCounter: Symbol('socket request counter'), kMaxResponseSize: Symbol('max response size'), kHTTP2Session: Symbol('http2Session'), + kHTTP2Options: Symbol('http2 options'), kHTTP2SessionState: Symbol('http2Session state'), kRetryHandlerDefaultRetry: Symbol('retry agent default retry'), kConstruct: Symbol('constructable'), diff --git a/deps/undici/src/lib/dispatcher/client-h1.js b/deps/undici/src/lib/dispatcher/client-h1.js index abf381f65a46..9f6f17c1579b 100644 --- a/deps/undici/src/lib/dispatcher/client-h1.js +++ b/deps/undici/src/lib/dispatcher/client-h1.js @@ -1052,7 +1052,7 @@ function onSocketClose () { function clearIdleSocketValidation (socket) { if (socket[kIdleSocketValidationTimeout]) { - clearImmediate(socket[kIdleSocketValidationTimeout]) + clearTimeout(socket[kIdleSocketValidationTimeout]) socket[kIdleSocketValidationTimeout] = null } @@ -1061,14 +1061,14 @@ function clearIdleSocketValidation (socket) { function scheduleIdleSocketValidation (client, socket) { socket[kIdleSocketValidation] = 1 - socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = setTimeout(() => { socket[kIdleSocketValidationTimeout] = null socket[kIdleSocketValidation] = 2 if (client[kSocket] === socket && !socket.destroyed) { client[kResume]() } - }) + }, 0) socket[kIdleSocketValidationTimeout].unref?.() } diff --git a/deps/undici/src/lib/dispatcher/client-h2.js b/deps/undici/src/lib/dispatcher/client-h2.js index 19622db68ace..bc401008f004 100644 --- a/deps/undici/src/lib/dispatcher/client-h2.js +++ b/deps/undici/src/lib/dispatcher/client-h2.js @@ -26,10 +26,7 @@ const { kStrictContentLength, kOnError, kMaxConcurrentStreams, - kPingInterval, kHTTP2Session, - kHTTP2InitialWindowSize, - kHTTP2ConnectionWindowSize, kHostAuthority, kResume, kSize, @@ -41,7 +38,8 @@ const { kEnableConnectProtocol, kRemoteSettings, kHTTP2Stream, - kHTTP2SessionState + kHTTP2SessionState, + kHTTP2Options } = require('../core/symbols.js') const { channels } = require('../core/diagnostics.js') @@ -51,6 +49,14 @@ const kRequestStream = Symbol('request stream') const kRequestStreamCleanup = Symbol('request stream cleanup') const kRequestStreamState = Symbol('request stream state') const kReceivedGoAway = Symbol('received goaway') +const kGoAwayReplayAttempts = Symbol('goaway replay attempts') +const kRefusedStreamRetry = Symbol('refused stream retry') + +// RFC 9113 section 8.7: a client SHOULD NOT automatically retry a request more +// than once. Without a budget a peer that keeps refusing turns one request into +// an unbounded connect/refuse/reconnect loop that never settles and starves the +// event loop. +const MAX_GOAWAY_REPLAY_ATTEMPTS = 1 let extractBody @@ -179,12 +185,24 @@ function completeRequest (client, request, resetPendingIdx = false) { } } -function canRetryRequestAfterGoAway (request) { +function canReplayRequest (request) { const { body } = request return body == null || util.isBuffer(body) || util.isBlobLike(body) } +// Count a GOAWAY refusal against the request's replay budget. A peer that +// refuses every connection must eventually surface an error to the caller +// rather than being retried forever. Kept separate from canReplayRequest so +// that the REFUSED_STREAM retry, which has its own single-attempt limit, does +// not consume this budget just by asking whether the body can be replayed. +function registerGoAwayRefusal (request) { + const attempts = (request[kGoAwayReplayAttempts] ?? 0) + 1 + request[kGoAwayReplayAttempts] = attempts + + return attempts <= MAX_GOAWAY_REPLAY_ATTEMPTS +} + function closeStream (stream, code = NGHTTP2_REFUSED_STREAM) { if (stream != null && !stream.destroyed && !stream.closed) { try { @@ -197,19 +215,44 @@ function detachRequestStreamForClose (request) { const stream = request[kRequestStream] clearRequestStream(request) + severRequestStream(stream) return stream } +// Unbind a stream from its request for good. releaseRequestStream() alone +// leaves the 'close' listener attached and kRequestStreamState populated, so a +// stream abandoned here would still run completeRequestStream() later — and +// splice out the request that has since been requeued onto another session. +function severRequestStream (stream) { + if (stream == null || stream[kRequestStreamState] == null) { + return + } + + stream[kRequestStreamState] = null + stream.off('close', completeRequestStream) + // Upgrade streams use their own close cleanup, which would otherwise release + // the session a second time after the stream has been severed for GOAWAY. + stream.off('close', onUpgradeStreamClose) + + if (stream[kHTTP2Session] != null) { + closeStreamSession(stream) + } + + if (!stream.destroyed && !stream.closed) { + stream.once('error', noop) + } +} + function connectH2 (client, socket) { client[kSocket] = socket - const http2InitialWindowSize = client[kHTTP2InitialWindowSize] - const http2ConnectionWindowSize = client[kHTTP2ConnectionWindowSize] + const http2InitialWindowSize = client[kHTTP2Options].sessionOptions?.initialWindowSize + const http2ConnectionWindowSize = client[kHTTP2Options].connectionWindowSize const session = http2.connect(client[kUrl], { createConnection: () => socket, - peerMaxConcurrentStreams: client[kMaxConcurrentStreams], + peerMaxConcurrentStreams: client[kHTTP2Options].maxConcurrentStreams, settings: { // TODO(metcoder95): add support for PUSH enablePush: false, @@ -223,13 +266,16 @@ function connectH2 (client, socket) { session[kSocket] = socket session[kHTTP2SessionState] = { idleTimeout: null, + // Armed while the peer advertises MAX_CONCURRENT_STREAMS = 0 and we have + // work that cannot start. See setNoStreamsTimeout. + noStreamsTimeout: null, // Sockets start out ref'd. Session ref/unref proxies to the socket, so a // single cached flag lets us skip redundant uv ref/unref calls, provided // every ref/unref of the session or its socket goes through // refH2Session/unrefH2Session. refed: true, ping: { - interval: client[kPingInterval] === 0 ? null : setInterval(onHttp2SendPing, client[kPingInterval], session).unref() + interval: client[kHTTP2Options].pingInterval === 0 ? null : setInterval(onHttp2SendPing, client[kHTTP2Options].pingInterval, session).unref() } } session[kReceivedGoAway] = false @@ -369,7 +415,74 @@ function resumeH2 (client) { } else { clearHttp2IdleTimeout(session) } + + if (client[kMaxConcurrentStreams] === 0 && client[kRunning] === 0 && client[kPending] > 0) { + setNoStreamsTimeout(session) + } else { + clearNoStreamsTimeout(session) + } + } +} + +function clearNoStreamsTimeout (session) { + const state = session[kHTTP2SessionState] + + if (state?.noStreamsTimeout != null) { + clearTimeout(state.noStreamsTimeout) + state.noStreamsTimeout = null + } +} + +// A peer is allowed to advertise SETTINGS_MAX_CONCURRENT_STREAMS = 0 to refuse +// new streams (RFC 9113 §6.5.2), and is expected to raise it again later. Until +// it does, busy() reports the client as permanently busy and queued requests +// cannot open a stream — which means no per-stream timeout covers them, and no +// reconnect can happen either, so the SETTINGS frame that would lift the limit +// can never arrive. Give the peer headersTimeout to start honouring requests +// before failing them; a request that cannot even be sent has missed the same +// deadline as one whose headers never arrive. +function setNoStreamsTimeout (session) { + const client = session[kClient] + const state = session[kHTTP2SessionState] + const timeout = client[kHeadersTimeout] + + if (!timeout || state.noStreamsTimeout != null) { + return + } + + state.noStreamsTimeout = setTimeout(onNoStreamsTimeout, timeout, session).unref() +} + +function onNoStreamsTimeout (session) { + const client = session[kClient] + const state = session[kHTTP2SessionState] + + state.noStreamsTimeout = null + + if ( + client[kHTTP2Session] !== session || + client[kMaxConcurrentStreams] !== 0 || + client[kRunning] !== 0 || + client[kPending] === 0 + ) { + return + } + + const err = new HeadersTimeoutError( + `HTTP/2: server did not accept a new stream within ${client[kHeadersTimeout]}` + ) + + const requests = client[kQueue].splice(client[kPendingIdx]) + for (let i = 0; i < requests.length; i++) { + if (requests[i] != null) { + util.errorRequest(client, requests[i], err) + } } + + // Drop the unusable session so the next request gets a fresh connection, + // whose SETTINGS may well allow streams again. + session[kError] = err + resetHttp2Session(session, err) } function clearHttp2IdleTimeout (session) { @@ -527,7 +640,7 @@ function onHttp2SessionGoAway (errorCode, lastStreamID) { if (request != null) { streamsToClose.push(detachRequestStreamForClose(request)) - if (canRetryRequestAfterGoAway(request)) { + if (canReplayRequest(request) && registerGoAwayRefusal(request)) { retriableRequests.push(request) } else { util.errorRequest(client, request, err) @@ -552,6 +665,7 @@ function onHttp2SessionGoAway (errorCode, lastStreamID) { } clearHttp2IdleTimeout(this) + clearNoStreamsTimeout(this) if (!this.closed && !this.destroyed) { this.close() @@ -576,6 +690,7 @@ function onHttp2SessionClose () { } clearHttp2IdleTimeout(this) + clearNoStreamsTimeout(this) if (state.ping.interval != null) { clearInterval(state.ping.interval) @@ -687,6 +802,16 @@ function completeRequestStream () { if (state.pendingEnd && !state.request.aborted && !state.request.completed) { state.request.onResponseEnd(state.trailers || {}) + } else if (!state.request.aborted && !state.request.completed) { + // The stream closed without a complete response and without reporting an + // error. finalizeRequest() below frees the queue slot either way, so + // without this the request would simply vanish and its caller would never + // hear back. + util.errorRequest( + state.client, + state.request, + new InformationalError('HTTP/2: stream closed before the response was complete') + ) } finalizeRequest(state) @@ -1286,6 +1411,38 @@ function onEnd () { } } +function retryRefusedStream (stream, state) { + const { client, request } = state + + if ( + state.responseReceived || + request.aborted || + request.completed || + request[kRefusedStreamRetry] || + !canReplayRequest(request) + ) { + return false + } + + // RFC 9113 section 8.7 permits retrying REFUSED_STREAM, but says clients + // SHOULD NOT automatically retry the same request more than once. + request[kRefusedStreamRetry] = true + + // Detach the failed attempt before moving the request back to the pending + // queue. The peer only reset this stream, so the HTTP/2 session remains + // usable for the retry. Severing also drops the 'close' listener, so the + // abandoned stream cannot later complete the retried request. + detachRequestStreamForClose(request) + state.stream = null + state.requestFinalized = true + + completeRequest(client, request) + client[kQueue].splice(client[kPendingIdx], 0, request) + client[kResume]() + + return true +} + function onError (err) { const stream = this const state = stream[kRequestStreamState] @@ -1295,6 +1452,18 @@ function onError (err) { } stream.off('error', onError) + + if (typeof stream.rstCode === 'number' && stream.rstCode !== NGHTTP2_NO_ERROR) { + err.http2ErrorCode = stream.rstCode + } + + if ( + stream.rstCode === NGHTTP2_REFUSED_STREAM && + retryRefusedStream(stream, state) + ) { + return + } + state.abort(err) } diff --git a/deps/undici/src/lib/dispatcher/client.js b/deps/undici/src/lib/dispatcher/client.js index 8a4f65171bd9..d620e8310cfb 100644 --- a/deps/undici/src/lib/dispatcher/client.js +++ b/deps/undici/src/lib/dispatcher/client.js @@ -53,10 +53,8 @@ const { kHTTPContext, kMaxConcurrentStreams, kHostAuthority, - kHTTP2InitialWindowSize, - kHTTP2ConnectionWindowSize, kResume, - kPingInterval + kHTTP2Options } = require('../core/symbols.js') const connectH1 = require('./client-h1.js') const connectH2 = require('./client-h2.js') @@ -76,6 +74,16 @@ function getPipelining (client) { return client[kPipelining] ?? client[kHTTPContext]?.defaultPipelining ?? 1 } +let h2NamespaceOptsWarning = false +function emitH2OptionsNamespaceWarning (optName) { + if (h2NamespaceOptsWarning === true) return + + process.emitWarning(`Use h2Options.${optName} instead. ${optName} for H2 will be deprecated in future major.`, { + code: 'UNDICI-H2-OPTIONS' + }) + h2NamespaceOptsWarning = true +} + // Protocol-aware dispatch ceiling. h1 RFC7230 pipelining is unrelated to h2 // stream multiplexing — over h2 the ceiling is the (server-confirmed) // maxConcurrentStreams. Before a context is attached we use the h1 @@ -128,7 +136,8 @@ class Client extends DispatcherBase { initialWindowSize, connectionWindowSize, pingInterval, - webSocket + webSocket, + h2Options } = {}) { if (keepAlive !== undefined) { throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead') @@ -216,24 +225,55 @@ class Client extends DispatcherBase { throw new InvalidArgumentError('allowH2 must be a valid boolean value') } - if (maxConcurrentStreams != null && (typeof maxConcurrentStreams !== 'number' || maxConcurrentStreams < 1)) { - throw new InvalidArgumentError('maxConcurrentStreams must be a positive integer, greater than 0') - } + // We validate only if allowH2 is enabled or null (enabled by default) + if (allowH2 !== false) { + // Prioritise new h2Options object, otherwise fallback to prior configuration options + if (h2Options != null) { + if (h2Options.useH2c != null && typeof h2Options.useH2c !== 'boolean') { + throw new InvalidArgumentError('h2Options.useH2c must be a valid boolean value') + } - if (useH2c != null && typeof useH2c !== 'boolean') { - throw new InvalidArgumentError('useH2c must be a valid boolean value') - } + if (h2Options.settings?.initialWindowSize != null && (!Number.isInteger(h2Options.settings.initialWindowSize) || h2Options.settings.initialWindowSize < 1)) { + throw new InvalidArgumentError('h2Options.settings.initialWindowSize must be a positive integer, greater than 0') + } - if (initialWindowSize != null && (!Number.isInteger(initialWindowSize) || initialWindowSize < 1)) { - throw new InvalidArgumentError('initialWindowSize must be a positive integer, greater than 0') - } + if (h2Options.maxConcurrentStreams != null && (!Number.isInteger(h2Options.connectionWindowSize) || h2Options.maxConcurrentStreams < 1)) { + throw new InvalidArgumentError('h2Options.maxConcurrentStreams must be a positive integer, greater than 0') + } - if (connectionWindowSize != null && (!Number.isInteger(connectionWindowSize) || connectionWindowSize < 1)) { - throw new InvalidArgumentError('connectionWindowSize must be a positive integer, greater than 0') - } + if (h2Options.connectionWindowSize != null && (!Number.isInteger(h2Options.connectionWindowSize) || h2Options.connectionWindowSize < 1)) { + throw new InvalidArgumentError('h2Options.connectionWindowSize must be a positive integer, greater than 0') + } + + if (h2Options.pingInterval != null && (typeof h2Options.pingInterval !== 'number' || !Number.isInteger(h2Options.pingInterval) || h2Options.pingInterval < 0)) { + throw new InvalidArgumentError('h2Options.pingInterval must be a positive integer, greater or equal to 0') + } + } else { + if (useH2c != null && typeof useH2c !== 'boolean') { + emitH2OptionsNamespaceWarning('useH2c') + throw new InvalidArgumentError('useH2c must be a valid boolean value') + } + + if (maxConcurrentStreams != null && (typeof maxConcurrentStreams !== 'number' || maxConcurrentStreams < 1)) { + emitH2OptionsNamespaceWarning('maxConcurrentStreams') + throw new InvalidArgumentError('maxConcurrentStreams must be a positive integer, greater than 0') + } - if (pingInterval != null && (typeof pingInterval !== 'number' || !Number.isInteger(pingInterval) || pingInterval < 0)) { - throw new InvalidArgumentError('pingInterval must be a positive integer, greater or equal to 0') + if (initialWindowSize != null && (!Number.isInteger(initialWindowSize) || initialWindowSize < 1)) { + emitH2OptionsNamespaceWarning('initialWindowSize') + throw new InvalidArgumentError('initialWindowSize must be a positive integer, greater than 0') + } + + if (connectionWindowSize != null && (!Number.isInteger(connectionWindowSize) || connectionWindowSize < 1)) { + emitH2OptionsNamespaceWarning('connectionWindowSize') + throw new InvalidArgumentError('connectionWindowSize must be a positive integer, greater than 0') + } + + if (pingInterval != null && (typeof pingInterval !== 'number' || !Number.isInteger(pingInterval) || pingInterval < 0)) { + emitH2OptionsNamespaceWarning('pingInterval') + throw new InvalidArgumentError('pingInterval must be a positive integer, greater or equal to 0') + } + } } super({ webSocket }) @@ -243,8 +283,8 @@ class Client extends DispatcherBase { ...tls, maxCachedSessions, allowH2, - useH2c, socketPath, + useH2c: h2Options?.useH2c ?? useH2c, timeout: connectTimeout, ...(typeof autoSelectFamily === 'boolean' ? { autoSelectFamily, autoSelectFamilyAttemptTimeout } : undefined), ...connect @@ -280,16 +320,20 @@ class Client extends DispatcherBase { this[kMaxResponseSize] = maxResponseSize > -1 ? maxResponseSize : -1 this[kHTTPContext] = null // h2 - this[kMaxConcurrentStreams] = maxConcurrentStreams != null ? maxConcurrentStreams : 100 // Max peerConcurrentStreams for a Node h2 server - // HTTP/2 window sizes are set to higher defaults than Node.js core for better performance: - // - initialWindowSize: 262144 (256KB) vs Node.js default 65535 (64KB - 1) - // Allows more data to be sent before requiring acknowledgment, improving throughput - // especially on high-latency networks. This matches common production HTTP/2 servers. - // - connectionWindowSize: 524288 (512KB) vs Node.js default (none set) - // Provides better flow control for the entire connection across multiple streams. - this[kHTTP2InitialWindowSize] = initialWindowSize != null ? initialWindowSize : 262144 - this[kHTTP2ConnectionWindowSize] = connectionWindowSize != null ? connectionWindowSize : 524288 - this[kPingInterval] = pingInterval != null ? pingInterval : 60e3 // Default ping interval for h2 - 1 minute + this[kHTTP2Options] = { + pingInterval: h2Options?.pingInterval ?? pingInterval ?? 60e3, + connectionWindowSize: h2Options?.connectionWindowSize ?? connectionWindowSize ?? 524288, + maxConcurrentStreams: h2Options?.maxConcurrentStreams ?? maxConcurrentStreams ?? 100, // Max peerConcurrentStreams for a Node h2 server + sessionOptions: { + // HTTP/2 window sizes are set to higher defaults than Node.js core for better performance: + // - initialWindowSize: 262144 (256KB) vs Node.js default 65535 (64KB - 1) + // Allows more data to be sent before requiring acknowledgment, improving throughput + // especially on high-latency networks. This matches common production HTTP/2 servers. + // - connectionWindowSize: 524288 (512KB) vs Node.js default (none set) + // Provides better flow control for the entire connection across multiple streams. + initialWindowSize: h2Options?.initialWindowSize ?? initialWindowSize ?? 262144 + } + } // kQueue is built up of 3 sections separated by // the kRunningIdx and kPendingIdx indices. @@ -672,6 +716,7 @@ function _resume (client, sync) { } if (!client[kHTTPContext]) { + client[kServerName] = request.servername connect(client) return } diff --git a/deps/undici/src/lib/dispatcher/env-http-proxy-agent.js b/deps/undici/src/lib/dispatcher/env-http-proxy-agent.js index f88437f1936a..51c50601714b 100644 --- a/deps/undici/src/lib/dispatcher/env-http-proxy-agent.js +++ b/deps/undici/src/lib/dispatcher/env-http-proxy-agent.js @@ -65,9 +65,10 @@ class EnvHttpProxyAgent extends DispatcherBase { #getProxyAgentForUrl (url) { let { protocol, host: hostname, port } = url - // Stripping ports in this way instead of using parsedUrl.hostname to make - // sure that the brackets around IPv6 addresses are kept. - hostname = hostname.replace(/:\d*$/, '').toLowerCase() + // Remove the port suffix (e.g. ":8080") and then strip surrounding + // brackets from IPv6 literals (e.g. "[::1]" -> "::1") so that the + // result matches the unbracketed form stored by #parseNoProxy. + hostname = hostname.replace(/:\d*$/, '').replace(/^\[(.+)\]$/, '$1').toLowerCase() port = Number.parseInt(port, 10) || DEFAULT_PORTS[protocol] || 0 if (!this.#shouldProxy(hostname, port)) { return this[kNoProxyAgent] @@ -119,11 +120,32 @@ class EnvHttpProxyAgent extends DispatcherBase { if (!entry) { continue } - const parsed = entry.match(/^(.+):(\d+)$/) + + // An IPv6 entry with a port must be bracketed: [::1]:443. + // A bare IPv6 address like ::1 contains colons that must not be + // confused with a host:port separator, so we handle it separately. + let hostname, port + const ipv6WithPort = entry.match(/^\[(.+)\]:(\d+)$/) + if (ipv6WithPort) { + hostname = ipv6WithPort[1] + port = Number.parseInt(ipv6WithPort[2], 10) + } else { + // Bracketed IPv6 without port, or plain hostname[:port], or bare IPv6. + // Strip optional brackets first. + const unbracketed = entry.replace(/^\[(.+)\]$/, '$1') + // A bare IPv6 address contains multiple colons; a hostname:port entry + // has exactly one colon followed by digits. Only attempt host:port + // splitting when that is unambiguously the case. + const colonCount = (unbracketed.match(/:/g) || []).length + const parsed = colonCount === 1 && unbracketed.match(/^(.+):(\d+)$/) + hostname = parsed ? parsed[1] : unbracketed + port = parsed ? Number.parseInt(parsed[2], 10) : 0 + } + noProxyEntries.push({ // strip leading dot or asterisk with dot - hostname: (parsed ? parsed[1] : entry).replace(/^\*?\./, '').toLowerCase(), - port: parsed ? Number.parseInt(parsed[2], 10) : 0 + hostname: hostname.replace(/^\*?\./, '').toLowerCase(), + port }) } diff --git a/deps/undici/src/lib/dispatcher/socks5-proxy-agent.js b/deps/undici/src/lib/dispatcher/socks5-proxy-agent.js index bb46b7cfa184..909c7f502478 100644 --- a/deps/undici/src/lib/dispatcher/socks5-proxy-agent.js +++ b/deps/undici/src/lib/dispatcher/socks5-proxy-agent.js @@ -6,7 +6,7 @@ let tls // include tls conditionally since it is not always available const DispatcherBase = require('./dispatcher-base') const { InvalidArgumentError } = require('../core/errors') const { Socks5Client, STATES } = require('../core/socks5-client') -const { kDispatch, kClose, kDestroy } = require('../core/symbols') +const { kBusy, kConnected, kDispatch, kClose, kDestroy } = require('../core/symbols') const Pool = require('./pool') const buildConnector = require('../core/connect') const { debuglog } = require('node:util') @@ -226,6 +226,20 @@ class Socks5ProxyAgent extends DispatcherBase { } }) this[kPools].set(originKey, pool) + + const closePoolIfUnused = () => { + if (this[kPools].get(originKey) !== pool || pool[kConnected] > 0 || pool[kBusy]) { + return + } + + this[kPools].delete(originKey) + if (!pool.destroyed) { + pool.close() + } + } + + pool.on('disconnect', closePoolIfUnused) + pool.on('connectionError', closePoolIfUnused) } // Dispatch the request through the per-origin pool diff --git a/deps/undici/src/lib/handler/retry-handler.js b/deps/undici/src/lib/handler/retry-handler.js index 3fc26229a1cc..c098b510c26c 100644 --- a/deps/undici/src/lib/handler/retry-handler.js +++ b/deps/undici/src/lib/handler/retry-handler.js @@ -241,6 +241,11 @@ class RetryHandler { } onResponseStart (controller, statusCode, headers, statusMessage) { + if (statusCode < 200) { + this.handler.onResponseStart?.(this.controllerProxy, statusCode, headers, statusMessage) + return + } + this.error = null this.retryCount += 1 this.statusCode = statusCode @@ -305,7 +310,7 @@ class RetryHandler { // First time we receive 206 const range = parseRangeHeader(headers['content-range']) - if (range == null) { + if (range == null || range.end == null) { this.headersSent = true this.handler.onResponseStart?.( this.controllerProxy, @@ -330,7 +335,7 @@ class RetryHandler { } // We make our best to checkpoint the body for further range headers - if (this.end == null) { + if (this.end == null && this.opts.method !== 'HEAD') { const contentLength = headers['content-length'] this.end = contentLength != null ? Number(contentLength) - 1 : null } diff --git a/deps/undici/src/lib/interceptor/cache.js b/deps/undici/src/lib/interceptor/cache.js index f50c1b7b67dc..2d7d01f130aa 100644 --- a/deps/undici/src/lib/interceptor/cache.js +++ b/deps/undici/src/lib/interceptor/cache.js @@ -540,13 +540,16 @@ module.exports = (opts = {}) => { return dispatch => { return (opts, handler) => { - if (!opts.origin || arrayIncludes(safeMethodsToNotCache, opts.method)) { - // Not a method we want to cache or we don't have the origin, skip + if (arrayIncludes(safeMethodsToNotCache, opts.method)) { + // Not a method we want to cache, skip return dispatch(opts, handler) } // Check if origin is in whitelist if (origins !== undefined) { + if (!opts.origin) { + return dispatch(opts, handler) + } const requestOrigin = opts.origin.toString().toLowerCase() let isAllowed = false diff --git a/deps/undici/src/lib/interceptor/deduplicate.js b/deps/undici/src/lib/interceptor/deduplicate.js index e81525ac5ea7..bacfeb3fb37e 100644 --- a/deps/undici/src/lib/interceptor/deduplicate.js +++ b/deps/undici/src/lib/interceptor/deduplicate.js @@ -59,7 +59,7 @@ module.exports = (opts = {}) => { return dispatch => { return (opts, handler) => { - if (!opts.origin || methods.includes(opts.method) === false) { + if (opts.upgrade || methods.includes(opts.method) === false) { return dispatch(opts, handler) } diff --git a/deps/undici/src/lib/llhttp/wasm_build_env.txt b/deps/undici/src/lib/llhttp/wasm_build_env.txt index e4cfa0c37626..82569ca62dd1 100644 --- a/deps/undici/src/lib/llhttp/wasm_build_env.txt +++ b/deps/undici/src/lib/llhttp/wasm_build_env.txt @@ -1,5 +1,5 @@ -> undici@8.9.0 build:wasm +> undici@8.10.0 build:wasm > node build/wasm.js --docker > docker run --rm --platform=linux/x86_64 --user 1001:1001 --mount type=bind,source=/home/runner/work/node/node/deps/undici/src/lib/llhttp,target=/home/node/build/lib/llhttp --mount type=bind,source=/home/runner/work/node/node/deps/undici/src/build,target=/home/node/build/build --mount type=bind,source=/home/runner/work/node/node/deps/undici/src/deps,target=/home/node/build/deps -t ghcr.io/nodejs/wasm-builder@sha256:975f391d907e42a75b8c72eb77c782181e941608687d4d8694c3e9df415a0970 node build/wasm.js diff --git a/deps/undici/src/lib/mock/mock-utils.js b/deps/undici/src/lib/mock/mock-utils.js index 111a860e9aee..e43f7218d0b4 100644 --- a/deps/undici/src/lib/mock/mock-utils.js +++ b/deps/undici/src/lib/mock/mock-utils.js @@ -17,6 +17,7 @@ const { } } = require('node:util') const { InvalidArgumentError } = require('../core/errors') +const requestAborted = Symbol('request aborted') function matchValue (match, value) { if (typeof match === 'string') { @@ -153,6 +154,11 @@ function getResponseData (data) { return data } else if (data instanceof ArrayBuffer) { return data + } else if (ArrayBuffer.isView(data)) { + // A DataView, or any non-Uint8Array typed array, is a byte container + // rather than a plain object. Buffer.from() cannot read one directly, so + // expose the bytes it covers instead of letting it reach JSON.stringify. + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength) } else if (typeof data === 'object') { return JSON.stringify(data) } else if (data) { @@ -225,9 +231,15 @@ function deleteMockDispatch (mockDispatches, key) { } /** - * @param {string} path Path to remove trailing slash from + * @param {string|RegExp|Function} path Path, or path matcher, to remove trailing slash from */ function removeTrailingSlash (path) { + // Registered path matchers may be a RegExp or a function, which have no + // trailing slash to strip; hand those back for matchValue to apply. + if (typeof path !== 'string') { + return path + } + while (path.endsWith('/')) { path = path.slice(0, -1) } @@ -302,9 +314,13 @@ function mockDispatch (opts, handler) { mockDispatch.consumed = !mockDispatch.persist && timesInvoked >= times mockDispatch.pending = timesInvoked < times + const hasBodyHooks = typeof handler.onBodySent === 'function' || + typeof handler.onRequestSent === 'function' + // Here's where we resolve a callback if a callback is present for the dispatch data. - if (mockDispatch.data.callback) { - const callbackResult = mockDispatch.data.callback(opts) + if (mockDispatch.data.callback && (!hasBodyHooks || opts.body == null)) { + const { callback, ...responseDefaults } = mockDispatch.data + const callbackResult = callback(opts) // An asynchronous reply options callback resolves to the reply data, so // the dispatch can only continue once the returned promise settles. @@ -313,18 +329,25 @@ function mockDispatch (opts, handler) { if (isPromise(callbackResult)) { callbackResult.then( (resolvedData) => { - mockDispatch.data = { ...mockDispatch.data, ...resolvedData } + if (resolvedData == null || typeof resolvedData !== 'object') { + handler.onResponseError(null, new InvalidArgumentError('reply options callback must return an object')) + return + } + mockDispatch.data = { ...responseDefaults, ...resolvedData } dispatchMockReply(mockDispatches, mockDispatch, key, opts, handler) }, (error) => { - deleteMockDispatch(mockDispatches, key) handler.onResponseError(null, error) } ) return true } - mockDispatch.data = { ...mockDispatch.data, ...callbackResult } + if (callbackResult == null || typeof callbackResult !== 'object') { + throw new InvalidArgumentError('reply options callback must return an object') + } + + mockDispatch.data = { ...responseDefaults, ...callbackResult } } return dispatchMockReply(mockDispatches, mockDispatch, key, opts, handler) @@ -335,12 +358,12 @@ function mockDispatch (opts, handler) { */ function dispatchMockReply (mockDispatches, mockDispatch, key, opts, handler) { // Parse mockDispatch data - const { data: { statusCode, data, headers, trailers, error }, delay } = mockDispatch + const { data: response, delay } = mockDispatch // If specified, trigger dispatch error - if (error !== null) { + if (response.error !== null) { deleteMockDispatch(mockDispatches, key) - handler.onResponseError(null, error) + handler.onResponseError(null, response.error) return true } @@ -375,32 +398,107 @@ function dispatchMockReply (mockDispatches, mockDispatch, key, opts, handler) { } } + let replyOpts = opts + const dispatches = mockDispatches + // Call onRequestStart to allow the handler to receive the controller handler.onRequestStart?.(controller, null) - // Handle the request with a delay if necessary - if (typeof delay === 'number' && delay > 0) { - timer = setTimeout(() => { - timer = null - handleReply(mockDispatches) - }, delay) - } else { - handleReply(mockDispatches) + if (aborted) { + return true + } + + const requestBody = dispatchRequestBody(opts.body, handler, controller, () => aborted) + + if (isPromise(requestBody)) { + requestBody.then((body) => { + if (body === requestAborted) { + return + } + + if (body !== opts.body) { + replyOpts = { ...opts, body } + } + + sendReply() + }, (error) => controller.abort(error)) + return true + } + + if (requestBody === requestAborted) { + return true + } + + if (requestBody !== opts.body) { + replyOpts = { ...opts, body: requestBody } + } + + sendReply() + + function sendReply () { + if (response.callback) { + const { callback, ...responseDefaults } = response + let callbackResult + try { + callbackResult = callback(replyOpts) + } catch (err) { + deleteMockDispatch(mockDispatches, key) + handler.onResponseError(null, err) + return + } + + if (isPromise(callbackResult)) { + callbackResult.then( + (resolvedData) => { + if (resolvedData == null || typeof resolvedData !== 'object') { + handler.onResponseError(null, new InvalidArgumentError('reply options callback must return an object')) + return + } + mockDispatch.data = { ...responseDefaults, ...resolvedData } + handleReply(dispatches, mockDispatch.data) + }, + (err) => { + handler.onResponseError(null, err) + } + ) + return + } + + if (callbackResult == null || typeof callbackResult !== 'object') { + throw new InvalidArgumentError('reply options callback must return an object') + } + + mockDispatch.data = { ...responseDefaults, ...callbackResult } + handleReply(dispatches, mockDispatch.data) + return + } + + // Handle the request with a delay if necessary + if (typeof delay === 'number' && delay > 0) { + timer = setTimeout(() => { + timer = null + handleReply(dispatches) + }, delay) + } else { + handleReply(dispatches) + } } - function handleReply (mockDispatches, _data = data) { + function handleReply (mockDispatches, _response = response) { // Don't send response if the request was aborted if (aborted) { return } + const { statusCode, data, headers, trailers } = _response + // fetch's HeadersList is a 1D string array const optsHeaders = Array.isArray(opts.headers) ? buildHeadersFromArray(opts.headers) : opts.headers - const body = typeof _data === 'function' - ? _data({ ...opts, headers: optsHeaders }) - : _data + const body = typeof data === 'function' + ? data({ ...replyOpts, headers: optsHeaders }) + : data // util.types.isPromise is likely needed for jest. if (isPromise(body)) { @@ -409,7 +507,7 @@ function dispatchMockReply (mockDispatches, mockDispatch, key, opts, handler) { // synchronously throw the error, which breaks some tests. // Rather, we wait for the callback to resolve if it is a // promise, and then re-run handleReply with the new body. - return body.then((newData) => handleReply(mockDispatches, newData)) + return body.then((newData) => handleReply(mockDispatches, { ..._response, data: newData })) } // Check again if aborted after async body resolution @@ -418,8 +516,8 @@ function dispatchMockReply (mockDispatches, mockDispatch, key, opts, handler) { } const responseData = getResponseData(body) - const responseHeaders = generateKeyValues(headers) - const responseTrailers = generateKeyValues(trailers) + const responseHeaders = generateKeyValues(headers ?? {}) + const responseTrailers = generateKeyValues(trailers ?? {}) // Update the controller with response data controller.rawHeaders = responseHeaders @@ -434,6 +532,97 @@ function dispatchMockReply (mockDispatches, mockDispatch, key, opts, handler) { return true } +function dispatchRequestBody (body, handler, controller, isAborted) { + if (typeof handler.onBodySent !== 'function' && typeof handler.onRequestSent !== 'function') { + return body + } + + if (body == null) { + return callOnRequestSent(handler, controller, isAborted) ? body : requestAborted + } + + if (body && typeof body[Symbol.asyncIterator] === 'function') { + return dispatchAsyncIterableBody(body, handler, controller, isAborted) + } + + if (isIterableBody(body)) { + const chunks = [] + + for (const chunk of body) { + if (isAborted()) { + return requestAborted + } + chunks.push(chunk) + if (!callOnBodySent(handler, controller, chunk) || isAborted()) { + return requestAborted + } + } + + return callOnRequestSent(handler, controller, isAborted) ? chunks : requestAborted + } + + if (isAborted()) { + return requestAborted + } + + if (!callOnBodySent(handler, controller, body)) { + return requestAborted + } + + return callOnRequestSent(handler, controller, isAborted) ? body : requestAborted +} + +async function dispatchAsyncIterableBody (body, handler, controller, isAborted) { + const chunks = [] + + for await (const chunk of body) { + if (isAborted()) { + return requestAborted + } + chunks.push(chunk) + if (!callOnBodySent(handler, controller, chunk) || isAborted()) { + return requestAborted + } + } + + if (!callOnRequestSent(handler, controller, isAborted)) { + return requestAborted + } + + return { + async * [Symbol.asyncIterator] () { + yield * chunks + } + } +} + +function callOnBodySent (handler, controller, chunk) { + try { + handler.onBodySent?.(chunk) + return true + } catch (error) { + controller.abort(error) + return false + } +} + +function callOnRequestSent (handler, controller, isAborted) { + try { + handler.onRequestSent?.() + return !isAborted() + } catch (error) { + controller.abort(error) + return false + } +} + +function isIterableBody (body) { + return typeof body !== 'string' && + !Buffer.isBuffer(body) && + !ArrayBuffer.isView(body) && + typeof body[Symbol.iterator] === 'function' +} + function buildMockDispatch () { const agent = this[kMockAgent] const origin = this[kOrigin] diff --git a/deps/undici/src/lib/util/cache.js b/deps/undici/src/lib/util/cache.js index d156731d1b5d..1fac28af5d97 100644 --- a/deps/undici/src/lib/util/cache.js +++ b/deps/undici/src/lib/util/cache.js @@ -148,9 +148,7 @@ function getMalformedRestrictiveDirectiveName (key) { * @param {import('../../types/dispatcher.d.ts').default.DispatchOptions} opts */ function makeCacheKey (opts) { - if (!opts.origin) { - throw new Error('opts.origin is undefined') - } + const origin = opts.origin ? opts.origin.toString() : '' let fullPath = opts.path || '/' @@ -159,7 +157,7 @@ function makeCacheKey (opts) { } return { - origin: opts.origin.toString(), + origin, method: opts.method, path: fullPath, headers: opts.headers diff --git a/deps/undici/src/lib/web/websocket/websocket.js b/deps/undici/src/lib/web/websocket/websocket.js index e473a1bc4917..45dbce1bea93 100644 --- a/deps/undici/src/lib/web/websocket/websocket.js +++ b/deps/undici/src/lib/web/websocket/websocket.js @@ -25,6 +25,9 @@ const { SendQueue } = require('./sender') const { WebsocketFrameSend } = require('./frame') const { channels } = require('../../core/diagnostics') +const kRef = Symbol.for('nodejs.ref') +const kUnref = Symbol.for('nodejs.unref') + function getSocketAddress (socket) { if (typeof socket?.address === 'function') { return socket.address() @@ -68,6 +71,7 @@ class WebSocket extends EventTarget { #bufferedAmount = 0 #protocol = '' #extensions = '' + #refed = true /** @type {SendQueue} */ #sendQueue @@ -194,6 +198,20 @@ class WebSocket extends EventTarget { this.#binaryType = 'blob' } + [kRef] () { + webidl.brandCheck(this, WebSocket) + + this.#refed = true + this.#handler.socket?.ref?.() + } + + [kUnref] () { + webidl.brandCheck(this, WebSocket) + + this.#refed = false + this.#handler.socket?.unref?.() + } + /** * @see https://websockets.spec.whatwg.org/#dom-websocket-close * @param {number|undefined} code @@ -468,6 +486,10 @@ class WebSocket extends EventTarget { // once this happens, the connection is open this.#handler.socket = response.socket + if (!this.#refed) { + this.#handler.socket.unref?.() + } + // Get options from dispatcher options const maxFragments = this.#handler.controller.dispatcher?.webSocketOptions?.maxFragments const maxPayloadSize = this.#handler.controller.dispatcher?.webSocketOptions?.maxPayloadSize diff --git a/deps/undici/src/package-lock.json b/deps/undici/src/package-lock.json index ecc36b1137b0..28d9db8de52e 100644 --- a/deps/undici/src/package-lock.json +++ b/deps/undici/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "undici", - "version": "8.9.0", + "version": "8.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "undici", - "version": "8.9.0", + "version": "8.10.0", "license": "MIT", "devDependencies": { "@fastify/busboy": "3.2.0", diff --git a/deps/undici/src/package.json b/deps/undici/src/package.json index f6feb3f0a832..270b572dfc89 100644 --- a/deps/undici/src/package.json +++ b/deps/undici/src/package.json @@ -1,6 +1,6 @@ { "name": "undici", - "version": "8.9.0", + "version": "8.10.0", "description": "An HTTP/1.1 client, written from scratch for Node.js", "homepage": "https://undici.nodejs.org", "bugs": { diff --git a/deps/undici/src/types/client.d.ts b/deps/undici/src/types/client.d.ts index e3b121962ef0..064d3d69f2c1 100644 --- a/deps/undici/src/types/client.d.ts +++ b/deps/undici/src/types/client.d.ts @@ -1,10 +1,15 @@ import { URL } from 'node:url' +import { SessionOptions } from 'node:http2' import Dispatcher from './dispatcher' import buildConnector from './connector' import TClientStats from './client-stats' type ClientConnectOptions = Omit, 'origin'> +// TODO: Pendings +// 1. Reflect this on Client instantiation +// 2. Client H2 should use this namespaced options instead. + /** * A basic HTTP/1.1 client, mapped on top a single TCP/TLS connection. Pipelining is disabled by default. */ @@ -87,23 +92,31 @@ export declare namespace Client { /** * @description Dictates the maximum number of concurrent streams for a single H2 session. It can be overridden by a SETTINGS remote frame. * @default 100 + * @deprecated Use h2Options.maxConcurrentStreams instead */ maxConcurrentStreams?: number; /** * @description Sets the HTTP/2 stream-level flow-control window size (SETTINGS_INITIAL_WINDOW_SIZE). * @default 262144 + * @deprecated Use h2Options.settings.initialWindowSize instead */ initialWindowSize?: number; /** * @description Sets the HTTP/2 connection-level flow-control window size (ClientHttp2Session.setLocalWindowSize). * @default 524288 + * @deprecated Use h2Options.connectionWindowSize instead */ connectionWindowSize?: number; /** * @description Time interval between PING frames dispatch * @default 60000 + * @deprecated Use h2Options.connectionWindowSize instead */ pingInterval?: number; + /** + * @description HTTP/2 configuration options + */ + h2Options?: Client.H2Options; } export interface SocketInfo { localAddress?: string @@ -129,6 +142,33 @@ export declare namespace Client { */ maxPayloadSize?: number; } + + export interface H2Options extends Omit { + /** + * @description Sets the HTTP/2 connection-level flow-control window size (ClientHttp2Session.setLocalWindowSize). + * @default 524288 + */ + connectionWindowSize?: number; + /** + * @description Time interval between PING frames dispatch + * @default 60000 + */ + pingInterval?: number; + /** + * @description Dictates the maximum number of concurrent streams for a single H2 session. It can be overridden by a SETTINGS remote frame. + * @default 100 + */ + maxConcurrentStreams?: number; + /** + * @description Enable support for H2C (plain text) + * @default false + */ + useH2c?: boolean; + /** + * @description SETTINGS frame object. Default to 'node:http2' defaults + */ + settings?: Omit + } } export default Client diff --git a/deps/undici/undici.js b/deps/undici/undici.js index c0505480ffe9..bb398a0686ce 100644 --- a/deps/undici/undici.js +++ b/deps/undici/undici.js @@ -574,6 +574,7 @@ var require_symbols = __commonJS({ kCounter: /* @__PURE__ */ Symbol("socket request counter"), kMaxResponseSize: /* @__PURE__ */ Symbol("max response size"), kHTTP2Session: /* @__PURE__ */ Symbol("http2Session"), + kHTTP2Options: /* @__PURE__ */ Symbol("http2 options"), kHTTP2SessionState: /* @__PURE__ */ Symbol("http2Session state"), kRetryHandlerDefaultRetry: /* @__PURE__ */ Symbol("retry agent default retry"), kConstruct: /* @__PURE__ */ Symbol("constructable"), @@ -3266,14 +3267,26 @@ var require_connect = __commonJS({ } else { assert(!httpSocket, "httpSocket can only be sent on TLS update"); port = port || 80; - socket = net.connect({ + const connectOptions = { highWaterMark: 64 * 1024, // Same as nodejs fs streams. ...options, localAddress, port, host: hostname - }); + }; + const family = net.isIP(hostname); + if (family !== 0 && servername && servername !== hostname) { + connectOptions.host = servername; + connectOptions.lookup = (_hostname, lookupOptions, cb) => { + if (lookupOptions.all) { + cb(null, [{ address: hostname, family }]); + } else { + cb(null, hostname, family); + } + }; + } + socket = net.connect(connectOptions); if (useH2c === true) { socket.alpnProtocol = "h2"; } @@ -7843,7 +7856,7 @@ var require_client_h1 = __commonJS({ __name(onSocketClose, "onSocketClose"); function clearIdleSocketValidation(socket) { if (socket[kIdleSocketValidationTimeout]) { - clearImmediate(socket[kIdleSocketValidationTimeout]); + clearTimeout(socket[kIdleSocketValidationTimeout]); socket[kIdleSocketValidationTimeout] = null; } socket[kIdleSocketValidation] = 0; @@ -7851,13 +7864,13 @@ var require_client_h1 = __commonJS({ __name(clearIdleSocketValidation, "clearIdleSocketValidation"); function scheduleIdleSocketValidation(client, socket) { socket[kIdleSocketValidation] = 1; - socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = setTimeout(() => { socket[kIdleSocketValidationTimeout] = null; socket[kIdleSocketValidation] = 2; if (client[kSocket] === socket && !socket.destroyed) { client[kResume](); } - }); + }, 0); socket[kIdleSocketValidationTimeout].unref?.(); } __name(scheduleIdleSocketValidation, "scheduleIdleSocketValidation"); @@ -8394,10 +8407,7 @@ var require_client_h2 = __commonJS({ kStrictContentLength, kOnError, kMaxConcurrentStreams, - kPingInterval, kHTTP2Session, - kHTTP2InitialWindowSize, - kHTTP2ConnectionWindowSize, kHostAuthority, kResume, kSize, @@ -8409,7 +8419,8 @@ var require_client_h2 = __commonJS({ kEnableConnectProtocol, kRemoteSettings, kHTTP2Stream, - kHTTP2SessionState + kHTTP2SessionState, + kHTTP2Options } = require_symbols(); var { channels } = require_diagnostics(); var kOpenStreams = /* @__PURE__ */ Symbol("open streams"); @@ -8418,6 +8429,9 @@ var require_client_h2 = __commonJS({ var kRequestStreamCleanup = /* @__PURE__ */ Symbol("request stream cleanup"); var kRequestStreamState = /* @__PURE__ */ Symbol("request stream state"); var kReceivedGoAway = /* @__PURE__ */ Symbol("received goaway"); + var kGoAwayReplayAttempts = /* @__PURE__ */ Symbol("goaway replay attempts"); + var kRefusedStreamRetry = /* @__PURE__ */ Symbol("refused stream retry"); + var MAX_GOAWAY_REPLAY_ATTEMPTS = 1; var extractBody; var http2; try { @@ -8523,11 +8537,17 @@ var require_client_h2 = __commonJS({ } } __name(completeRequest, "completeRequest"); - function canRetryRequestAfterGoAway(request) { + function canReplayRequest(request) { const { body } = request; return body == null || util.isBuffer(body) || util.isBlobLike(body); } - __name(canRetryRequestAfterGoAway, "canRetryRequestAfterGoAway"); + __name(canReplayRequest, "canReplayRequest"); + function registerGoAwayRefusal(request) { + const attempts = (request[kGoAwayReplayAttempts] ?? 0) + 1; + request[kGoAwayReplayAttempts] = attempts; + return attempts <= MAX_GOAWAY_REPLAY_ATTEMPTS; + } + __name(registerGoAwayRefusal, "registerGoAwayRefusal"); function closeStream(stream, code = NGHTTP2_REFUSED_STREAM) { if (stream != null && !stream.destroyed && !stream.closed) { try { @@ -8540,16 +8560,32 @@ var require_client_h2 = __commonJS({ function detachRequestStreamForClose(request) { const stream = request[kRequestStream]; clearRequestStream(request); + severRequestStream(stream); return stream; } __name(detachRequestStreamForClose, "detachRequestStreamForClose"); + function severRequestStream(stream) { + if (stream == null || stream[kRequestStreamState] == null) { + return; + } + stream[kRequestStreamState] = null; + stream.off("close", completeRequestStream); + stream.off("close", onUpgradeStreamClose); + if (stream[kHTTP2Session] != null) { + closeStreamSession(stream); + } + if (!stream.destroyed && !stream.closed) { + stream.once("error", noop); + } + } + __name(severRequestStream, "severRequestStream"); function connectH2(client, socket) { client[kSocket] = socket; - const http2InitialWindowSize = client[kHTTP2InitialWindowSize]; - const http2ConnectionWindowSize = client[kHTTP2ConnectionWindowSize]; + const http2InitialWindowSize = client[kHTTP2Options].sessionOptions?.initialWindowSize; + const http2ConnectionWindowSize = client[kHTTP2Options].connectionWindowSize; const session = http2.connect(client[kUrl], { createConnection: /* @__PURE__ */ __name(() => socket, "createConnection"), - peerMaxConcurrentStreams: client[kMaxConcurrentStreams], + peerMaxConcurrentStreams: client[kHTTP2Options].maxConcurrentStreams, settings: { // TODO(metcoder95): add support for PUSH enablePush: false, @@ -8562,13 +8598,16 @@ var require_client_h2 = __commonJS({ session[kSocket] = socket; session[kHTTP2SessionState] = { idleTimeout: null, + // Armed while the peer advertises MAX_CONCURRENT_STREAMS = 0 and we have + // work that cannot start. See setNoStreamsTimeout. + noStreamsTimeout: null, // Sockets start out ref'd. Session ref/unref proxies to the socket, so a // single cached flag lets us skip redundant uv ref/unref calls, provided // every ref/unref of the session or its socket goes through // refH2Session/unrefH2Session. refed: true, ping: { - interval: client[kPingInterval] === 0 ? null : setInterval(onHttp2SendPing, client[kPingInterval], session).unref() + interval: client[kHTTP2Options].pingInterval === 0 ? null : setInterval(onHttp2SendPing, client[kHTTP2Options].pingInterval, session).unref() } }; session[kReceivedGoAway] = false; @@ -8676,9 +8715,52 @@ var require_client_h2 = __commonJS({ } else { clearHttp2IdleTimeout(session); } + if (client[kMaxConcurrentStreams] === 0 && client[kRunning] === 0 && client[kPending] > 0) { + setNoStreamsTimeout(session); + } else { + clearNoStreamsTimeout(session); + } } } __name(resumeH2, "resumeH2"); + function clearNoStreamsTimeout(session) { + const state = session[kHTTP2SessionState]; + if (state?.noStreamsTimeout != null) { + clearTimeout(state.noStreamsTimeout); + state.noStreamsTimeout = null; + } + } + __name(clearNoStreamsTimeout, "clearNoStreamsTimeout"); + function setNoStreamsTimeout(session) { + const client = session[kClient]; + const state = session[kHTTP2SessionState]; + const timeout = client[kHeadersTimeout]; + if (!timeout || state.noStreamsTimeout != null) { + return; + } + state.noStreamsTimeout = setTimeout(onNoStreamsTimeout, timeout, session).unref(); + } + __name(setNoStreamsTimeout, "setNoStreamsTimeout"); + function onNoStreamsTimeout(session) { + const client = session[kClient]; + const state = session[kHTTP2SessionState]; + state.noStreamsTimeout = null; + if (client[kHTTP2Session] !== session || client[kMaxConcurrentStreams] !== 0 || client[kRunning] !== 0 || client[kPending] === 0) { + return; + } + const err = new HeadersTimeoutError( + `HTTP/2: server did not accept a new stream within ${client[kHeadersTimeout]}` + ); + const requests = client[kQueue].splice(client[kPendingIdx]); + for (let i = 0; i < requests.length; i++) { + if (requests[i] != null) { + util.errorRequest(client, requests[i], err); + } + } + session[kError] = err; + resetHttp2Session(session, err); + } + __name(onNoStreamsTimeout, "onNoStreamsTimeout"); function clearHttp2IdleTimeout(session) { const state = session[kHTTP2SessionState]; if (state?.idleTimeout != null) { @@ -8794,7 +8876,7 @@ var require_client_h2 = __commonJS({ const request = client[kQueue][i]; if (request != null) { streamsToClose.push(detachRequestStreamForClose(request)); - if (canRetryRequestAfterGoAway(request)) { + if (canReplayRequest(request) && registerGoAwayRefusal(request)) { retriableRequests.push(request); } else { util.errorRequest(client, request, err); @@ -8815,6 +8897,7 @@ var require_client_h2 = __commonJS({ client[kHTTP2Session] = null; } clearHttp2IdleTimeout(this); + clearNoStreamsTimeout(this); if (!this.closed && !this.destroyed) { this.close(); } @@ -8832,6 +8915,7 @@ var require_client_h2 = __commonJS({ client[kHTTP2Session] = null; } clearHttp2IdleTimeout(this); + clearNoStreamsTimeout(this); if (state.ping.interval != null) { clearInterval(state.ping.interval); state.ping.interval = null; @@ -8915,6 +8999,12 @@ var require_client_h2 = __commonJS({ releaseRequestStream(this); if (state.pendingEnd && !state.request.aborted && !state.request.completed) { state.request.onResponseEnd(state.trailers || {}); + } else if (!state.request.aborted && !state.request.completed) { + util.errorRequest( + state.client, + state.request, + new InformationalError("HTTP/2: stream closed before the response was complete") + ); } finalizeRequest(state); closeStreamSession(this); @@ -9333,6 +9423,21 @@ var require_client_h2 = __commonJS({ } } __name(onEnd, "onEnd"); + function retryRefusedStream(stream, state) { + const { client, request } = state; + if (state.responseReceived || request.aborted || request.completed || request[kRefusedStreamRetry] || !canReplayRequest(request)) { + return false; + } + request[kRefusedStreamRetry] = true; + detachRequestStreamForClose(request); + state.stream = null; + state.requestFinalized = true; + completeRequest(client, request); + client[kQueue].splice(client[kPendingIdx], 0, request); + client[kResume](); + return true; + } + __name(retryRefusedStream, "retryRefusedStream"); function onError(err) { const stream = this; const state = stream[kRequestStreamState]; @@ -9340,6 +9445,12 @@ var require_client_h2 = __commonJS({ return; } stream.off("error", onError); + if (typeof stream.rstCode === "number" && stream.rstCode !== NGHTTP2_NO_ERROR) { + err.http2ErrorCode = stream.rstCode; + } + if (stream.rstCode === NGHTTP2_REFUSED_STREAM && retryRefusedStream(stream, state)) { + return; + } state.abort(err); } __name(onError, "onError"); @@ -9633,10 +9744,8 @@ var require_client = __commonJS({ kHTTPContext, kMaxConcurrentStreams, kHostAuthority, - kHTTP2InitialWindowSize, - kHTTP2ConnectionWindowSize, kResume, - kPingInterval + kHTTP2Options } = require_symbols(); var connectH1 = require_client_h1(); var connectH2 = require_client_h2(); @@ -9650,6 +9759,15 @@ var require_client = __commonJS({ return client[kPipelining] ?? client[kHTTPContext]?.defaultPipelining ?? 1; } __name(getPipelining, "getPipelining"); + var h2NamespaceOptsWarning = false; + function emitH2OptionsNamespaceWarning(optName) { + if (h2NamespaceOptsWarning === true) return; + process.emitWarning(`Use h2Options.${optName} instead. ${optName} for H2 will be deprecated in future major.`, { + code: "UNDICI-H2-OPTIONS" + }); + h2NamespaceOptsWarning = true; + } + __name(emitH2OptionsNamespaceWarning, "emitH2OptionsNamespaceWarning"); function getMaxConcurrent(client) { if (client[kHTTPContext]?.version === "h2") { return client[kMaxConcurrentStreams]; @@ -9697,7 +9815,8 @@ var require_client = __commonJS({ initialWindowSize, connectionWindowSize, pingInterval, - webSocket + webSocket, + h2Options } = {}) { if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); @@ -9760,20 +9879,45 @@ var require_client = __commonJS({ if (allowH2 != null && typeof allowH2 !== "boolean") { throw new InvalidArgumentError("allowH2 must be a valid boolean value"); } - if (maxConcurrentStreams != null && (typeof maxConcurrentStreams !== "number" || maxConcurrentStreams < 1)) { - throw new InvalidArgumentError("maxConcurrentStreams must be a positive integer, greater than 0"); - } - if (useH2c != null && typeof useH2c !== "boolean") { - throw new InvalidArgumentError("useH2c must be a valid boolean value"); - } - if (initialWindowSize != null && (!Number.isInteger(initialWindowSize) || initialWindowSize < 1)) { - throw new InvalidArgumentError("initialWindowSize must be a positive integer, greater than 0"); - } - if (connectionWindowSize != null && (!Number.isInteger(connectionWindowSize) || connectionWindowSize < 1)) { - throw new InvalidArgumentError("connectionWindowSize must be a positive integer, greater than 0"); - } - if (pingInterval != null && (typeof pingInterval !== "number" || !Number.isInteger(pingInterval) || pingInterval < 0)) { - throw new InvalidArgumentError("pingInterval must be a positive integer, greater or equal to 0"); + if (allowH2 !== false) { + if (h2Options != null) { + if (h2Options.useH2c != null && typeof h2Options.useH2c !== "boolean") { + throw new InvalidArgumentError("h2Options.useH2c must be a valid boolean value"); + } + if (h2Options.settings?.initialWindowSize != null && (!Number.isInteger(h2Options.settings.initialWindowSize) || h2Options.settings.initialWindowSize < 1)) { + throw new InvalidArgumentError("h2Options.settings.initialWindowSize must be a positive integer, greater than 0"); + } + if (h2Options.maxConcurrentStreams != null && (!Number.isInteger(h2Options.connectionWindowSize) || h2Options.maxConcurrentStreams < 1)) { + throw new InvalidArgumentError("h2Options.maxConcurrentStreams must be a positive integer, greater than 0"); + } + if (h2Options.connectionWindowSize != null && (!Number.isInteger(h2Options.connectionWindowSize) || h2Options.connectionWindowSize < 1)) { + throw new InvalidArgumentError("h2Options.connectionWindowSize must be a positive integer, greater than 0"); + } + if (h2Options.pingInterval != null && (typeof h2Options.pingInterval !== "number" || !Number.isInteger(h2Options.pingInterval) || h2Options.pingInterval < 0)) { + throw new InvalidArgumentError("h2Options.pingInterval must be a positive integer, greater or equal to 0"); + } + } else { + if (useH2c != null && typeof useH2c !== "boolean") { + emitH2OptionsNamespaceWarning("useH2c"); + throw new InvalidArgumentError("useH2c must be a valid boolean value"); + } + if (maxConcurrentStreams != null && (typeof maxConcurrentStreams !== "number" || maxConcurrentStreams < 1)) { + emitH2OptionsNamespaceWarning("maxConcurrentStreams"); + throw new InvalidArgumentError("maxConcurrentStreams must be a positive integer, greater than 0"); + } + if (initialWindowSize != null && (!Number.isInteger(initialWindowSize) || initialWindowSize < 1)) { + emitH2OptionsNamespaceWarning("initialWindowSize"); + throw new InvalidArgumentError("initialWindowSize must be a positive integer, greater than 0"); + } + if (connectionWindowSize != null && (!Number.isInteger(connectionWindowSize) || connectionWindowSize < 1)) { + emitH2OptionsNamespaceWarning("connectionWindowSize"); + throw new InvalidArgumentError("connectionWindowSize must be a positive integer, greater than 0"); + } + if (pingInterval != null && (typeof pingInterval !== "number" || !Number.isInteger(pingInterval) || pingInterval < 0)) { + emitH2OptionsNamespaceWarning("pingInterval"); + throw new InvalidArgumentError("pingInterval must be a positive integer, greater or equal to 0"); + } + } } super({ webSocket }); if (typeof connect2 !== "function") { @@ -9781,8 +9925,8 @@ var require_client = __commonJS({ ...tls, maxCachedSessions, allowH2, - useH2c, socketPath, + useH2c: h2Options?.useH2c ?? useH2c, timeout: connectTimeout, ...typeof autoSelectFamily === "boolean" ? { autoSelectFamily, autoSelectFamilyAttemptTimeout } : void 0, ...connect2 @@ -9817,10 +9961,21 @@ var require_client = __commonJS({ this[kClosedResolve] = null; this[kMaxResponseSize] = maxResponseSize > -1 ? maxResponseSize : -1; this[kHTTPContext] = null; - this[kMaxConcurrentStreams] = maxConcurrentStreams != null ? maxConcurrentStreams : 100; - this[kHTTP2InitialWindowSize] = initialWindowSize != null ? initialWindowSize : 262144; - this[kHTTP2ConnectionWindowSize] = connectionWindowSize != null ? connectionWindowSize : 524288; - this[kPingInterval] = pingInterval != null ? pingInterval : 6e4; + this[kHTTP2Options] = { + pingInterval: h2Options?.pingInterval ?? pingInterval ?? 6e4, + connectionWindowSize: h2Options?.connectionWindowSize ?? connectionWindowSize ?? 524288, + maxConcurrentStreams: h2Options?.maxConcurrentStreams ?? maxConcurrentStreams ?? 100, + // Max peerConcurrentStreams for a Node h2 server + sessionOptions: { + // HTTP/2 window sizes are set to higher defaults than Node.js core for better performance: + // - initialWindowSize: 262144 (256KB) vs Node.js default 65535 (64KB - 1) + // Allows more data to be sent before requiring acknowledgment, improving throughput + // especially on high-latency networks. This matches common production HTTP/2 servers. + // - connectionWindowSize: 524288 (512KB) vs Node.js default (none set) + // Provides better flow control for the entire connection across multiple streams. + initialWindowSize: h2Options?.initialWindowSize ?? initialWindowSize ?? 262144 + } + }; this[kQueue] = []; this[kRunningIdx] = 0; this[kPendingIdx] = 0; @@ -10110,6 +10265,7 @@ var require_client = __commonJS({ return; } if (!client[kHTTPContext]) { + client[kServerName] = request.servername; connect(client); return; } @@ -11076,7 +11232,7 @@ var require_socks5_proxy_agent = __commonJS({ var DispatcherBase = require_dispatcher_base(); var { InvalidArgumentError } = require_errors(); var { Socks5Client, STATES } = require_socks5_client(); - var { kDispatch, kClose, kDestroy } = require_symbols(); + var { kBusy, kConnected, kDispatch, kClose, kDestroy } = require_symbols(); var Pool = require_pool(); var buildConnector = require_connect(); var { debuglog } = require("node:util"); @@ -11237,6 +11393,17 @@ var require_socks5_proxy_agent = __commonJS({ }, "connect") }); this[kPools].set(originKey, pool); + const closePoolIfUnused = /* @__PURE__ */ __name(() => { + if (this[kPools].get(originKey) !== pool || pool[kConnected] > 0 || pool[kBusy]) { + return; + } + this[kPools].delete(originKey); + if (!pool.destroyed) { + pool.close(); + } + }, "closePoolIfUnused"); + pool.on("disconnect", closePoolIfUnused); + pool.on("connectionError", closePoolIfUnused); } return pool[kDispatch](opts, handler); } catch (err) { @@ -11638,7 +11805,7 @@ var require_env_http_proxy_agent = __commonJS({ } #getProxyAgentForUrl(url) { let { protocol, host: hostname, port } = url; - hostname = hostname.replace(/:\d*$/, "").toLowerCase(); + hostname = hostname.replace(/:\d*$/, "").replace(/^\[(.+)\]$/, "$1").toLowerCase(); port = Number.parseInt(port, 10) || DEFAULT_PORTS[protocol] || 0; if (!this.#shouldProxy(hostname, port)) { return this[kNoProxyAgent]; @@ -11681,11 +11848,22 @@ var require_env_http_proxy_agent = __commonJS({ if (!entry) { continue; } - const parsed = entry.match(/^(.+):(\d+)$/); + let hostname, port; + const ipv6WithPort = entry.match(/^\[(.+)\]:(\d+)$/); + if (ipv6WithPort) { + hostname = ipv6WithPort[1]; + port = Number.parseInt(ipv6WithPort[2], 10); + } else { + const unbracketed = entry.replace(/^\[(.+)\]$/, "$1"); + const colonCount = (unbracketed.match(/:/g) || []).length; + const parsed = colonCount === 1 && unbracketed.match(/^(.+):(\d+)$/); + hostname = parsed ? parsed[1] : unbracketed; + port = parsed ? Number.parseInt(parsed[2], 10) : 0; + } noProxyEntries.push({ // strip leading dot or asterisk with dot - hostname: (parsed ? parsed[1] : entry).replace(/^\*?\./, "").toLowerCase(), - port: parsed ? Number.parseInt(parsed[2], 10) : 0 + hostname: hostname.replace(/^\*?\./, "").toLowerCase(), + port }); } this.#noProxyValue = noProxyValue; @@ -16062,6 +16240,8 @@ var require_websocket = __commonJS({ var { SendQueue } = require_sender(); var { WebsocketFrameSend } = require_frame(); var { channels } = require_diagnostics(); + var kRef = /* @__PURE__ */ Symbol.for("nodejs.ref"); + var kUnref = /* @__PURE__ */ Symbol.for("nodejs.unref"); function getSocketAddress(socket) { if (typeof socket?.address === "function") { return socket.address(); @@ -16085,6 +16265,7 @@ var require_websocket = __commonJS({ #bufferedAmount = 0; #protocol = ""; #extensions = ""; + #refed = true; /** @type {SendQueue} */ #sendQueue; /** @type {Handler} */ @@ -16167,6 +16348,16 @@ var require_websocket = __commonJS({ this.#handler.readyState = _WebSocket.CONNECTING; this.#binaryType = "blob"; } + [kRef]() { + webidl.brandCheck(this, _WebSocket); + this.#refed = true; + this.#handler.socket?.ref?.(); + } + [kUnref]() { + webidl.brandCheck(this, _WebSocket); + this.#refed = false; + this.#handler.socket?.unref?.(); + } /** * @see https://websockets.spec.whatwg.org/#dom-websocket-close * @param {number|undefined} code @@ -16328,6 +16519,9 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this.#handler.socket = response.socket; + if (!this.#refed) { + this.#handler.socket.unref?.(); + } const maxFragments = this.#handler.controller.dispatcher?.webSocketOptions?.maxFragments; const maxPayloadSize = this.#handler.controller.dispatcher?.webSocketOptions?.maxPayloadSize; const parser = new ByteParser(this.#handler, parsedExtensions, { @@ -17247,7 +17441,6 @@ var require_readable = __commonJS({ var kContentLength = /* @__PURE__ */ Symbol("kContentLength"); var kUsed = /* @__PURE__ */ Symbol("kUsed"); var kBytesRead = /* @__PURE__ */ Symbol("kBytesRead"); - var kPreservedBuffer = /* @__PURE__ */ Symbol("kPreservedBuffer"); var noop = /* @__PURE__ */ __name(() => { }, "noop"); var BodyReadable = class extends Readable { @@ -17489,21 +17682,6 @@ var require_readable = __commonJS({ */ setEncoding(encoding) { if (Buffer.isEncoding(encoding)) { - const state = this._readableState; - const buffer = state.buffer; - if (buffer && state.length > 0) { - const bufferIndex = state.bufferIndex ?? 0; - const preserved = []; - const source = typeof buffer.slice === "function" ? buffer.slice(bufferIndex) : buffer; - for (const data of source) { - if (Buffer.isBuffer(data)) { - preserved.push(data); - } - } - if (preserved.length > 0) { - this[kPreservedBuffer] = (this[kPreservedBuffer] || []).concat(preserved); - } - } super.setEncoding(encoding); } return this; @@ -17557,13 +17735,7 @@ var require_readable = __commonJS({ return; } const { _readableState: state } = consume2.stream; - const preserved = consume2.stream[kPreservedBuffer]; - if (preserved && preserved.length > 0) { - for (const chunk of preserved) { - consumePush(consume2, chunk); - } - consume2.stream[kPreservedBuffer] = null; - } else if (state.bufferIndex) { + if (state.bufferIndex) { const start = state.bufferIndex; const end = state.buffer.length; for (let n = start; n < end; n++) { @@ -17574,13 +17746,17 @@ var require_readable = __commonJS({ consumePush(consume2, chunk); } } + const decoder = state.decoder; + if (decoder != null && decoder.lastNeed > 0) { + consumePush(consume2, Buffer.from(decoder.lastChar.subarray(0, decoder.lastTotal - decoder.lastNeed))); + } if (state.endEmitted) { - consumeEnd(this[kConsume], this._readableState.encoding); - } else { - consume2.stream.on("end", function() { - consumeEnd(this[kConsume], this._readableState.encoding); - }); + consumeEnd(consume2, state.encoding); + return; } + consume2.stream.on("end", function() { + consumeEnd(this[kConsume], this._readableState.encoding); + }); consume2.stream.resume(); while (consume2.stream.read() != null) { } @@ -17641,6 +17817,9 @@ var require_readable = __commonJS({ if (consume2.body === null) { return; } + if (typeof chunk === "string") { + chunk = Buffer.from(chunk, consume2.stream._readableState.encoding); + } consume2.length += chunk.length; consume2.body.push(chunk); } diff --git a/src/undici_version.h b/src/undici_version.h index e6ecc19f161c..8afd91ad6f9a 100644 --- a/src/undici_version.h +++ b/src/undici_version.h @@ -2,5 +2,5 @@ // Refer to tools/dep_updaters/update-undici.sh #ifndef SRC_UNDICI_VERSION_H_ #define SRC_UNDICI_VERSION_H_ -#define UNDICI_VERSION "8.9.0" +#define UNDICI_VERSION "8.10.0" #endif // SRC_UNDICI_VERSION_H_ From 83169e58f319e3b2038c59c96e472db994da3af8 Mon Sep 17 00:00:00 2001 From: Trevor Burnham Date: Sat, 8 Aug 2026 20:54:22 -0400 Subject: [PATCH 107/344] sqlite: reject statement-less SQL in SQLTagStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sqlite3_prepare_v2() returns SQLITE_OK without producing a statement when its input holds no SQL, such as a comment. PrepareStatement() only checked the return code, so it cached a StatementSync wrapping a null sqlite3_stmt. Executing it reached sqlite3_clear_bindings(), which only guards against a null statement under SQLITE_ENABLE_API_ARMOR, and segfaulted. Reject such input instead of caching it. The StatementSync methods already avoid the crash because their IsFinalized() guard treats a null statement as finalized. Fixes: https://github.com/nodejs/node/issues/65149 Signed-off-by: Trevor Burnham PR-URL: https://github.com/nodejs/node/pull/65157 Fixes: https://github.com/nodejs/node/issues/65149 Reviewed-By: René Reviewed-By: Trivikram Kamat --- src/node_sqlite.cc | 8 ++++++++ test/parallel/test-sqlite-template-tag.js | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/node_sqlite.cc b/src/node_sqlite.cc index 0898e450a503..7688ba30b899 100644 --- a/src/node_sqlite.cc +++ b/src/node_sqlite.cc @@ -3659,6 +3659,14 @@ BaseObjectPtr SQLTagStore::PrepareStatement( return BaseObjectPtr(); } + // sqlite3_prepare_v2() reports success without producing a statement when + // the input holds no SQL, such as a comment. Such a statement cannot be + // bound or executed, so reject it instead of caching it. + if (s == nullptr) { + THROW_ERR_INVALID_ARG_VALUE(env, "The SQL query contains no statements."); + return BaseObjectPtr(); + } + BaseObjectPtr stmt_obj = StatementSync::Create( env, BaseObjectPtr(session->database_), s); diff --git a/test/parallel/test-sqlite-template-tag.js b/test/parallel/test-sqlite-template-tag.js index 445231bef0bd..20376e199d1b 100644 --- a/test/parallel/test-sqlite-template-tag.js +++ b/test/parallel/test-sqlite-template-tag.js @@ -317,6 +317,29 @@ test('sql error messages are descriptive', () => { }); }); +test('rejects SQL that contains no statements', () => { + const expectedError = { + code: 'ERR_INVALID_ARG_VALUE', + message: /contains no statements/, + }; + + for (const method of ['run', 'get', 'all', 'iterate']) { + assert.throws(() => { + // eslint-disable-next-line no-unused-expressions + sql[method]`-- comment`; + }, expectedError); + + assert.throws(() => { + // eslint-disable-next-line no-unused-expressions + sql[method]``; + }, expectedError); + } + + // A rejected statement must not be cached, so a later valid query with the + // same tag store still works. + assert.strictEqual(sql.run`INSERT INTO foo (text) VALUES (${'bob'})`.changes, 1); +}); + test('a tag store keeps the database alive by itself', () => { const sql = new DatabaseSync(':memory:').createTagStore(); From 5e387ce979c1133c89bd17974566cb6d7f32fe39 Mon Sep 17 00:00:00 2001 From: Trevor Burnham Date: Sun, 9 Aug 2026 11:48:27 -0400 Subject: [PATCH 108/344] sqlite: reject statement-less SQL in prepare() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the same check to DatabaseSync::Prepare() so that statement-less SQL is rejected at preparation instead of on first use. This matches SQLite's own oo1 JavaScript API, which throws when the SQL contains no statements rather than exposing the C API's null statement pointer. Previously db.prepare('-- comment') returned a StatementSync whose statement_ was null. Every method on it threw "statement has been finalized", which was misleading because nothing had been finalized, and the object was still inserted into statements_. Since IsFinalized() is true for a null statement, its destructor skipped UntrackStatement() and left a dangling pointer in the set that a later close() would finalize. Refs: https://github.com/nodejs/node/pull/65157#discussion_r3742903347 Refs: https://sqlite.org/wasm/doc/trunk/api-oo1.md Signed-off-by: Trevor Burnham PR-URL: https://github.com/nodejs/node/pull/65157 Fixes: https://github.com/nodejs/node/issues/65149 Reviewed-By: René Reviewed-By: Trivikram Kamat --- doc/api/sqlite.md | 4 ++++ src/node_sqlite.cc | 15 ++++++++++--- test/parallel/test-sqlite-database-sync.js | 26 ++++++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/doc/api/sqlite.md b/doc/api/sqlite.md index 449e88bfb3a5..601961243ce2 100644 --- a/doc/api/sqlite.md +++ b/doc/api/sqlite.md @@ -662,6 +662,10 @@ console.log(query.get()); * `sql` {string} A SQL string to compile to a prepared statement. diff --git a/src/node_sqlite.cc b/src/node_sqlite.cc index 7688ba30b899..97384bdf449d 100644 --- a/src/node_sqlite.cc +++ b/src/node_sqlite.cc @@ -1581,6 +1581,16 @@ void DatabaseSync::Prepare(const FunctionCallbackInfo& args) { int r = sqlite3_prepare_v2(db->connection_, *sql, -1, &s, nullptr); CHECK_ERROR_OR_THROW(env->isolate(), db, r, SQLITE_OK, void()); + + // sqlite3_prepare_v2() reports success without producing a statement when + // the input holds no SQL, such as a comment. Such a statement can never be + // stepped, and tracking it would leave a dangling pointer in statements_ + // because its destructor treats a null statement as already finalized. + if (s == nullptr) { + THROW_ERR_INVALID_ARG_VALUE(env, "The SQL query contains no statements."); + return; + } + BaseObjectPtr stmt = StatementSync::Create(env, BaseObjectPtr(db), s); db->statements_.insert(stmt.get()); @@ -3659,9 +3669,8 @@ BaseObjectPtr SQLTagStore::PrepareStatement( return BaseObjectPtr(); } - // sqlite3_prepare_v2() reports success without producing a statement when - // the input holds no SQL, such as a comment. Such a statement cannot be - // bound or executed, so reject it instead of caching it. + // As in DatabaseSync::Prepare(), reject input that holds no SQL rather + // than caching a statement that can never be bound or stepped. if (s == nullptr) { THROW_ERR_INVALID_ARG_VALUE(env, "The SQL query contains no statements."); return BaseObjectPtr(); diff --git a/test/parallel/test-sqlite-database-sync.js b/test/parallel/test-sqlite-database-sync.js index af7677a3cfc0..08a636c9cbdc 100644 --- a/test/parallel/test-sqlite-database-sync.js +++ b/test/parallel/test-sqlite-database-sync.js @@ -397,6 +397,32 @@ suite('DatabaseSync.prototype.prepare()', () => { message: /The "sql" argument must be a string/, }); }); + + test('throws if sql contains no statements', (t) => { + using db = new DatabaseSync(nextDb()); + + for (const sql of ['', ' ', ';', '-- comment', '/* comment */']) { + t.assert.throws(() => { + db.prepare(sql); + }, { + code: 'ERR_INVALID_ARG_VALUE', + message: /contains no statements/, + }); + } + }); + + test('prepares statements that contain comments', (t) => { + using db = new DatabaseSync(nextDb()); + const queries = [ + '-- lead\nSELECT 1 AS v', + 'SELECT 1 AS v -- trail', + 'SELECT /* mid */ 1 AS v', + ]; + + for (const sql of queries) { + t.assert.strictEqual(db.prepare(sql).get().v, 1); + } + }); }); suite('DatabaseSync.prototype.exec()', () => { From 162257b403557c20d571522bcce81cf2959eb0dd Mon Sep 17 00:00:00 2001 From: Kamal Rawal Date: Tue, 11 Aug 2026 11:18:03 +0530 Subject: [PATCH 109/344] assert: improve documentation wording Signed-off-by: Rawal27 PR-URL: https://github.com/nodejs/node/pull/64953 Reviewed-By: Aviv Keller Reviewed-By: Rich Trott --- doc/api/assert.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/api/assert.md b/doc/api/assert.md index 1a99709767ef..c33f4ba82336 100644 --- a/doc/api/assert.md +++ b/doc/api/assert.md @@ -317,7 +317,7 @@ const assert2 = new Assert({ skipPrototype: true }); assert2.deepStrictEqual(foo, bar); // OK ``` -When destructured, methods lose access to the instance's `this` context and revert to default assertion behavior +When destructured, methods lose access to the instance's `this` context and revert to the default assertion behavior (diff: 'simple', non-strict mode). To maintain custom options when using destructured methods, avoid destructuring and call methods directly on the instance. @@ -423,8 +423,8 @@ are also recursively evaluated by the following rules. ### Comparison details * Primitive values are compared with the [`==` operator][], - with the exception of {NaN}. It is treated as being identical in case - both sides are {NaN}. + except for {NaN}, which is treated as identical when both + sides are {NaN}. * [Type tags][Object.prototype.toString()] of objects should be the same. * Only [enumerable "own" properties][] are considered. * Object constructors are compared when available. @@ -938,7 +938,7 @@ error messages as expressive as possible. If specified, `error` can be a [`Class`][], {RegExp} or a validation function. See [`assert.throws()`][] for more details. -Besides the async nature to await the completion behaves identically to +Aside from asynchronously awaiting completion, it behaves identically to [`assert.doesNotThrow()`][]. ```mjs From 40b1b18839e4fffde57f76be5311904840c59487 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Tue, 11 Aug 2026 12:56:18 +0200 Subject: [PATCH 110/344] tools: remove skip logic in `commit-queue.sh` Signed-off-by: Antoine du Hamel PR-URL: https://github.com/nodejs/node/pull/65162 Reviewed-By: Jithil P Ponnan Reviewed-By: Filip Skokan Reviewed-By: Chemi Atlow Reviewed-By: Luigi Pinca Reviewed-By: Aviv Keller Reviewed-By: Trivikram Kamat Reviewed-By: Colin Ihrig --- tools/actions/commit-queue.sh | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/tools/actions/commit-queue.sh b/tools/actions/commit-queue.sh index b6e62139d626..9828f2d6b3e8 100755 --- a/tools/actions/commit-queue.sh +++ b/tools/actions/commit-queue.sh @@ -35,18 +35,7 @@ SHOULD_ABORT= for pr in "$@"; do gh pr view "$pr" --json labels --jq ".labels" > labels.json - # Skip PR if CI was requested - if jq -e 'map(.name) | index("request-ci")' < labels.json; then - echo "pr ${pr} skipped, waiting for CI to start" - continue - fi - - # Skip PR if CI is still running - if gh pr checks "$pr" | grep -q "\spending\s"; then - echo "pr ${pr} skipped, CI still running" - continue - fi - + if jq -e 'map(.name) | index("commit-queue-squash")' < labels.json; then MULTIPLE_COMMIT_POLICY="--fixupAll" elif jq -e 'map(.name) | index("commit-queue-rebase")' < labels.json; then From 3cfe19489c3fa96d2675a99fc29401a78d7896e7 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Tue, 11 Aug 2026 07:08:57 -0700 Subject: [PATCH 111/344] src: use concepts where appropriate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some general modernizations of templates Signed-off-by: James M Snell PR-URL: https://github.com/nodejs/node/pull/65148 Reviewed-By: Aviv Keller Reviewed-By: Tobias Nießen --- node.gyp | 1 + src/aliased_buffer-inl.h | 20 +++++++++++++ src/aliased_buffer.h | 3 +- src/blob_serializer_deserializer-inl.h | 20 ++++++------- src/blob_serializer_deserializer.h | 10 +++++++ src/crypto/crypto_aes.cc | 2 +- src/crypto/crypto_util.h | 2 +- src/debug_utils-inl.h | 14 +++------ src/json_utils.h | 6 ++-- src/memory_tracker-inl.h | 2 +- src/memory_tracker.h | 6 ++-- src/node_concepts.h | 39 ++++++++++++++++++++++++++ src/node_i18n.cc | 4 +-- src/node_platform.h | 9 ++---- src/node_realm-inl.h | 4 +-- src/node_realm.h | 2 +- src/node_report.h | 2 +- src/node_sea.cc | 10 +++---- src/node_snapshotable.cc | 12 ++++---- src/node_snapshotable.h | 13 ++------- src/node_wasi.cc | 8 +++--- src/req_wrap-inl.h | 3 +- src/stream_base-inl.h | 31 +++++++++----------- src/stream_base.h | 7 ++--- src/stream_wrap.cc | 6 ++-- src/tracing/trace_event_legacy_inl.h | 7 +++-- src/util-inl.h | 26 ++++++++--------- src/util.h | 33 ++++++++-------------- 28 files changed, 161 insertions(+), 141 deletions(-) create mode 100644 src/node_concepts.h diff --git a/node.gyp b/node.gyp index 5fbf5e31c039..82913b344b29 100644 --- a/node.gyp +++ b/node.gyp @@ -254,6 +254,7 @@ 'src/node_blob.h', 'src/node_buffer.h', 'src/node_builtins.h', + 'src/node_concepts.h', 'src/node_config_file.h', 'src/node_constants.h', 'src/node_context_data.h', diff --git a/src/aliased_buffer-inl.h b/src/aliased_buffer-inl.h index da2c45321f6d..5cb36e707ce2 100644 --- a/src/aliased_buffer-inl.h +++ b/src/aliased_buffer-inl.h @@ -12,6 +12,7 @@ namespace node { typedef size_t AliasedBufferIndex; template + requires std::is_scalar_v AliasedBufferBase::AliasedBufferBase( v8::Isolate* isolate, const size_t count, const AliasedBufferIndex* index) : isolate_(isolate), count_(count), byte_offset_(0), index_(index) { @@ -34,6 +35,7 @@ AliasedBufferBase::AliasedBufferBase( } template + requires std::is_scalar_v AliasedBufferBase::AliasedBufferBase( v8::Isolate* isolate, const size_t byte_offset, @@ -65,6 +67,7 @@ AliasedBufferBase::AliasedBufferBase( } template + requires std::is_scalar_v AliasedBufferBase::AliasedBufferBase( const AliasedBufferBase& that) : isolate_(that.isolate_), @@ -76,6 +79,7 @@ AliasedBufferBase::AliasedBufferBase( } template + requires std::is_scalar_v AliasedBufferIndex AliasedBufferBase::Serialize( v8::Local context, v8::SnapshotCreator* creator) { DCHECK(is_valid()); @@ -83,6 +87,7 @@ AliasedBufferIndex AliasedBufferBase::Serialize( } template + requires std::is_scalar_v inline void AliasedBufferBase::Deserialize( v8::Local context) { DCHECK_NOT_NULL(index_); @@ -99,6 +104,7 @@ inline void AliasedBufferBase::Deserialize( } template + requires std::is_scalar_v AliasedBufferBase& AliasedBufferBase::operator=( AliasedBufferBase&& that) noexcept { DCHECK(is_valid()); @@ -116,41 +122,48 @@ AliasedBufferBase& AliasedBufferBase::operator=( } template + requires std::is_scalar_v v8::Local AliasedBufferBase::GetJSArray() const { DCHECK(is_valid()); return js_array_.Get(isolate_); } template + requires std::is_scalar_v void AliasedBufferBase::Release() { DCHECK_NULL(index_); js_array_.Reset(); } template + requires std::is_scalar_v inline void AliasedBufferBase::MakeWeak() { DCHECK(is_valid()); js_array_.SetWeak(); } template + requires std::is_scalar_v v8::Local AliasedBufferBase::GetArrayBuffer() const { return GetJSArray()->Buffer(); } template + requires std::is_scalar_v inline const NativeT* AliasedBufferBase::GetNativeBuffer() const { DCHECK(is_valid()); return buffer_; } template + requires std::is_scalar_v inline const NativeT* AliasedBufferBase::operator*() const { return GetNativeBuffer(); } template + requires std::is_scalar_v inline void AliasedBufferBase::SetValue(const size_t index, NativeT value) { DCHECK_LT(index, count_); @@ -159,6 +172,7 @@ inline void AliasedBufferBase::SetValue(const size_t index, } template + requires std::is_scalar_v inline const NativeT AliasedBufferBase::GetValue( const size_t index) const { DCHECK(is_valid()); @@ -167,6 +181,7 @@ inline const NativeT AliasedBufferBase::GetValue( } template + requires std::is_scalar_v typename AliasedBufferBase::Reference AliasedBufferBase::operator[](size_t index) { DCHECK(is_valid()); @@ -174,16 +189,19 @@ AliasedBufferBase::operator[](size_t index) { } template + requires std::is_scalar_v NativeT AliasedBufferBase::operator[](size_t index) const { return GetValue(index); } template + requires std::is_scalar_v size_t AliasedBufferBase::Length() const { return count_; } template + requires std::is_scalar_v void AliasedBufferBase::reserve(size_t new_capacity) { DCHECK(is_valid()); DCHECK_GE(new_capacity, count_); @@ -214,11 +232,13 @@ void AliasedBufferBase::reserve(size_t new_capacity) { } template + requires std::is_scalar_v inline bool AliasedBufferBase::is_valid() const { return index_ == nullptr && !js_array_.IsEmpty(); } template + requires std::is_scalar_v inline size_t AliasedBufferBase::SelfSize() const { return sizeof(*this); } diff --git a/src/aliased_buffer.h b/src/aliased_buffer.h index 8e988bcb010d..ff2b961724d6 100644 --- a/src/aliased_buffer.h +++ b/src/aliased_buffer.h @@ -29,10 +29,9 @@ typedef size_t AliasedBufferIndex; * observed. Any notification APIs will be left as a future exercise. */ template + requires std::is_scalar_v class AliasedBufferBase final : public MemoryRetainer { public: - static_assert(std::is_scalar_v); - AliasedBufferBase(v8::Isolate* isolate, size_t count, const AliasedBufferIndex* index = nullptr); diff --git a/src/blob_serializer_deserializer-inl.h b/src/blob_serializer_deserializer-inl.h index fa258dbe8144..2a2aed1171f7 100644 --- a/src/blob_serializer_deserializer-inl.h +++ b/src/blob_serializer_deserializer-inl.h @@ -90,8 +90,8 @@ std::string BlobSerializerDeserializer::GetName() const { // Helper for reading numeric types. template template + requires std::is_arithmetic_v T BlobDeserializer::ReadArithmetic() { - static_assert(std::is_arithmetic_v, "Not an arithmetic type"); T result; ReadArithmetic(&result, 1); return result; @@ -158,8 +158,8 @@ std::string_view BlobDeserializer::ReadStringView(StringLogMode mode) { // Helper for reading an array of numeric types. template template + requires std::is_arithmetic_v void BlobDeserializer::ReadArithmetic(T* out, size_t count) { - static_assert(std::is_arithmetic_v, "Not an arithmetic type"); DCHECK_GT(count, 0); // Should not read contents for vectors of size 0. if (is_debug) { std::string name = GetName(); @@ -180,8 +180,8 @@ void BlobDeserializer::ReadArithmetic(T* out, size_t count) { // Helper for reading numeric vectors. template template + requires std::is_arithmetic_v std::vector BlobDeserializer::ReadArithmeticVector(size_t count) { - static_assert(std::is_arithmetic_v, "Not an arithmetic type"); DCHECK_GT(count, 0); // Should not read contents for vectors of size 0. std::vector result(count); ReadArithmetic(result.data(), count); @@ -191,8 +191,8 @@ std::vector BlobDeserializer::ReadArithmeticVector(size_t count) { // Helper for reading non-numeric vectors. template template + requires(!std::is_arithmetic_v) std::vector BlobDeserializer::ReadNonArithmeticVector(size_t count) { - static_assert(!std::is_arithmetic_v, "Arithmetic type"); DCHECK_GT(count, 0); // Should not read contents for vectors of size 0. std::vector result; result.reserve(count); @@ -224,8 +224,8 @@ T BlobDeserializer::ReadElement() { // Helper for writing numeric types. template template + requires std::is_arithmetic_v size_t BlobSerializer::WriteArithmetic(const T& data) { - static_assert(std::is_arithmetic_v, "Not an arithmetic type"); return WriteArithmetic(&data, 1); } @@ -303,8 +303,8 @@ static size_t kPreviewCount = 16; // Helper for writing an array of numeric types. template template + requires std::is_arithmetic_v size_t BlobSerializer::WriteArithmetic(const T* data, size_t count) { - static_assert(std::is_arithmetic_v, "Arithmetic type"); DCHECK_GT(count, 0); // Should not write contents for vectors of size 0. if (is_debug) { size_t preview_count = count < kPreviewCount ? count : kPreviewCount; @@ -338,18 +338,18 @@ size_t BlobSerializer::WriteArithmetic(const T* data, size_t count) { // Helper for writing numeric vectors. template template + requires std::is_arithmetic_v size_t BlobSerializer::WriteArithmeticVector( const std::vector& data) { - static_assert(std::is_arithmetic_v, "Arithmetic type"); return WriteArithmetic(data.data(), data.size()); } // Helper for writing non-numeric vectors. template template -size_t BlobSerializer::WriteNonArithmeticVector( - const std::vector& data) { - static_assert(!std::is_arithmetic_v, "Arithmetic type"); + requires(!std::is_arithmetic_v) +size_t + BlobSerializer::WriteNonArithmeticVector(const std::vector& data) { DCHECK_GT(data.size(), 0); // Should not write contents for vectors of size 0. size_t written_total = 0; diff --git a/src/blob_serializer_deserializer.h b/src/blob_serializer_deserializer.h index fe7989e22a35..cd63d0477675 100644 --- a/src/blob_serializer_deserializer.h +++ b/src/blob_serializer_deserializer.h @@ -1,7 +1,9 @@ #ifndef SRC_BLOB_SERIALIZER_DESERIALIZER_H_ #define SRC_BLOB_SERIALIZER_DESERIALIZER_H_ +#include #include +#include #include #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS @@ -49,6 +51,7 @@ class BlobDeserializer : public BlobSerializerDeserializer { // Helper for reading numeric types. template + requires std::is_arithmetic_v T ReadArithmetic(); // Layout of vectors: @@ -63,15 +66,18 @@ class BlobDeserializer : public BlobSerializerDeserializer { // Helper for reading an array of numeric types. template + requires std::is_arithmetic_v void ReadArithmetic(T* out, size_t count); // Helper for reading numeric vectors. template + requires std::is_arithmetic_v std::vector ReadArithmeticVector(size_t count); private: // Helper for reading non-numeric vectors. template + requires(!std::is_arithmetic_v) std::vector ReadNonArithmeticVector(size_t count); template @@ -94,6 +100,7 @@ class BlobSerializer : public BlobSerializerDeserializer { // Helper for writing numeric types. template + requires std::is_arithmetic_v size_t WriteArithmetic(const T& data); // Layout of vectors: @@ -110,15 +117,18 @@ class BlobSerializer : public BlobSerializerDeserializer { // Helper for writing an array of numeric types. template + requires std::is_arithmetic_v size_t WriteArithmetic(const T* data, size_t count); // Helper for writing numeric vectors. template + requires std::is_arithmetic_v size_t WriteArithmeticVector(const std::vector& data); private: // Helper for writing non-numeric vectors. template + requires(!std::is_arithmetic_v) size_t WriteNonArithmeticVector(const std::vector& data); template diff --git a/src/crypto/crypto_aes.cc b/src/crypto/crypto_aes.cc index 171688b92926..bea8f5b24be5 100644 --- a/src/crypto/crypto_aes.cc +++ b/src/crypto/crypto_aes.cc @@ -259,7 +259,7 @@ WebCryptoCipherStatus AES_KW_Cipher(Environment* env, // implementation here: // https://github.com/chromium/chromium/blob/7af6cfd/components/webcrypto/algorithms/aes_ctr.cc -template +template T CeilDiv(T a, T b) { return a == 0 ? 0 : 1 + (a - 1) / b; } diff --git a/src/crypto/crypto_util.h b/src/crypto/crypto_util.h index 5c9dad13196b..dd7e0842a29b 100644 --- a/src/crypto/crypto_util.h +++ b/src/crypto/crypto_util.h @@ -732,8 +732,8 @@ class ArrayBufferOrViewContents final { } template + requires(sizeof(M) == 1) void CopyTo(M* dest, size_t len) const { - static_assert(sizeof(M) == 1, "sizeof(M) must equal 1"); len = std::min(len, size()); if (len > 0 && data() != nullptr) { memcpy(dest, data(), len); diff --git a/src/debug_utils-inl.h b/src/debug_utils-inl.h index 3afab8629968..5f739246825a 100644 --- a/src/debug_utils-inl.h +++ b/src/debug_utils-inl.h @@ -49,10 +49,7 @@ struct ToStringHelper { return value.ToStringView(); } - template || std::is_enum_v, bool>, - typename dummy = bool> + template static std::string Convert(const T& value) { return std::to_string(value); } @@ -81,9 +78,7 @@ struct ToStringHelper { return utf8_value.ToString(); } - template >> + template static std::string BaseConvert(const T& value) { auto v = static_cast(value); char ret[3 * sizeof(T)]; @@ -96,9 +91,8 @@ struct ToStringHelper { } while ((v >>= BASE_BITS) != 0); return ptr; } - template >> + template + requires(!std::integral) static auto BaseConvert(T&& value) { return Convert(std::forward(value)); } diff --git a/src/json_utils.h b/src/json_utils.h index 06d4a7ac0905..e75556578522 100644 --- a/src/json_utils.h +++ b/src/json_utils.h @@ -3,6 +3,8 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS +#include "node_concepts.h" + #include #include #include @@ -132,9 +134,7 @@ class JSONWriter { }; private: - template ::is_specialized, bool>::type> + template inline void write_value(T number) { if constexpr (std::is_same::value) out_ << (number ? "true" : "false"); diff --git a/src/memory_tracker-inl.h b/src/memory_tracker-inl.h index a59452fd994c..3c82983ce01e 100644 --- a/src/memory_tracker-inl.h +++ b/src/memory_tracker-inl.h @@ -203,7 +203,7 @@ void MemoryTracker::TrackField(const char* edge_name, TrackField(edge_name, container, node_name, element_name); } -template +template void MemoryTracker::TrackField(const char* edge_name, const T& value, const char* node_name) { diff --git a/src/memory_tracker.h b/src/memory_tracker.h index 88987c954f4d..d7893f10b3af 100644 --- a/src/memory_tracker.h +++ b/src/memory_tracker.h @@ -2,6 +2,7 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS +#include "node_concepts.h" #include "v8-profiler.h" #include @@ -229,10 +230,7 @@ class MemoryTracker { inline void TrackField(const char* edge_name, const std::basic_string& value, const char* node_name = nullptr); - template ::is_specialized, bool>::type, - typename dummy = bool> + template inline void TrackField(const char* edge_name, const T& value, const char* node_name = nullptr); diff --git a/src/node_concepts.h b/src/node_concepts.h new file mode 100644 index 000000000000..5dbce5c6f88a --- /dev/null +++ b/src/node_concepts.h @@ -0,0 +1,39 @@ +#ifndef SRC_NODE_CONCEPTS_H_ +#define SRC_NODE_CONCEPTS_H_ + +#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS + +#include +#include +#include + +namespace node { + +// A numeric type recognized by std::numeric_limits. +// The is_array guard prevents a hard error from instantiating +// numeric_limits, whose member functions would return array types. +template +concept NumericValue = ! +std::is_array_v&& std::numeric_limits::is_specialized; + +// A numeric type or an enum (which has an underlying numeric type). +template +concept NumericOrEnum = NumericValue || std::is_enum_v; + +// A type that has a valid std::char_traits specialization, as required by +// std::basic_string and std::basic_string_view. +template +concept StandardCharType = + std::is_same_v || std::is_same_v || + std::is_same_v || std::is_same_v || + std::is_same_v; + +// Test whether some value can be called with (). +template +concept IsCallable = std::is_function::value || requires { &T::operator(); }; + +} // namespace node + +#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS + +#endif // SRC_NODE_CONCEPTS_H_ diff --git a/src/node_i18n.cc b/src/node_i18n.cc index 3c4f419aa294..96ec2f6ee5ab 100644 --- a/src/node_i18n.cc +++ b/src/node_i18n.cc @@ -103,14 +103,12 @@ namespace i18n { namespace { template + requires(sizeof(T) == 1 || sizeof(T) == 2) MaybeLocal ToBufferEndian(Environment* env, MaybeStackBuffer* buf) { Local ret; if (!Buffer::New(env, buf).ToLocal(&ret)) { return {}; } - - static_assert(sizeof(T) == 1 || sizeof(T) == 2, - "Currently only one- or two-byte buffers are supported"); if constexpr (sizeof(T) > 1 && IsBigEndian()) { SPREAD_BUFFER_ARG(ret, retbuf); CHECK(nbytes::SwapBytes16(retbuf_data, retbuf_length)); diff --git a/src/node_platform.h b/src/node_platform.h index f47e2a46b66b..e98ecf322802 100644 --- a/src/node_platform.h +++ b/src/node_platform.h @@ -3,6 +3,7 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS +#include #include #include #include @@ -20,12 +21,8 @@ class NodePlatform; class IsolateData; class PerIsolatePlatformData; -template -struct has_priority : std::false_type {}; - template -struct has_priority().priority)>> - : std::true_type {}; +concept has_priority = requires(T t) { t.priority; }; template class TaskQueue { @@ -35,7 +32,7 @@ class TaskQueue { struct EntryCompare { bool operator()(const std::unique_ptr& a, const std::unique_ptr& b) const { - if constexpr (has_priority::value) { + if constexpr (has_priority) { return a->priority < b->priority; } else { return false; diff --git a/src/node_realm-inl.h b/src/node_realm-inl.h index b004bd1e1500..394ece5a8ace 100644 --- a/src/node_realm-inl.h +++ b/src/node_realm-inl.h @@ -92,10 +92,8 @@ inline T* Realm::GetBindingData() { return result; } -template +template T, typename... Args> inline T* Realm::AddBindingData(v8::Local target, Args&&... args) { - // This won't compile if T is not a BaseObject subclass. - static_assert(std::is_base_of_v); // The binding data must be weak so that it won't keep the realm reachable // from strong GC roots indefinitely. The wrapper object of binding data // should be referenced from JavaScript, thus the binding data should be diff --git a/src/node_realm.h b/src/node_realm.h index 2ed04aa271b4..690beaf1a1aa 100644 --- a/src/node_realm.h +++ b/src/node_realm.h @@ -127,7 +127,7 @@ class Realm : public MemoryRetainer { // Methods created using SetMethod(), SetPrototypeMethod(), etc. inside // this scope can access the created T* object using // GetBindingData(args) later. - template + template T, typename... Args> T* AddBindingData(v8::Local target, Args&&... args); template static inline T* GetBindingData(const v8::PropertyCallbackInfo& info); diff --git a/src/node_report.h b/src/node_report.h index 98be339ae90d..ae1e03df625b 100644 --- a/src/node_report.h +++ b/src/node_report.h @@ -22,7 +22,7 @@ namespace report { void WalkHandleNetwork(uv_handle_t* h, void* arg); void WalkHandleNoNetwork(uv_handle_t* h, void* arg); -template +template std::string ValueToHexString(T value) { std::stringstream hex; diff --git a/src/node_sea.cc b/src/node_sea.cc index 81d47c11ffab..03d487f1dba7 100644 --- a/src/node_sea.cc +++ b/src/node_sea.cc @@ -66,9 +66,8 @@ class SeaSerializer : public BlobSerializer { : BlobSerializer( per_process::enabled_debug_list.enabled(DebugCategory::SEA)) {} - template ::value>* = nullptr, - std::enable_if_t::value>* = nullptr> + template + requires(!std::is_arithmetic_v && !std::same_as) size_t Write(const T& data); }; @@ -150,9 +149,8 @@ class SeaDeserializer : public BlobDeserializer { : BlobDeserializer( per_process::enabled_debug_list.enabled(DebugCategory::SEA), v) {} - template ::value>* = nullptr, - std::enable_if_t::value>* = nullptr> + template + requires(!std::is_arithmetic_v && !std::same_as) T Read(); }; diff --git a/src/node_snapshotable.cc b/src/node_snapshotable.cc index f90e5f28ac28..7f5d9b9e1821 100644 --- a/src/node_snapshotable.cc +++ b/src/node_snapshotable.cc @@ -155,9 +155,8 @@ class SnapshotDeserializer : public BlobDeserializer { DebugCategory::SNAPSHOT_SERDES), v) {} - template ::value>* = nullptr, - std::enable_if_t::value>* = nullptr> + template + requires(!std::is_arithmetic_v && !std::same_as) T Read(); }; @@ -172,9 +171,8 @@ class SnapshotSerializer : public BlobSerializer { sink.reserve(4 * 1024 * 1024); } - template ::value>* = nullptr, - std::enable_if_t::value>* = nullptr> + template + requires(!std::is_arithmetic_v && !std::same_as) size_t Write(const T& data); }; @@ -733,13 +731,13 @@ static std::string FormatSize(size_t size) { } template + requires(std::same_as || std::same_as) void WriteByteVectorLiteral(std::ostream* ss, const T* vec, size_t size, const char* var_name, bool use_array_literals) { constexpr bool is_uint8_t = std::is_same_v; - static_assert(is_uint8_t || std::is_same_v); constexpr const char* type_name = is_uint8_t ? "uint8_t" : "char"; if (!use_array_literals) { const uint8_t* data = reinterpret_cast(vec); diff --git a/src/node_snapshotable.h b/src/node_snapshotable.h index 31be74bcfd56..18ef5fbd43fd 100644 --- a/src/node_snapshotable.h +++ b/src/node_snapshotable.h @@ -41,11 +41,8 @@ struct InternalFieldInfoBase { EmbedderObjectType type; size_t length; - template + template T> static T* New(EmbedderObjectType type) { - static_assert(std::is_base_of_v || - std::is_same_v, - "Can only accept InternalFieldInfoBase subclasses"); void* buf = ::operator new[](sizeof(T)); memset(buf, 0, sizeof(T)); // Make the padding reproducible. T* result = new (buf) T; @@ -54,13 +51,9 @@ struct InternalFieldInfoBase { return result; } - template + template T> + requires std::is_trivially_copyable_v T* Copy() const { - static_assert(std::is_base_of_v || - std::is_same_v, - "Can only accept InternalFieldInfoBase subclasses"); - static_assert(std::is_trivially_copyable_v, - "Can only memcpy trivially copyable class"); void* buf = ::operator new[](sizeof(T)); T* result = new (buf) T; memcpy(result, this, sizeof(T)); diff --git a/src/node_wasi.cc b/src/node_wasi.cc index 7ee59f463310..7d8d056620c4 100644 --- a/src/node_wasi.cc +++ b/src/node_wasi.cc @@ -358,8 +358,8 @@ template ::value, bool> = true> + std::size_t... Indices> + requires(!std::is_void_v) inline void CallAndSetReturn(std::index_sequence, const FunctionCallbackInfo& args, WASI* wasi, @@ -372,8 +372,8 @@ template ::value, bool> = true> + std::size_t... Indices> + requires std::is_void_v inline void CallAndSetReturn(std::index_sequence, const FunctionCallbackInfo& args, WASI* wasi, diff --git a/src/req_wrap-inl.h b/src/req_wrap-inl.h index ac0ca8921a1e..61d6d79116ad 100644 --- a/src/req_wrap-inl.h +++ b/src/req_wrap-inl.h @@ -111,8 +111,7 @@ struct CallLibuvFunction { template struct MakeLibuvRequestCallback { static T For(ReqWrap* req_wrap, T v) { - static_assert(!is_callable, - "MakeLibuvRequestCallback missed a callback"); + static_assert(!IsCallable, "MakeLibuvRequestCallback missed a callback"); return v; } }; diff --git a/src/stream_base-inl.h b/src/stream_base-inl.h index 4418cdad504f..cc468ee566a7 100644 --- a/src/stream_base-inl.h +++ b/src/stream_base-inl.h @@ -98,25 +98,20 @@ StreamBase::StreamBase(Environment* env) : env_(env) { PushStreamListener(&default_listener_); } -template +template OtherBase> SimpleShutdownWrap::SimpleShutdownWrap( - StreamBase* stream, - v8::Local req_wrap_obj) - : ShutdownWrap(stream, req_wrap_obj), - OtherBase(stream->stream_env(), - req_wrap_obj, - AsyncWrap::PROVIDER_SHUTDOWNWRAP) { -} - -template -SimpleWriteWrap::SimpleWriteWrap( - StreamBase* stream, - v8::Local req_wrap_obj) - : WriteWrap(stream, req_wrap_obj), - OtherBase(stream->stream_env(), - req_wrap_obj, - AsyncWrap::PROVIDER_WRITEWRAP) { -} + StreamBase* stream, v8::Local req_wrap_obj) + : ShutdownWrap(stream, req_wrap_obj), + OtherBase(stream->stream_env(), + req_wrap_obj, + AsyncWrap::PROVIDER_SHUTDOWNWRAP) {} + +template OtherBase> +SimpleWriteWrap::SimpleWriteWrap(StreamBase* stream, + v8::Local req_wrap_obj) + : WriteWrap(stream, req_wrap_obj), + OtherBase( + stream->stream_env(), req_wrap_obj, AsyncWrap::PROVIDER_WRITEWRAP) {} void StreamBase::AttachToObject(v8::Local obj) { obj->SetAlignedPointerInInternalField( diff --git a/src/stream_base.h b/src/stream_base.h index be00134eb1fc..cb795a541297 100644 --- a/src/stream_base.h +++ b/src/stream_base.h @@ -432,12 +432,11 @@ class StreamBase : public StreamResource { friend class Environment; // For kNumStreamBaseStateFields. }; - // These are helpers for creating `ShutdownWrap`/`WriteWrap` instances. // `OtherBase` must have a constructor that matches the `AsyncWrap` -// constructors’s (Environment*, Local, AsyncWrap::Provider) signature +// constructors's (Environment*, Local, AsyncWrap::Provider) signature // and be a subclass of `AsyncWrap`. -template +template OtherBase> class SimpleShutdownWrap : public ShutdownWrap, public OtherBase { public: enum InternalFields { @@ -459,7 +458,7 @@ class SimpleShutdownWrap : public ShutdownWrap, public OtherBase { } }; -template +template OtherBase> class SimpleWriteWrap : public WriteWrap, public OtherBase { public: enum InternalFields { diff --git a/src/stream_wrap.cc b/src/stream_wrap.cc index b41f6ac74947..6b85d6533879 100644 --- a/src/stream_wrap.cc +++ b/src/stream_wrap.cc @@ -227,12 +227,10 @@ void LibuvStreamWrap::OnUvAlloc(size_t suggested_size, uv_buf_t* buf) { } template + requires(std::derived_from || + std::derived_from) static MaybeLocal AcceptHandle(Environment* env, LibuvStreamWrap* parent) { - static_assert(std::is_base_of::value || - std::is_base_of::value, - "Can only accept stream handles"); - EscapableHandleScope scope(env->isolate()); Local wrap_obj; diff --git a/src/tracing/trace_event_legacy_inl.h b/src/tracing/trace_event_legacy_inl.h index 4fadb6d8e38f..9030473afe96 100644 --- a/src/tracing/trace_event_legacy_inl.h +++ b/src/tracing/trace_event_legacy_inl.h @@ -9,6 +9,8 @@ #error Perfetto is enabled. #endif +#include + #include "v8-platform.h" #include "tracing/agent_legacy.h" #include "tracing/trace_event_helper.h" @@ -558,9 +560,8 @@ static inline void SetTraceValue(v8::ConvertableToTraceFormat* convertable_value *value = static_cast(reinterpret_cast(convertable_value)); } -template -static inline typename std::enable_if< - std::is_convertible::value>::type +template T> +static inline void SetTraceValue(std::unique_ptr ptr, unsigned char* type, uint64_t* value) { SetTraceValue(ptr.release(), type, value); } diff --git a/src/util-inl.h b/src/util-inl.h index e357d15a1449..4ac8726aa43f 100644 --- a/src/util-inl.h +++ b/src/util-inl.h @@ -195,7 +195,7 @@ char ToLower(char c) { return std::tolower(c, std::locale::classic()); } -template +template std::string ToLower(const T& in) { auto it = std::cbegin(in); auto end = std::cend(in); @@ -210,7 +210,7 @@ char ToUpper(char c) { return std::toupper(c, std::locale::classic()); } -template +template std::string ToUpper(const T& in) { auto it = std::cbegin(in); auto end = std::cend(in); @@ -239,7 +239,7 @@ bool StringEqualNoCaseN(const char* a, const char* b, size_t length) { return true; } -template +template inline T MultiplyWithOverflowCheck(T a, T b) { auto ret = a * b; if (a != 0) @@ -485,7 +485,7 @@ v8::Local ConvertNumberToV8Value(v8::Isolate* isolate, return v8::Number::New(isolate, static_cast(number)); } -template +template v8::MaybeLocal ToV8Value(v8::Local context, const T& number, v8::Isolate* isolate) { @@ -494,14 +494,10 @@ v8::MaybeLocal ToV8Value(v8::Local context, } template + requires std::is_arithmetic_v v8::Local ToV8ValuePrimitiveArray(v8::Local context, const std::vector& vec, v8::Isolate* isolate) { - static_assert( - std::is_same_v || std::is_integral_v || - std::is_floating_point_v, - "Only primitive types (bool, integral, floating-point) are supported."); - if (isolate == nullptr) isolate = v8::Isolate::GetCurrent(); v8::EscapableHandleScope handle_scope(isolate); @@ -550,6 +546,7 @@ void MaybeStackBuffer::AllocateSufficientStorage( } template + requires(sizeof(T) == 1) ArrayBufferViewContents::ArrayBufferViewContents( v8::Local value) { DCHECK(value->IsArrayBufferView() || value->IsSharedArrayBuffer() || @@ -558,6 +555,7 @@ ArrayBufferViewContents::ArrayBufferViewContents( } template + requires(sizeof(T) == 1) ArrayBufferViewContents::ArrayBufferViewContents( v8::Local value) { CHECK(value->IsArrayBufferView()); @@ -565,14 +563,15 @@ ArrayBufferViewContents::ArrayBufferViewContents( } template + requires(sizeof(T) == 1) ArrayBufferViewContents::ArrayBufferViewContents( v8::Local abv) { Read(abv); } template + requires(sizeof(T) == 1) void ArrayBufferViewContents::Read(v8::Local abv) { - static_assert(sizeof(T) == 1, "Only supports one-byte data at the moment"); length_ = abv->ByteLength(); if (length_ > sizeof(stack_storage_) || abv->HasBuffer()) { auto buf_data = abv->Buffer()->Data(); @@ -585,8 +584,8 @@ void ArrayBufferViewContents::Read(v8::Local abv) { } template + requires(sizeof(T) == 1) void ArrayBufferViewContents::ReadValue(v8::Local buf) { - static_assert(sizeof(T) == 1, "Only supports one-byte data at the moment"); DCHECK(buf->IsArrayBufferView() || buf->IsSharedArrayBuffer() || buf->IsArrayBuffer()); @@ -649,10 +648,7 @@ constexpr std::string_view FastStringKey::as_string_view() const { } // Converts a V8 numeric value to a corresponding C++ primitive or enum type. -template ::is_specialized || - std::is_enum_v>> +template T FromV8Value(v8::Local value) { if constexpr (std::is_enum_v) { using Underlying = std::underlying_type_t; diff --git a/src/util.h b/src/util.h index 48305bfdc131..a979f626bdb4 100644 --- a/src/util.h +++ b/src/util.h @@ -30,6 +30,7 @@ #include "v8.h" #include "node.h" +#include "node_concepts.h" #include "node_exit_code.h" #include @@ -40,6 +41,7 @@ #include #include +#include #include #include #include @@ -99,7 +101,7 @@ inline char* Calloc(size_t n); inline char* UncheckedMalloc(size_t n); inline char* UncheckedCalloc(size_t n); -template +template inline T MultiplyWithOverflowCheck(T a, T b); namespace per_process { @@ -366,12 +368,12 @@ inline v8::Local FIXED_ONE_BYTE_STRING(v8::Isolate* isolate, // tolower() is locale-sensitive. Use ToLower() instead. inline char ToLower(char c); -template +template inline std::string ToLower(const T& in); // toupper() is locale-sensitive. Use ToUpper() instead. inline char ToUpper(char c); -template +template inline std::string ToUpper(const T& in); // strcasecmp() is locale-sensitive. Use StringEqualNoCase() instead. @@ -390,14 +392,6 @@ constexpr size_t strsize(const T (&)[N]) { return N - 1; } -// A type that has a valid std::char_traits specialization, as required by -// std::basic_string and std::basic_string_view. -template -concept standard_char_type = - std::is_same_v || std::is_same_v || - std::is_same_v || std::is_same_v || - std::is_same_v; - // Allocates an array of member type T. For up to kStackStorageSize items, // the stack is used, otherwise malloc(). template @@ -511,11 +505,11 @@ class MaybeStackBuffer { free(buf_); } - template + template inline std::basic_string ToString() const { return {out(), length()}; } - template + template inline std::basic_string_view ToStringView() const { return {out(), length()}; } @@ -534,6 +528,7 @@ class MaybeStackBuffer { // or for small data, a copy of it. This object's lifetime is bound to the // original ArrayBufferView's lifetime. template + requires(sizeof(T) == 1) class ArrayBufferViewContents { public: ArrayBufferViewContents() = default; @@ -610,7 +605,7 @@ class BufferValue : public MaybeStackBuffer { // silence a compiler warning about that. template inline void USE(T&&) {} -template +template struct OnScopeLeaveImpl { Fn fn_; bool active_; @@ -630,7 +625,7 @@ struct OnScopeLeaveImpl { // auto on_scope_leave = OnScopeLeave([&] { // // ... run some code ... // }); -template +template inline MUST_USE_RESULT OnScopeLeaveImpl OnScopeLeave(Fn&& fn) { return OnScopeLeaveImpl{std::move(fn)}; } @@ -676,11 +671,6 @@ struct MallocedBuffer { MallocedBuffer& operator=(const MallocedBuffer&) = delete; }; -// Test whether some value can be called with (). -template -concept is_callable = - std::is_function::value || requires { &T::operator(); }; - template struct FunctionDeleter { void operator()(T* pointer) const { function(pointer); } @@ -710,8 +700,7 @@ inline v8::MaybeLocal ToV8Value(v8::Local context, inline v8::MaybeLocal ToV8Value(v8::Local context, v8_inspector::StringView str, v8::Isolate* isolate); -template ::is_specialized, bool>::type> +template inline v8::MaybeLocal ToV8Value(v8::Local context, const T& number, v8::Isolate* isolate = nullptr); From f2cc681109bae31273dbb77ca7e3f1d628df16fb Mon Sep 17 00:00:00 2001 From: Daniel Lemire Date: Tue, 11 Aug 2026 10:09:16 -0400 Subject: [PATCH 112/344] deps: enable AVX-512 OpenSSL asm with clang Node.js ships two pre-generated sets of OpenSSL assembly: `asm`, which contains the AVX-512 routines, and `asm_avx2`, which does not. The set is picked in deps/openssl/openssl.gyp based on `gas_version` or `nasm_version`, but configure.py only reports `gas_version` when the compiler is not clang, because clang uses its own integrated assembler and has no GNU assembler version to report. Consequently every clang build silently falls back to the AVX-512-less `asm_avx2` set, with no warning. The result is that `ossl_vaes_vpclmulqdq_capable()` is assembled as a stub that always returns 0, so OpenSSL never selects `ossl_aes_gcm_encrypt_avx512()` and uses the older AES-NI path instead. On an Intel Xeon Gold 6548N this costs roughly 1.6x on AES-256-GCM and 1.8x on both ChaCha20-Poly1305 and RSA-2048 signing. This is not limited to custom builds: BUILDING.md documents that the official linux-x64 binaries are produced with clang, and the shipped v25.x and v26.x binaries contain the stub. Accept `llvm_version` in the condition, the way deps/openssl/openssl.gypi already does for the AVX2 set. clang's integrated assembler has handled AVX512IFMA since 3.9 and VAES / VPCLMULQDQ since 6.0; 8.0 is used as a conservative floor, well below the clang 19.1 that Node.js already requires. PR-URL: https://github.com/nodejs/node/pull/65136 Reviewed-By: Aviv Keller Reviewed-By: James M Snell Reviewed-By: Luigi Pinca --- deps/openssl/openssl.gyp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/deps/openssl/openssl.gyp b/deps/openssl/openssl.gyp index 4e16412a0283..144085fd33df 100644 --- a/deps/openssl/openssl.gyp +++ b/deps/openssl/openssl.gyp @@ -36,7 +36,8 @@ # VC-WIN64-ARM inherits from VC-noCE-common that has no asms. 'includes': ['./openssl_no_asm.gypi'], }, 'gas_version and v(gas_version) >= v("2.26") or ' - 'nasm_version and v(nasm_version) >= v("2.11.8")', { + 'nasm_version and v(nasm_version) >= v("2.11.8") or ' + 'llvm_version and v(llvm_version) >= v("8.0")', { # Require AVX512IFMA supported. See # https://www.openssl.org/docs/man1.1.1/man3/OPENSSL_ia32cap.html # Currently crypto/poly1305/asm/poly1305-x86_64.pl requires AVX512IFMA. @@ -114,7 +115,8 @@ # VC-WIN64-ARM inherits from VC-noCE-common that has no asms. 'includes': ['./openssl-fips_no_asm.gypi'], }, 'gas_version and v(gas_version) >= v("2.26") or ' - 'nasm_version and v(nasm_version) >= v("2.11.8")', { + 'nasm_version and v(nasm_version) >= v("2.11.8") or ' + 'llvm_version and v(llvm_version) >= v("8.0")', { # Require AVX512IFMA supported. See # https://www.openssl.org/docs/man1.1.1/man3/OPENSSL_ia32cap.html # Currently crypto/poly1305/asm/poly1305-x86_64.pl requires AVX512IFMA. From 3ab7e7255549ccdb7b8d9af55417a4d2bba0fcab Mon Sep 17 00:00:00 2001 From: semimikoh Date: Wed, 12 Aug 2026 00:30:37 +0900 Subject: [PATCH 113/344] sqlite: check sqlite3_step() and sqlite3_reset() results Signed-off-by: semimikoh PR-URL: https://github.com/nodejs/node/pull/63319 Fixes: https://github.com/nodejs/node/issues/63311 Reviewed-By: Trivikram Kamat --- src/node_sqlite.cc | 77 +++++++++++++++++---- test/parallel/test-sqlite-statement-sync.js | 76 ++++++++++++++++++++ 2 files changed, 139 insertions(+), 14 deletions(-) diff --git a/src/node_sqlite.cc b/src/node_sqlite.cc index 97384bdf449d..68554ed33b31 100644 --- a/src/node_sqlite.cc +++ b/src/node_sqlite.cc @@ -88,6 +88,17 @@ inline MaybeLocal Utf8StringMaybeOneByte(Isolate* isolate, } \ } while (0) +#define RESET_OR_THROW(isolate, db, stmt, ret) \ + CHECK_ERROR_OR_THROW((isolate), (db), sqlite3_reset((stmt)), SQLITE_OK, (ret)) + +// Surface deferred SQLite errors that sqlite3_reset() returns from the prior +// sqlite3_step(). Disables the safety-net reset guard via |needs_reset|. +#define RESET_AND_CHECK(isolate, db, stmt, needs_reset, ret) \ + do { \ + (needs_reset) = false; \ + RESET_OR_THROW((isolate), (db), (stmt), (ret)); \ + } while (0) + #define THROW_AND_RETURN_ON_BAD_STATE(env, condition, msg) \ do { \ if ((condition)) { \ @@ -3020,9 +3031,20 @@ MaybeLocal StatementExecutionHelper::Run(Environment* env, bool use_big_ints) { Isolate* isolate = env->isolate(); EscapableHandleScope scope(isolate); - sqlite3_step(stmt); - int r = sqlite3_reset(stmt); - CHECK_ERROR_OR_THROW(isolate, db, r, SQLITE_OK, MaybeLocal()); + bool needs_reset = true; + auto reset = OnScopeLeave([&]() { + if (needs_reset) sqlite3_reset(stmt); + }); + + int step_r = sqlite3_step(stmt); + // SQLITE_ROW is accepted here (and discarded) so that run() can still be + // used on RETURNING/SELECT statements, matching prior behavior of + // ignoring the step result entirely. + if (step_r != SQLITE_DONE && step_r != SQLITE_ROW) { + THROW_ERR_SQLITE_ERROR(isolate, db); + return MaybeLocal(); + } + RESET_AND_CHECK(isolate, db, stmt, needs_reset, MaybeLocal()); sqlite3_int64 last_insert_rowid = sqlite3_last_insert_rowid(db->Connection()); sqlite3_int64 changes = sqlite3_changes64(db->Connection()); @@ -3096,10 +3118,16 @@ MaybeLocal StatementExecutionHelper::Get(Environment* env, bool use_big_ints) { Isolate* isolate = env->isolate(); EscapableHandleScope scope(isolate); - auto reset = OnScopeLeave([&]() { sqlite3_reset(stmt); }); + bool needs_reset = true; + auto reset = OnScopeLeave([&]() { + if (needs_reset) sqlite3_reset(stmt); + }); int r = sqlite3_step(stmt); - if (r == SQLITE_DONE) return scope.Escape(Undefined(isolate)); + if (r == SQLITE_DONE) { + RESET_AND_CHECK(isolate, db, stmt, needs_reset, MaybeLocal()); + return scope.Escape(Undefined(isolate)); + } if (r != SQLITE_ROW) { THROW_ERR_SQLITE_ERROR(isolate, db); return MaybeLocal(); @@ -3107,7 +3135,8 @@ MaybeLocal StatementExecutionHelper::Get(Environment* env, int num_cols = sqlite3_column_count(stmt); if (num_cols == 0) { - return Undefined(isolate); + RESET_AND_CHECK(isolate, db, stmt, needs_reset, MaybeLocal()); + return scope.Escape(Undefined(isolate)); } LocalVector row_values(isolate); @@ -3116,9 +3145,9 @@ MaybeLocal StatementExecutionHelper::Get(Environment* env, return MaybeLocal(); } + Local result; if (return_arrays) { - return scope.Escape( - Array::New(isolate, row_values.data(), row_values.size())); + result = Array::New(isolate, row_values.data(), row_values.size()); } else { LocalVector keys(isolate); keys.reserve(num_cols); @@ -3131,9 +3160,12 @@ MaybeLocal StatementExecutionHelper::Get(Environment* env, } DCHECK_EQ(keys.size(), row_values.size()); - return scope.Escape(Object::New( - isolate, Null(isolate), keys.data(), row_values.data(), num_cols)); + result = Object::New( + isolate, Null(isolate), keys.data(), row_values.data(), num_cols); } + + RESET_AND_CHECK(isolate, db, stmt, needs_reset, MaybeLocal()); + return scope.Escape(result); } void StatementSync::All(const FunctionCallbackInfo& args) { @@ -3150,8 +3182,10 @@ void StatementSync::All(const FunctionCallbackInfo& args) { return; } - auto reset = OnScopeLeave([&]() { sqlite3_reset(stmt->statement_); }); - + bool needs_reset = true; + auto reset = OnScopeLeave([&]() { + if (needs_reset) sqlite3_reset(stmt->statement_); + }); Local result; if (StatementExecutionHelper::All(env, stmt->db_.get(), @@ -3159,6 +3193,8 @@ void StatementSync::All(const FunctionCallbackInfo& args) { stmt->return_arrays_, stmt->use_big_ints_) .ToLocal(&result)) { + RESET_AND_CHECK( + isolate, stmt->db_.get(), stmt->statement_, needs_reset, void()); args.GetReturnValue().Set(result); } } @@ -3592,7 +3628,11 @@ void SQLTagStore::All(const FunctionCallbackInfo& args) { return; } - auto reset = OnScopeLeave([&]() { sqlite3_reset(stmt->statement_); }); + Isolate* isolate = env->isolate(); + bool needs_reset = true; + auto reset = OnScopeLeave([&]() { + if (needs_reset) sqlite3_reset(stmt->statement_); + }); Local result; if (StatementExecutionHelper::All(env, stmt->db_.get(), @@ -3600,6 +3640,8 @@ void SQLTagStore::All(const FunctionCallbackInfo& args) { stmt->return_arrays_, stmt->use_big_ints_) .ToLocal(&result)) { + RESET_AND_CHECK( + isolate, stmt->db_.get(), stmt->statement_, needs_reset, void()); args.GetReturnValue().Set(result); } } @@ -3833,8 +3875,11 @@ void StatementSyncIterator::Next(const FunctionCallbackInfo& args) { if (r != SQLITE_ROW) { CHECK_ERROR_OR_THROW( env->isolate(), iter->stmt_->db_.get(), r, SQLITE_DONE, void()); - sqlite3_reset(iter->stmt_->statement_); iter->done_ = true; + RESET_OR_THROW(env->isolate(), + iter->stmt_->db_.get(), + iter->stmt_->statement_, + void()); MaybeLocal values[] = {Boolean::New(isolate, true), Null(isolate)}; Local result; if (NewDictionaryInstanceNullProto(env->context(), iter_template, values) @@ -3886,6 +3931,10 @@ void StatementSyncIterator::Return(const FunctionCallbackInfo& args) { env, iter->stmt_->IsFinalized(), "statement has been finalized"); Isolate* isolate = env->isolate(); + // Unlike Next(), the reset result is intentionally ignored here: Return() + // is invoked by the language during abrupt completion (e.g. a `throw` + // inside a `for...of` body), and throwing on a deferred SQLite error + // would discard the caller's already-pending exception. sqlite3_reset(iter->stmt_->statement_); iter->done_ = true; diff --git a/test/parallel/test-sqlite-statement-sync.js b/test/parallel/test-sqlite-statement-sync.js index cf0e4daa45ca..a55e19fd14f3 100644 --- a/test/parallel/test-sqlite-statement-sync.js +++ b/test/parallel/test-sqlite-statement-sync.js @@ -79,6 +79,28 @@ suite('StatementSync.prototype.get()', () => { message: /statement has been finalized/, }); }); + + test('surfaces a deferred SQLite error from reset() even though a row was already built', (t) => { + using db = new DatabaseSync(':memory:'); + db.exec(` + PRAGMA foreign_keys = ON; + PRAGMA defer_foreign_keys = ON; + CREATE TABLE parent(id INTEGER PRIMARY KEY); + CREATE TABLE child(id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent(id)); + `); + // The FK check is deferred until the implicit transaction commits, which + // happens inside reset() here because RETURNING leaves the statement's + // VDBE running after the row is produced. + const stmt = db.prepare( + 'INSERT INTO child (parent_id) VALUES (999) RETURNING id' + ); + t.assert.throws(() => { + stmt.get(); + }, { + code: 'ERR_SQLITE_ERROR', + message: /FOREIGN KEY constraint failed/, + }); + }); }); suite('StatementSync.prototype.all()', () => { @@ -144,6 +166,25 @@ suite('StatementSync.prototype.all()', () => { message: /statement has been finalized/, }); }); + + test('surfaces a deferred SQLite error from reset() even though the array was already built', (t) => { + using db = new DatabaseSync(':memory:'); + db.exec(` + PRAGMA foreign_keys = ON; + PRAGMA defer_foreign_keys = ON; + CREATE TABLE parent(id INTEGER PRIMARY KEY); + CREATE TABLE child(id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent(id)); + `); + const stmt = db.prepare( + 'INSERT INTO child (parent_id) VALUES (999) RETURNING id' + ); + t.assert.throws(() => { + stmt.all(); + }, { + code: 'ERR_SQLITE_ERROR', + message: /FOREIGN KEY constraint failed/, + }); + }); }); suite('StatementSync.prototype.iterate()', () => { @@ -322,6 +363,41 @@ suite('StatementSync.prototype.iterate()', () => { message: /statement has been finalized/, }); }); + + test('does not replay results after the iterator is naturally exhausted', (t) => { + using db = new DatabaseSync(':memory:'); + db.exec(` + CREATE TABLE test(key TEXT); + INSERT INTO test (key) VALUES ('key1'); + `); + const it = db.prepare('SELECT * FROM test').iterate(); + t.assert.deepStrictEqual(it.next(), { + __proto__: null, done: false, value: { __proto__: null, key: 'key1' }, + }); + t.assert.deepStrictEqual( + it.next(), { __proto__: null, done: true, value: null }); + // Calling next() again on an exhausted iterator must keep reporting + // done, not silently reset the statement and replay from row 1. + t.assert.deepStrictEqual( + it.next(), { __proto__: null, done: true, value: null }); + }); + + test('propagates a pending exception when the loop body throws mid-iteration', (t) => { + using db = new DatabaseSync(':memory:'); + db.exec(` + CREATE TABLE test(key TEXT); + INSERT INTO test (key) VALUES ('key1'); + INSERT INTO test (key) VALUES ('key2'); + `); + const stmt = db.prepare('SELECT * FROM test'); + const userError = new Error('boom'); + t.assert.throws(() => { + // eslint-disable-next-line no-unused-vars + for (const row of stmt.iterate()) { + throw userError; + } + }, (err) => err === userError); + }); }); suite('StatementSync.prototype.run()', () => { From 53d52731c9624de4ed3f919d67a9a417aaaabc9f Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Tue, 11 Aug 2026 19:06:25 +0200 Subject: [PATCH 114/344] tools: fix quote escaping in `update-nixpkgs-pin.sh` Signed-off-by: Antoine du Hamel PR-URL: https://github.com/nodejs/node/pull/65166 Reviewed-By: Filip Skokan Reviewed-By: Chemi Atlow Reviewed-By: Colin Ihrig --- tools/dep_updaters/update-nixpkgs-pin.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/dep_updaters/update-nixpkgs-pin.sh b/tools/dep_updaters/update-nixpkgs-pin.sh index e31e70b8cd7c..cd95bb811a9d 100755 --- a/tools/dep_updaters/update-nixpkgs-pin.sh +++ b/tools/dep_updaters/update-nixpkgs-pin.sh @@ -70,7 +70,7 @@ nix-instantiate -I "nixpkgs=$NIXPKGS_PIN_FILE" --eval --strict --json -E " }: { - # "default" OpenSSL release line, should be kept in sync with the bundled version: + # \"default\" OpenSSL release line, should be kept in sync with the bundled version: openssl = pkgs.\(.default); # Other OpenSSL variants we want to test for: From 43f1dc03e7b8b1b4e091255c2a4f64e9f8c237d3 Mon Sep 17 00:00:00 2001 From: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:40:25 -0700 Subject: [PATCH 115/344] doc: document close() error when in a sqlite callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> PR-URL: https://github.com/nodejs/node/pull/65090 Refs: https://github.com/nodejs/node/pull/64743 Reviewed-By: René Reviewed-By: Edy Silva --- doc/api/sqlite.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/doc/api/sqlite.md b/doc/api/sqlite.md index 601961243ce2..f43e27a1d8f1 100644 --- a/doc/api/sqlite.md +++ b/doc/api/sqlite.md @@ -307,7 +307,10 @@ added: v22.5.0 --> Closes the database connection. An exception is thrown if the database is not -open. This method is a wrapper around [`sqlite3_close_v2()`][]. +open. An [`ERR_INVALID_STATE`][] error is thrown if the method is called while +a statement is executing, such as inside a user-defined function, an aggregate +function, or an authorizer callback. This method is a wrapper around +[`sqlite3_close_v2()`][]. ### `database.loadExtension(path[, entryPoint])` From d6059e84c09b9de9b589ce0e124b672695dfd855 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:34:44 +0200 Subject: [PATCH 116/344] doc: update synopsis Signed-off-by: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> PR-URL: https://github.com/nodejs/node/pull/65171 Reviewed-By: Mike McCready <66998419+MikeMcC399@users.noreply.github.com> Reviewed-By: Antoine du Hamel Reviewed-By: Richard Lau Reviewed-By: Colin Ihrig Reviewed-By: Luigi Pinca --- doc/api/synopsis.md | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/doc/api/synopsis.md b/doc/api/synopsis.md index 24bb35e08f8c..85b2b4cf7470 100644 --- a/doc/api/synopsis.md +++ b/doc/api/synopsis.md @@ -15,15 +15,8 @@ Please see the [Command-line options][] document for more information. An example of a [web server][] written with Node.js which responds with `'Hello, World!'`: -Commands in this document start with `$` or `>` to replicate how they would -appear in a user's terminal. Do not include the `$` and `>` characters. They are -there to show the start of each command. - -Lines that don't start with `$` or `>` character show the output of the previous -command. - First, make sure to have downloaded and installed Node.js. See -[Installing Node.js via package manager][] for further install information. +[Installing Node.js][] for further install information. Now, create an empty project folder called `projects`, then navigate into it. @@ -90,5 +83,5 @@ If the browser displays the string `Hello, World!`, that indicates the server is working. [Command-line options]: cli.md#options -[Installing Node.js via package manager]: https://nodejs.org/en/download/package-manager/ +[Installing Node.js]: https://nodejs.org/en/download [web server]: http.md From 4e06739c7bca94ed35f696462ef0f95327b2c7bc Mon Sep 17 00:00:00 2001 From: Chemi Atlow Date: Tue, 11 Aug 2026 22:34:54 +0300 Subject: [PATCH 117/344] test_runner: do not tag-filter test file wrappers Under run({ testTagFilters, isolation: 'process' }) the parent process's FileTest wrappers have empty tag sets, so any include filter filtered out the wrappers themselves and no test file was ever spawned. The same applied to the single re-spawned child in watch mode with isolation 'none'. Exempt file wrappers from tag filtering: the filter is re-emitted to the child process and applied there, matching isolation 'none' results. This also removes the testTagFilterExpressions bookkeeping and the isolation-conditional assignment of testTagFilters, both of which existed only to keep the parent process from filtering its own file wrappers. The parent now always holds the canonical filter values and re-emits them to child processes. Refs: https://github.com/nodejs/node/pull/63221 Signed-off-by: atlowChemi PR-URL: https://github.com/nodejs/node/pull/65170 Reviewed-By: Benjamin Gruenbaum Reviewed-By: Moshe Atlow --- lib/internal/test_runner/runner.js | 18 +++++++++++------- lib/internal/test_runner/test.js | 6 +++++- lib/internal/test_runner/utils.js | 18 +++++------------- test/parallel/test-runner-tags-events.mjs | 14 +++++++++++--- 4 files changed, 32 insertions(+), 24 deletions(-) diff --git a/lib/internal/test_runner/runner.js b/lib/internal/test_runner/runner.js index a5a53e44d29a..548ed006e152 100644 --- a/lib/internal/test_runner/runner.js +++ b/lib/internal/test_runner/runner.js @@ -182,7 +182,7 @@ function getRunArgs(path, { forceExit, inspectPort, testNamePatterns, testSkipPatterns, - testTagFilterExpressions, + testTagFilters, only, hasFiles, testFiles, @@ -224,8 +224,8 @@ function getRunArgs(path, { forceExit, if (testSkipPatterns != null) { ArrayPrototypeForEach(testSkipPatterns, (pattern) => ArrayPrototypePush(runArgs, `--test-skip-pattern=${pattern}`)); } - if (testTagFilterExpressions != null) { - ArrayPrototypeForEach(testTagFilterExpressions, (value) => ArrayPrototypePush(runArgs, `--experimental-test-tag-filter=${value}`)); + if (testTagFilters != null) { + ArrayPrototypeForEach(testTagFilters, (value) => ArrayPrototypePush(runArgs, `--experimental-test-tag-filter=${value}`)); } if (only === true) { ArrayPrototypePush(runArgs, '--test-only'); @@ -284,6 +284,14 @@ class FileTest extends Test { this.timeout = null; } + willBeFilteredByTags() { + // File wrappers have no tags of their own. Tag filtering applies to the + // tests inside the file, which run in a child process (or in-process + // import); filtering the wrapper would prevent the file from running at + // all. + return false; + } + #skipReporting() { return this.#reportedChildren > 0 && (!this.error || this.error.failureType === kSubtestsFailed); } @@ -864,7 +872,6 @@ function run(options = kEmptyObject) { }); } - let testTagFilterExpressions = null; if (testTagFilters != null) { if (!ArrayIsArray(testTagFilters)) { testTagFilters = [testTagFilters]; @@ -876,10 +883,8 @@ function run(options = kEmptyObject) { testTagFilters = ArrayPrototypeMap(testTagFilters, (value, i) => ( validateAndCanonicalizeTagFilter(value, `options.testTagFilters[${i}]`) )); - testTagFilterExpressions = testTagFilters; } } - testTagFilterExpressions ??= options.testTagFilterExpressions; validateOneOf(isolation, 'options.isolation', ['process', 'none']); validateBoolean(coverage, 'options.coverage'); @@ -982,7 +987,6 @@ function run(options = kEmptyObject) { testNamePatterns, testSkipPatterns, testTagFilters, - testTagFilterExpressions, hasFiles: files != null, globPatterns, only, diff --git a/lib/internal/test_runner/test.js b/lib/internal/test_runner/test.js index 38ce54e4ea9b..a728378182df 100644 --- a/lib/internal/test_runner/test.js +++ b/lib/internal/test_runner/test.js @@ -656,7 +656,7 @@ class Test extends AsyncResource { } if (isFilteringByTags) { - this.filteredByTag = !evaluateTagFilters(config.testTagFilters, this.tagSet); + this.filteredByTag = this.willBeFilteredByTags(); if (!this.filteredByTag) { for (let t = this.parent; t !== null && t.filteredByTag; t = t.parent) { t.filteredByTag = false; @@ -894,6 +894,10 @@ class Test extends AsyncResource { return false; } + willBeFilteredByTags() { + return !evaluateTagFilters(this.config.testTagFilters, this.tagSet); + } + /** * Returns a name of the test prefixed by name of all its ancestors in ascending order, separated by a space * Ex."grandparent parent test" diff --git a/lib/internal/test_runner/utils.js b/lib/internal/test_runner/utils.js index 3590d3ef79b4..982fc6ef7bfe 100644 --- a/lib/internal/test_runner/utils.js +++ b/lib/internal/test_runner/utils.js @@ -273,7 +273,6 @@ function parseCommandLine() { let testNamePatterns = mapPatternFlagToRegExArray('--test-name-pattern'); let testSkipPatterns = mapPatternFlagToRegExArray('--test-skip-pattern'); let testTagFilters = null; - let testTagFilterExpressions = null; if (isChildProcessV8) { kBuiltinReporters.set('v8-serializer', 'internal/test_runner/reporter/v8-serializer'); @@ -309,19 +308,14 @@ function parseCommandLine() { const tagFilterFlag = getOptionValue('--experimental-test-tag-filter'); if (tagFilterFlag?.length > 0) { emitExperimentalWarning('Test tags'); - testTagFilterExpressions = tagFilterFlag; - // Validate at parent startup so a malformed flag fails fast, - // independent of isolation mode. Under isolation='process' the - // validated strings go unused at the parent (children re-validate - // and apply the filter); the validation here only surfaces input - // errors early. - const validated = ArrayPrototypeMap( + // File wrappers are exempt from tag filtering, so holding the filters + // in the parent is safe under any isolation mode; under + // isolation='process' the canonical values are re-emitted to the + // child processes, which apply the filter themselves. + testTagFilters = ArrayPrototypeMap( tagFilterFlag, (value, i) => validateAndCanonicalizeTagFilter(value, `--experimental-test-tag-filter[${i}]`), ); - if (isolation === 'none') { - testTagFilters = validated; - } } if (isolation === 'none') { @@ -365,7 +359,6 @@ function parseCommandLine() { const tagFilterFlag = getOptionValue('--experimental-test-tag-filter'); if (tagFilterFlag?.length > 0) { emitExperimentalWarning('Test tags'); - testTagFilterExpressions = tagFilterFlag; testTagFilters = ArrayPrototypeMap( tagFilterFlag, (value, i) => validateAndCanonicalizeTagFilter(value, `--experimental-test-tag-filter[${i}]`), @@ -433,7 +426,6 @@ function parseCommandLine() { sourceMaps, testNamePatterns, testSkipPatterns, - testTagFilterExpressions, testTagFilters, timeout, updateSnapshots, diff --git a/test/parallel/test-runner-tags-events.mjs b/test/parallel/test-runner-tags-events.mjs index 2f275cf876f9..7d1b35f7349d 100644 --- a/test/parallel/test-runner-tags-events.mjs +++ b/test/parallel/test-runner-tags-events.mjs @@ -83,9 +83,6 @@ describe('tag-bearing event payloads', { concurrency: false }, () => { }); it('test:pass fires only for selected tagged tests when filtered', async () => { - // isolation='none' so the parent applies the filter directly. Under - // 'process', the FileTest wrapper (which has no tags) would itself be - // filtered out by the include filter - same wart as --test-name-pattern. const stream = run({ files: [fixture], testTagFilters: ['db'], isolation: 'none' }); stream.on('test:fail', common.mustNotCall()); // 3 db-tagged tests pass + the db suite itself. @@ -93,4 +90,15 @@ describe('tag-bearing event payloads', { concurrency: false }, () => { // eslint-disable-next-line no-unused-vars for await (const _ of stream); }); + + it('filtering under process isolation runs the file and filters inside it', async () => { + // The FileTest wrapper has no tags and must not be filtered out itself; + // the filter is re-emitted to the child process and applied there. + const stream = run({ files: [fixture], testTagFilters: ['db'], isolation: 'process' }); + stream.on('test:fail', common.mustNotCall()); + // 3 db-tagged tests pass + the db suite itself. + stream.on('test:pass', common.mustCall(4)); + // eslint-disable-next-line no-unused-vars + for await (const _ of stream); + }); }); From 4abd1c8100a8deb41bb7ce9243924f3731b02d72 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Tue, 11 Aug 2026 23:26:33 +0200 Subject: [PATCH 118/344] meta: add support for alpha prerelease tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Antoine du Hamel PR-URL: https://github.com/nodejs/node/pull/63135 Refs: https://github.com/nodejs/Release/issues/1154 Reviewed-By: Marco Ippolito Reviewed-By: Michaël Zasso Reviewed-By: Richard Lau Reviewed-By: James M Snell Reviewed-By: Rafael Gonzaga --- .github/workflows/major-release.yml | 2 +- doc/contributing/releases.md | 139 +++++++++++++++------------- src/node_version.h | 22 +++-- 3 files changed, 94 insertions(+), 69 deletions(-) diff --git a/.github/workflows/major-release.yml b/.github/workflows/major-release.yml index b65917f89e74..1b15a00c8df1 100644 --- a/.github/workflows/major-release.yml +++ b/.github/workflows/major-release.yml @@ -2,7 +2,7 @@ name: Major Release on: schedule: - - cron: 0 0 15 2,8 * # runs at midnight UTC every 15 February and 15 August + - cron: 0 0 15 2 * # runs at midnight UTC every 15 February permissions: contents: read diff --git a/doc/contributing/releases.md b/doc/contributing/releases.md index c6b51bc2acff..da61b360ac9f 100644 --- a/doc/contributing/releases.md +++ b/doc/contributing/releases.md @@ -422,6 +422,11 @@ already defined in `src/node_version.h`: #define NODE_MAJOR_VERSION x #define NODE_MINOR_VERSION y #define NODE_PATCH_VERSION z + +// And for alpha releases: +#define NODE_ALPHA_MAJOR_VERSION a +#define NODE_ALPHA_MINOR_VERSION b +#define NODE_ALPHA_PATCH_VERSION c ``` Set the `NODE_VERSION_IS_RELEASE` macro value to `1`. This causes the build to @@ -431,6 +436,14 @@ be produced with a version string that does not have a trailing pre-release tag: #define NODE_VERSION_IS_RELEASE 1 ``` +
+Major version release + +Remove the `NODE_ALPHA_MAJOR_VERSION`, `NODE_ALPHA_MINOR_VERSION`, and +`NODE_ALPHA_PATCH_VERSION` macros. + +
+ ### 4. Update the changelog _(This step will be done automatically if you are using `create-release-proposal` or `git node release --prepare`)_ @@ -900,8 +913,8 @@ project README. On release proposal branch, edit `src/node_version.h` again and: -* Increment `NODE_PATCH_VERSION` by one -* Change `NODE_VERSION_IS_RELEASE` back to `0` +* Increment `NODE_PATCH_VERSION` (or `NODE_ALPHA_PATCH_VERSION` for alpha releases) by one. +* Change `NODE_VERSION_IS_RELEASE` back to `0`. Commit this change with the following commit message format: @@ -968,9 +981,12 @@ git restore --source=upstream/main src/node_version.h On the main branch, instead of reverting changes made to `src/node_version.h` edit it instead and: -* Increment `NODE_MAJOR_VERSION` by one -* Reset `NODE_PATCH_VERSION` to `0` -* Change `NODE_VERSION_IS_RELEASE` back to `0` +* Increment `NODE_MAJOR_VERSION` by one. +* Reset `NODE_PATCH_VERSION` and `NODE_MINOR_VERSION` to `0`. +* Set `NODE_ALPHA_MAJOR_VERSION`, `NODE_ALPHA_MINOR_VERSION`, and + `NODE_ALPHA_PATCH_VERSION` back to `0` (`main` should already have this, the + release commit will have them removed). +* Change `NODE_VERSION_IS_RELEASE` back to `0`. Amend the current commit to apply the changes: @@ -1281,9 +1297,9 @@ git node release --prepare --startLTS To mark a release line as LTS, the following changes must be made to `src/node_version.h`: -* The `NODE_MINOR_VERSION` macro must be incremented by one -* The `NODE_PATCH_VERSION` macro must be set to `0` -* The `NODE_VERSION_IS_LTS` macro must be set to `1` +* The `NODE_MINOR_VERSION` macro must be incremented by one. +* The `NODE_PATCH_VERSION` macro must be set to `0`. +* The `NODE_VERSION_IS_LTS` macro must be set to `1`. * The `NODE_VERSION_LTS_CODENAME` macro must be set to the code name selected for the LTS release. @@ -1352,15 +1368,15 @@ from cutting a minor or patch release. ### Schedule -New Node.js Major releases happen twice per year: +New Node.js Major releases happen once per year: -* Even-numbered releases are cut in April. -* Odd-numbered releases are cut in October. +* Branch-off is in October. +* Semver-major release is in April. Major releases should be targeted for the third Tuesday of the release month. A major release must not slip beyond the release month. In other words, major -releases must not slip into May or November. +releases must not slip into May. The @nodejs/releasers make a call for releasers 3 months in advance. Currently, this call is automated in the `#nodejs-release-private` @@ -1370,15 +1386,15 @@ The release date for the next major release should be announced immediately following the current release (e.g. the release date for 13.0.0 should be announced immediately following the release of 12.0.0). -### Release branch +### Branch-off (October) -Approximately two months before a major release, new `vN.x` and -`vN.x-staging` branches (where `N` indicates the major release) should be -created as forks of the `main` branch. Up until the cut-off date announced by -the releaser, these must be kept in sync with `main`. +#### Release branch -The `vN.x` and `vN.x-staging` branches must be kept in sync with one another -up until the date of the release. +Approximately six months before a major release, new `vN.x` and +`vN.x-staging` branches (where `N` indicates the major release) should be +created as forks of the `main` branch. Alpha releases should be released picking +up commits from `main`. Target the first alpha release to be released the same +day as the previous release line is graduated to LTS status. If a `SEMVER-MAJOR` pull request lands on the default branch within one month prior to the major release date, it must not be included on the new major @@ -1386,10 +1402,9 @@ staging branch, unless there is consensus from the Node.js releasers team to do so. This measure aims to ensure better stability for the release candidate (RC) phase, which begins approximately two weeks prior to the official release. By restricting `SEMVER-MAJOR` commits in this period, we provide more time for -thorough testing and reduce the potential for major breakages, especially in -LTS lines. +thorough testing and reduce the potential for major breakages. -### Create release labels +#### Create release labels The following issue labels must be created: @@ -1404,9 +1419,9 @@ The label description can be copied from existing labels of previous releases. The label color must be the same for all new labels, but different from the labels of previous releases. -### Release proposal +#### Initial Alpha release proposal -A draft release proposal should be created 6 weeks before the release. A +A draft release proposal should be created before the release. A separate `vN.x-proposal` branch should be created that tracks the `vN.x` branch. This branch will contain the draft release commit (with the draft changelog). @@ -1414,21 +1429,7 @@ changelog). Notify the `@nodejs/npm` team in the release proposal PR to inform them of the upcoming release. -To keep the branch in sync until the release date, it can be as simple as -doing the following: - -> Make sure to check that there are no PRs with the label `dont-land-on-vX.x`. - -```bash -git checkout vN.x -git reset --hard upstream/main -git checkout vN.x-staging -git reset --hard upstream/main -git push upstream vN.x -git push upstream vN.x-staging -``` - -### Update `NODE_MODULE_VERSION` +##### Update `NODE_MODULE_VERSION` This macro in `src/node_version.h` is used to signal an ABI version for native addons. It currently has two common uses in the community: @@ -1458,24 +1459,12 @@ see a need to bump `NODE_MODULE_VERSION` outside of a major release then you should consult the TSC. Commits may need to be reverted or a major version bump may need to happen. -### Test releases and release candidates - -Test builds should be generated from the `vN.x-proposal` branch starting at -about 6 weeks before the release. - -Release Candidates should be generated from the `vN.x-proposal` branch starting -at about 4 weeks before the release, with a target of one release candidate -per week. - -Always run test releases and release candidates through the Canary in the -Goldmine tool for additional testing. - -### Changelogs +##### Changelogs Generating major release changelogs is a bit more involved than minor and patch changelogs. -#### Create the changelog file +###### Create the changelog file In the `doc/changelogs` directory, create a new `CHANGELOG_V{N}.md` file where `{N}` is the major version of the release. Follow the structure of the existing @@ -1487,7 +1476,7 @@ updated to account for the new `CHANGELOG_V{N}.md` file. Once the file is created, the root `CHANGELOG.md` file must be updated to reference the newly-created major release `CHANGELOG_V{N}.md`. -#### Generate the changelog +###### Generate the changelog To generate a proper major release changelog, use the `branch-diff` tool to compare the `vN.x` branch against the `vN-1.x` branch (e.g. for Node.js 12.0, @@ -1506,14 +1495,7 @@ $ branch-diff upstream/vN-1.x upstream/vN.x --require-label=semver-minor --group $ branch-diff upstream/vN-1.x upstream/vN.x --exclude-label=semver-major,semver-minor --group --filter-release --markdown # get all patches ``` -#### Generate the notable changes - -For a major release, all SEMVER-MAJOR commits that are not strictly internal, -test, or doc-related are to be listed as notable changes. Some SEMVER-MINOR -commits may be listed as notable changes on a case-by-case basis. Use your -judgment there. - -### Update the expected assets +##### Update the expected assets The promotion script does a basic check that the expected files are present. Open a pull request in the Build repository to add the list of expected files @@ -1522,6 +1504,39 @@ version of the release), in the [expected assets][] folder. The change will need to be deployed onto the web server by a member of the [build-infra team][] before the release is promoted. +### Semver-major release (April) + +#### Release proposal + +A draft release proposal should be created 6 weeks before the release. A +separate `vN.x-proposal` branch should be created that tracks the `vN.x` +branch. This branch will contain the draft release commit (with the draft +changelog). + +Notify the `@nodejs/npm` team in the release proposal PR to inform them of the +upcoming release. + +Major release proposal should contain a single commit, the release one. All +semver-major changes must have landed in a alpha version before the major is +released. Semver-major changes that have missed the alpha period will be included +in the next major release line. + +##### Marking a release line as "out of Alpha" + +To mark a release line as stable, the following changes must be made to +`src/node_version.h`: + +* Remove `NODE_ALPHA_MAJOR_VERSION`, `NODE_ALPHA_MINOR_VERSION`, and + `NODE_ALPHA_PATCH_VERSION`. + +#### Generate the notable changes + +For a major release, all SEMVER-MAJOR commits that are not strictly internal, +test, or doc-related are to be listed as notable changes. Some SEMVER-MINOR +commits may be listed as notable changes on a case-by-case basis. Use your +judgment there. +Include the notable changes from the Alpha versions where it applies. + ### Snap The Node.js [Snap][] package has a "default" for installs where the user hasn't diff --git a/src/node_version.h b/src/node_version.h index 2bd579a2092d..95d6aa79439c 100644 --- a/src/node_version.h +++ b/src/node_version.h @@ -31,6 +31,10 @@ #define NODE_VERSION_IS_RELEASE 0 +#define NODE_ALPHA_MAJOR_VERSION 0 +#define NODE_ALPHA_MINOR_VERSION 0 +#define NODE_ALPHA_PATCH_VERSION 0 + #ifndef NODE_STRINGIFY #define NODE_STRINGIFY(n) NODE_STRINGIFY_HELPER(n) #define NODE_STRINGIFY_HELPER(n) #n @@ -41,18 +45,24 @@ #endif #ifndef NODE_TAG -# if NODE_VERSION_IS_RELEASE -# define NODE_TAG "" -# else -# define NODE_TAG "-pre" -# endif +#if NODE_VERSION_IS_RELEASE +#ifdef NODE_ALPHA_MAJOR_VERSION +#define NODE_TAG \ + "-alpha." NODE_STRINGIFY(NODE_ALPHA_MAJOR_VERSION) "." NODE_STRINGIFY( \ + NODE_ALPHA_MINOR_VERSION) "." NODE_STRINGIFY(NODE_ALPHA_PATCH_VERSION) #else +#define NODE_TAG "" +#endif // NODE_ALPHA_MAJOR_VERSION +#else // NODE_VERSION_IS_RELEASE +#define NODE_TAG "-pre" +#endif // NODE_VERSION_IS_RELEASE +#else // NODE_TAG // NODE_TAG is passed without quotes when rc.exe is run from msbuild # define NODE_EXE_VERSION NODE_STRINGIFY(NODE_MAJOR_VERSION) "." \ NODE_STRINGIFY(NODE_MINOR_VERSION) "." \ NODE_STRINGIFY(NODE_PATCH_VERSION) \ NODE_STRINGIFY(NODE_TAG) -#endif +#endif // NODE_TAG # define NODE_VERSION_STRING NODE_STRINGIFY(NODE_MAJOR_VERSION) "." \ NODE_STRINGIFY(NODE_MINOR_VERSION) "." \ From b7a23426d9d1fabba5a4c38a88ac5430cbd65d73 Mon Sep 17 00:00:00 2001 From: Max H Fisher Date: Wed, 22 Jul 2026 16:07:43 -0400 Subject: [PATCH 119/344] src: add SetAbortHandler Signed-off-by: Max H Fisher PR-URL: https://github.com/nodejs/node/pull/64684 Reviewed-By: Chengzhong Wu --- src/node.h | 10 +++++ src/node_errors.cc | 30 +++++++++++++-- src/util.h | 14 ++++--- test/addons/abort-handler/binding.cc | 18 +++++++++ test/addons/abort-handler/binding.gyp | 9 +++++ test/addons/abort-handler/test.js | 45 ++++++++++++++++++++++ test/cctest/test_environment.cc | 54 +++++++++++++++++++++++++++ 7 files changed, 171 insertions(+), 9 deletions(-) create mode 100644 test/addons/abort-handler/binding.cc create mode 100644 test/addons/abort-handler/binding.gyp create mode 100644 test/addons/abort-handler/test.js diff --git a/src/node.h b/src/node.h index 18969ad90a04..1c0726a82415 100644 --- a/src/node.h +++ b/src/node.h @@ -853,6 +853,16 @@ NODE_EXTERN void SetProcessExitHandler( std::function&& handler); NODE_EXTERN void DefaultProcessExitHandler(Environment* env, int exit_code); +// Sets a process-global handler invoked when Node.js programmatically aborts. +// Nullable strings representing the location and reason for the abort may or +// may not be passed as a parameter to the handler. The handler should not +// return, but node will ensure that the process exits after the handler is +// called regardless of whether or not it returns. Passing nullptr restores the +// default handler. This is process-global and may be invoked before any Isolate +// or Environment exists. +using AbortHandler = void (*)(const char* location, const char* message); +NODE_EXTERN void SetAbortHandler(AbortHandler handler); + // This may return nullptr if context is not associated with a Node instance. NODE_EXTERN Environment* GetCurrentEnvironment(v8::Local context); NODE_EXTERN IsolateData* GetEnvironmentIsolateData(Environment* env); diff --git a/src/node_errors.cc b/src/node_errors.cc index 63db97f6a56d..2c464bb895e5 100644 --- a/src/node_errors.cc +++ b/src/node_errors.cc @@ -393,6 +393,30 @@ void AppendExceptionLine(Environment* env, .FromMaybe(false)); } +namespace { +// Default handler: Dumps native + JS backtraces to stderr and exits. This +// indirectly calls backtrace so it can not be marked as [[noreturn]] (see the +// comment on node::Assert() below). `message` and `location` are ignored +// because the assertion/fatal-error message, if any, is already printed to +// stderr by the caller (Assert()/OnFatalError()) before this handler runs. +void DefaultAbortHandler(const char* /*location*/, const char* /*message*/) { + DumpNativeBacktrace(stderr); + DumpJavaScriptBacktrace(stderr); + fflush(stderr); + ABORT_NO_BACKTRACE(); +} +// Constant-initialized, so this is valid from load time, safe even for a +// CHECK() during early startup, before any SetAbortHandler call. +AbortHandler g_abort_handler = DefaultAbortHandler; +} // namespace + +void SetAbortHandler(AbortHandler handler) { + g_abort_handler = handler ? handler : DefaultAbortHandler; +} +AbortHandler GetAbortHandler() { + return g_abort_handler; +} + void Assert(const AssertionInfo& info) { std::string name = GetHumanReadableProcessName(); @@ -406,7 +430,7 @@ void Assert(const AssertionInfo& info) { info.message); fflush(stderr); - ABORT(); + ABORT_WITH_DETAILS(info.file_line, info.message); } enum class EnhanceFatalException { kEnhance, kDontEnhance }; @@ -584,7 +608,7 @@ static void ReportFatalException(Environment* env, } fflush(stderr); - ABORT(); + ABORT_WITH_DETAILS(location, message); } void OOMErrorHandler(const char* location, const v8::OOMDetails& details) { @@ -620,7 +644,7 @@ void OOMErrorHandler(const char* location, const v8::OOMDetails& details) { } fflush(stderr); - ABORT(); + ABORT_WITH_DETAILS(location, message); } v8::ModifyCodeGenerationFromStringsResult ModifyCodeGenerationFromStrings( diff --git a/src/util.h b/src/util.h index a979f626bdb4..5e9bf2b1cdab 100644 --- a/src/util.h +++ b/src/util.h @@ -128,6 +128,9 @@ void NODE_EXTERN_PRIVATE Assert(const AssertionInfo& info); void DumpNativeBacktrace(FILE* fp); void DumpJavaScriptBacktrace(FILE* fp); +// Returns the currently installed abort handler which is never null. +AbortHandler GetAbortHandler(); + // Windows 8+ does not like abort() in Release mode #ifdef _WIN32 #define ABORT_NO_BACKTRACE() _exit(static_cast(node::ExitCode::kAbort)) @@ -140,13 +143,12 @@ void DumpJavaScriptBacktrace(FILE* fp); // when generating code for them the compiler can choose not to // maintain the frame pointers or link registers that are necessary for // correct backtracing. -// `ABORT` must be a macro and not a [[noreturn]] function to make sure the -// backtrace is correct. -#define ABORT() \ +// `ABORT` and `ABORT_WITH_DETAILS` must be a macro and not a [[noreturn]] +// function to make sure the backtrace is correct. +#define ABORT() ABORT_WITH_DETAILS(__FILE__ ":" STRINGIFY(__LINE__), nullptr) +#define ABORT_WITH_DETAILS(location, message) \ do { \ - node::DumpNativeBacktrace(stderr); \ - node::DumpJavaScriptBacktrace(stderr); \ - fflush(stderr); \ + node::GetAbortHandler()(location, message); \ ABORT_NO_BACKTRACE(); \ } while (0) diff --git a/test/addons/abort-handler/binding.cc b/test/addons/abort-handler/binding.cc new file mode 100644 index 000000000000..13ceb011e57b --- /dev/null +++ b/test/addons/abort-handler/binding.cc @@ -0,0 +1,18 @@ +#include +#include +#include + +namespace { +void TestAbortHandler(const char* /*location*/, const char* /*message*/) { + fputs("CUSTOM_ABORT_HANDLER_RAN\n", stderr); + fflush(stderr); +} + +void InstallAbortHandler(const v8::FunctionCallbackInfo&) { + node::SetAbortHandler(TestAbortHandler); +} +} // namespace + +NODE_MODULE_INIT() { + NODE_SET_METHOD(exports, "installAbortHandler", InstallAbortHandler); +} diff --git a/test/addons/abort-handler/binding.gyp b/test/addons/abort-handler/binding.gyp new file mode 100644 index 000000000000..55fbe7050f18 --- /dev/null +++ b/test/addons/abort-handler/binding.gyp @@ -0,0 +1,9 @@ +{ + 'targets': [ + { + 'target_name': 'binding', + 'sources': [ 'binding.cc' ], + 'includes': ['../common.gypi'], + } + ] +} diff --git a/test/addons/abort-handler/test.js b/test/addons/abort-handler/test.js new file mode 100644 index 000000000000..4c05046c92b1 --- /dev/null +++ b/test/addons/abort-handler/test.js @@ -0,0 +1,45 @@ +'use strict'; +const common = require('../../common'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { exec } = require('child_process'); + +const bindingPath = path.resolve( + __dirname, 'build', common.buildType, 'binding.node'); + +if (!fs.existsSync(bindingPath)) + common.skip('binding not built yet'); + +if (process.argv[2] === 'child') { + const binding = require(bindingPath); + binding.installAbortHandler(); + process.abort(); + return; +} + +const escapedArgs = + common.escapePOSIXShell`"${process.execPath}" "${__filename}" child`; +if (!common.isWindows) { + // Do not create core files, as it can take a lot of disk space on + // continuous testing and developers' machines. + escapedArgs[0] = 'ulimit -c 0 && ' + escapedArgs[0]; +} + +exec(...escapedArgs, common.mustCall((err, stdout, stderr) => { + assert.ok( + stderr.includes('CUSTOM_ABORT_HANDLER_RAN'), + `Expected custom abort handler marker in stderr, got:\n${stderr}`); + assert.ok( + !stderr.includes('Native stack trace'), + `Expected the custom handler to replace the default dump, got:\n${stderr}`); + + // The child aborts. Whether that surfaces as the SIGABRT signal or as exit + // code 134 depends on shell wrapping: the `ulimit -c 0 && ...` prefix makes + // /bin/sh wait on (rather than exec-replace itself with) the node grandchild, + // so sh reports the aborted grandchild as a normal exit with code 134. + // common.nodeProcessAborted() accepts both forms. + assert.ok( + err && common.nodeProcessAborted(err.code, err.signal), + `Expected the child to abort, got code=${err?.code} signal=${err?.signal}`); +})); diff --git a/test/cctest/test_environment.cc b/test/cctest/test_environment.cc index 59c71835499e..fb1bcc2ef90c 100644 --- a/test/cctest/test_environment.cc +++ b/test/cctest/test_environment.cc @@ -1201,3 +1201,57 @@ TEST_F(EnvironmentTest, LoadEnvironmentWithCallbackWithESModule) { printf("Frame: %s\n", *frame_str); EXPECT_EQ(frame_str.ToString(), " at embedded:esm.mjs:3:15"); } + +namespace { +void CustomAbortHandlerForContractTest(const char* location, + const char* message) {} + +bool abort_handler_dispatch_flag = false; +const char* abort_handler_received_location = nullptr; +const char* abort_handler_received_message = nullptr; +void AbortHandlerThatSetsDispatchFlag(const char* location, + const char* message) { + abort_handler_dispatch_flag = true; + abort_handler_received_location = location; + abort_handler_received_message = message; +} +} // namespace + +TEST(AbortHandlerTest, DefaultIsNonNullAndSetAbortHandlerRoundTrips) { + node::AbortHandler old = node::GetAbortHandler(); + + // There should always be a non-null default handler installed. + EXPECT_NE(node::GetAbortHandler(), nullptr); + + node::SetAbortHandler(CustomAbortHandlerForContractTest); + EXPECT_EQ(node::GetAbortHandler(), CustomAbortHandlerForContractTest); + + node::SetAbortHandler(nullptr); + EXPECT_NE(node::GetAbortHandler(), nullptr); + EXPECT_NE(node::GetAbortHandler(), CustomAbortHandlerForContractTest); + + node::SetAbortHandler(old); +} + +TEST(AbortHandlerTest, InstalledHandlerIsInvokedWhenCalled) { + node::AbortHandler old = node::GetAbortHandler(); + abort_handler_dispatch_flag = false; + abort_handler_received_location = nullptr; + abort_handler_received_message = nullptr; + + node::SetAbortHandler(AbortHandlerThatSetsDispatchFlag); + node::AbortHandler h = node::GetAbortHandler(); + // Fail cleanly (instead of crashing on a null call) if the handler wasn't + // actually installed. + ASSERT_NE(h, nullptr); + + // Dispatch through the public GetAbortHandler() accessor directly (not via + // the ABORT() macro, so nothing terminates), and verify the message is + // passed through unchanged. + node::GetAbortHandler()("some-test-location", "some-test-message"); + EXPECT_TRUE(abort_handler_dispatch_flag); + EXPECT_STREQ(abort_handler_received_location, "some-test-location"); + EXPECT_STREQ(abort_handler_received_message, "some-test-message"); + + node::SetAbortHandler(old); +} From 3e50a2f1e9277191761ceddc3c6729d3afeb695a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Tue, 11 Aug 2026 20:47:37 -0300 Subject: [PATCH 120/344] sqlite: manage sqlite3_stmt lifetime with RAII MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Guilherme Araújo PR-URL: https://github.com/nodejs/node/pull/62419 Reviewed-By: Trivikram Kamat --- src/node_sqlite.cc | 119 ++++++++++++---------- src/node_sqlite.h | 12 ++- test/parallel/test-sqlite-template-tag.js | 17 ++++ 3 files changed, 89 insertions(+), 59 deletions(-) diff --git a/src/node_sqlite.cc b/src/node_sqlite.cc index 68554ed33b31..9c80d18cdaab 100644 --- a/src/node_sqlite.cc +++ b/src/node_sqlite.cc @@ -1590,6 +1590,7 @@ void DatabaseSync::Prepare(const FunctionCallbackInfo& args) { Utf8Value sql(env->isolate(), args[0].As()); sqlite3_stmt* s = nullptr; int r = sqlite3_prepare_v2(db->connection_, *sql, -1, &s, nullptr); + StatementPtr stmt_ptr(s); CHECK_ERROR_OR_THROW(env->isolate(), db, r, SQLITE_OK, void()); @@ -1602,8 +1603,11 @@ void DatabaseSync::Prepare(const FunctionCallbackInfo& args) { return; } - BaseObjectPtr stmt = - StatementSync::Create(env, BaseObjectPtr(db), s); + BaseObjectPtr stmt = StatementSync::Create( + env, BaseObjectPtr(db), std::move(stmt_ptr)); + if (!stmt) { + return; + } db->statements_.insert(stmt.get()); if (return_arrays.has_value()) { @@ -2660,10 +2664,9 @@ int DatabaseSync::AuthorizerCallback(void* user_data, StatementSync::StatementSync(Environment* env, Local object, BaseObjectPtr db, - sqlite3_stmt* stmt) - : BaseObject(env, object), db_(std::move(db)) { + StatementPtr stmt) + : BaseObject(env, object), db_(std::move(db)), statement_(std::move(stmt)) { MakeWeak(); - statement_ = stmt; use_big_ints_ = db_->use_big_ints(); return_arrays_ = db_->return_arrays(); allow_bare_named_params_ = db_->allow_bare_named_params(); @@ -2677,15 +2680,15 @@ StatementSync::~StatementSync() { } void StatementSync::Close() { + db_->UntrackStatement(this); + if (!IsFinalized()) { - db_->UntrackStatement(this); Finalize(); } } void StatementSync::Finalize() { - sqlite3_finalize(statement_); - statement_ = nullptr; + statement_.reset(); InvalidateColumnNameCache(); } @@ -2714,11 +2717,11 @@ void StatementSync::Dispose(const FunctionCallbackInfo& args) { inline int StatementSync::ResetStatement() { reset_generation_++; - return sqlite3_reset(statement_); + return sqlite3_reset(statement_.get()); } bool StatementSync::BindParams(const FunctionCallbackInfo& args) { - int r = sqlite3_clear_bindings(statement_); + int r = sqlite3_clear_bindings(statement_.get()); CHECK_ERROR_OR_THROW(env()->isolate(), db_.get(), r, SQLITE_OK, false); int anon_idx = 1; @@ -2735,10 +2738,10 @@ bool StatementSync::BindParams(const FunctionCallbackInfo& args) { if (allow_bare_named_params_ && !bare_named_params_.has_value()) { bare_named_params_.emplace(); - int param_count = sqlite3_bind_parameter_count(statement_); + int param_count = sqlite3_bind_parameter_count(statement_.get()); // Parameter indexing starts at one. for (int i = 1; i <= param_count; ++i) { - const char* name = sqlite3_bind_parameter_name(statement_, i); + const char* name = sqlite3_bind_parameter_name(statement_.get(), i); if (name == nullptr) { continue; } @@ -2770,12 +2773,12 @@ bool StatementSync::BindParams(const FunctionCallbackInfo& args) { } Utf8Value utf8_key(env()->isolate(), key); - int r = sqlite3_bind_parameter_index(statement_, *utf8_key); + int r = sqlite3_bind_parameter_index(statement_.get(), *utf8_key); if (r == 0) { if (allow_bare_named_params_) { auto lookup = bare_named_params_->find(std::string(*utf8_key)); if (lookup != bare_named_params_->end()) { - r = sqlite3_bind_parameter_index(statement_, + r = sqlite3_bind_parameter_index(statement_.get(), lookup->second.c_str()); } } @@ -2805,7 +2808,8 @@ bool StatementSync::BindParams(const FunctionCallbackInfo& args) { for (int i = anon_start; i < args.Length(); ++i) { while (1) { - const char* param = sqlite3_bind_parameter_name(statement_, anon_idx); + const char* param = + sqlite3_bind_parameter_name(statement_.get(), anon_idx); if (param == nullptr || param[0] == '?') break; anon_idx++; } @@ -2831,7 +2835,7 @@ bool StatementSync::BindValue(const Local& value, const int index) { int r; if (value->IsNumber()) { const double val = value.As()->Value(); - r = sqlite3_bind_double(statement_, index, val); + r = sqlite3_bind_double(statement_.get(), index, val); } else if (value->IsString()) { Utf8Value val(isolate, value.As()); if (val.IsAllocated()) { @@ -2841,9 +2845,9 @@ bool StatementSync::BindValue(const Local& value, const int index) { const sqlite3_uint64 length = static_cast(val.length()); val.Release(); r = sqlite3_bind_text64( - statement_, index, data, length, std::free, SQLITE_UTF8); + statement_.get(), index, data, length, std::free, SQLITE_UTF8); } else { - r = sqlite3_bind_text64(statement_, + r = sqlite3_bind_text64(statement_.get(), index, *val, static_cast(val.length()), @@ -2851,17 +2855,17 @@ bool StatementSync::BindValue(const Local& value, const int index) { SQLITE_UTF8); } } else if (value->IsNull()) { - r = sqlite3_bind_null(statement_, index); + r = sqlite3_bind_null(statement_.get(), index); } else if (value->IsArrayBufferView() || value->IsArrayBuffer() || value->IsSharedArrayBuffer()) { ArrayBufferViewContents buf(value); - r = sqlite3_bind_blob64(statement_, + r = sqlite3_bind_blob64(statement_.get(), index, buf.data(), static_cast(buf.length()), SQLITE_TRANSIENT); } else if (value->IsBoolean()) { - r = sqlite3_bind_int(statement_, index, value->IsTrue() ? 1 : 0); + r = sqlite3_bind_int(statement_.get(), index, value->IsTrue() ? 1 : 0); } else if (value->IsBigInt()) { bool lossless; int64_t as_int = value.As()->Int64Value(&lossless); @@ -2869,7 +2873,7 @@ bool StatementSync::BindValue(const Local& value, const int index) { THROW_ERR_INVALID_ARG_VALUE(env(), "BigInt value is too large to bind."); return false; } - r = sqlite3_bind_int64(statement_, index, as_int); + r = sqlite3_bind_int64(statement_.get(), index, as_int); } else { THROW_ERR_INVALID_ARG_TYPE( isolate, @@ -2884,11 +2888,11 @@ bool StatementSync::BindValue(const Local& value, const int index) { MaybeLocal StatementSync::ColumnToValue(const int column) { return StatementExecutionHelper::ColumnToValue( - env(), statement_, column, use_big_ints_); + env(), statement_.get(), column, use_big_ints_); } MaybeLocal StatementSync::ColumnNameToName(const int column) { - const char* col_name = sqlite3_column_name(statement_, column); + const char* col_name = sqlite3_column_name(statement_.get(), column); if (col_name == nullptr) { THROW_ERR_INVALID_STATE(env(), "Cannot get name of column %d", column); return MaybeLocal(); @@ -2905,10 +2909,10 @@ bool StatementSync::GetCachedColumnNames(LocalVector* keys) { Isolate* isolate = env()->isolate(); const int reprepare_count = - sqlite3_stmt_status(statement_, SQLITE_STMTSTATUS_REPREPARE, false); + sqlite3_stmt_status(statement_.get(), SQLITE_STMTSTATUS_REPREPARE, false); if (reprepare_count != cached_column_names_reprepare_count_) { cached_column_names_.clear(); - const int num_cols = sqlite3_column_count(statement_); + const int num_cols = sqlite3_column_count(statement_.get()); if (num_cols == 0) { cached_column_names_reprepare_count_ = reprepare_count; return true; @@ -3184,17 +3188,17 @@ void StatementSync::All(const FunctionCallbackInfo& args) { bool needs_reset = true; auto reset = OnScopeLeave([&]() { - if (needs_reset) sqlite3_reset(stmt->statement_); + if (needs_reset) sqlite3_reset(stmt->statement_.get()); }); Local result; if (StatementExecutionHelper::All(env, stmt->db_.get(), - stmt->statement_, + stmt->statement_.get(), stmt->return_arrays_, stmt->use_big_ints_) .ToLocal(&result)) { RESET_AND_CHECK( - isolate, stmt->db_.get(), stmt->statement_, needs_reset, void()); + isolate, stmt->db_.get(), stmt->statement_.get(), needs_reset, void()); args.GetReturnValue().Set(result); } } @@ -3238,7 +3242,7 @@ void StatementSync::Get(const FunctionCallbackInfo& args) { Local result; if (StatementExecutionHelper::Get(env, stmt->db_.get(), - stmt->statement_, + stmt->statement_.get(), stmt->return_arrays_, stmt->use_big_ints_) .ToLocal(&result)) { @@ -3261,7 +3265,7 @@ void StatementSync::Run(const FunctionCallbackInfo& args) { Local result; if (StatementExecutionHelper::Run( - env, stmt->db_.get(), stmt->statement_, stmt->use_big_ints_) + env, stmt->db_.get(), stmt->statement_.get(), stmt->use_big_ints_) .ToLocal(&result)) { args.GetReturnValue().Set(result); } @@ -3273,7 +3277,7 @@ void StatementSync::Columns(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); THROW_AND_RETURN_ON_BAD_STATE( env, stmt->IsFinalized(), "statement has been finalized"); - int num_cols = sqlite3_column_count(stmt->statement_); + int num_cols = sqlite3_column_count(stmt->statement_.get()); Isolate* isolate = env->isolate(); LocalVector cols(isolate); auto sqlite_column_template = env->sqlite_column_template(); @@ -3288,14 +3292,14 @@ void StatementSync::Columns(const FunctionCallbackInfo& args) { for (int i = 0; i < num_cols; ++i) { MaybeLocal values[] = { NullableSQLiteStringToValue( - isolate, sqlite3_column_origin_name(stmt->statement_, i)), + isolate, sqlite3_column_origin_name(stmt->statement_.get(), i)), NullableSQLiteStringToValue( - isolate, sqlite3_column_database_name(stmt->statement_, i)), + isolate, sqlite3_column_database_name(stmt->statement_.get(), i)), stmt->ColumnNameToName(i), NullableSQLiteStringToValue( - isolate, sqlite3_column_table_name(stmt->statement_, i)), + isolate, sqlite3_column_table_name(stmt->statement_.get(), i)), NullableSQLiteStringToValue( - isolate, sqlite3_column_decltype(stmt->statement_, i)), + isolate, sqlite3_column_decltype(stmt->statement_.get(), i)), }; Local col; @@ -3317,7 +3321,7 @@ void StatementSync::SourceSQLGetter(const FunctionCallbackInfo& args) { THROW_AND_RETURN_ON_BAD_STATE( env, stmt->IsFinalized(), "statement has been finalized"); Local sql; - if (!String::NewFromUtf8(env->isolate(), sqlite3_sql(stmt->statement_)) + if (!String::NewFromUtf8(env->isolate(), sqlite3_sql(stmt->statement_.get())) .ToLocal(&sql)) { return; } @@ -3332,7 +3336,7 @@ void StatementSync::ExpandedSQLGetter(const FunctionCallbackInfo& args) { env, stmt->IsFinalized(), "statement has been finalized"); // sqlite3_expanded_sql may return nullptr without producing an error code. - char* expanded = sqlite3_expanded_sql(stmt->statement_); + char* expanded = sqlite3_expanded_sql(stmt->statement_.get()); if (expanded == nullptr) { return THROW_ERR_SQLITE_ERROR( env->isolate(), "Expanded SQL text would exceed configured limits"); @@ -3506,11 +3510,11 @@ bool SQLTagStore::ResetAndBindStatement( int r = stmt->ResetStatement(); CHECK_ERROR_OR_THROW(isolate, stmt->db_.get(), r, SQLITE_OK, false); - r = sqlite3_clear_bindings(stmt->statement_); + r = sqlite3_clear_bindings(stmt->statement_.get()); CHECK_ERROR_OR_THROW(isolate, stmt->db_.get(), r, SQLITE_OK, false); uint32_t n_params = args.Length() - 1; - int param_count = sqlite3_bind_parameter_count(stmt->statement_); + int param_count = sqlite3_bind_parameter_count(stmt->statement_.get()); if (param_count != static_cast(n_params)) { THROW_ERR_INVALID_ARG_VALUE( env, @@ -3547,7 +3551,7 @@ void SQLTagStore::Run(const FunctionCallbackInfo& args) { Local result; if (StatementExecutionHelper::Run( - env, stmt->db_.get(), stmt->statement_, stmt->use_big_ints_) + env, stmt->db_.get(), stmt->statement_.get(), stmt->use_big_ints_) .ToLocal(&result)) { args.GetReturnValue().Set(result); } @@ -3602,7 +3606,7 @@ void SQLTagStore::Get(const FunctionCallbackInfo& args) { Local result; if (StatementExecutionHelper::Get(env, stmt->db_.get(), - stmt->statement_, + stmt->statement_.get(), stmt->return_arrays_, stmt->use_big_ints_) .ToLocal(&result)) { @@ -3631,17 +3635,17 @@ void SQLTagStore::All(const FunctionCallbackInfo& args) { Isolate* isolate = env->isolate(); bool needs_reset = true; auto reset = OnScopeLeave([&]() { - if (needs_reset) sqlite3_reset(stmt->statement_); + if (needs_reset) sqlite3_reset(stmt->statement_.get()); }); Local result; if (StatementExecutionHelper::All(env, stmt->db_.get(), - stmt->statement_, + stmt->statement_.get(), stmt->return_arrays_, stmt->use_big_ints_) .ToLocal(&result)) { RESET_AND_CHECK( - isolate, stmt->db_.get(), stmt->statement_, needs_reset, void()); + isolate, stmt->db_.get(), stmt->statement_.get(), needs_reset, void()); args.GetReturnValue().Set(result); } } @@ -3704,10 +3708,10 @@ BaseObjectPtr SQLTagStore::PrepareStatement( sqlite3_stmt* s = nullptr; int r = sqlite3_prepare_v2( session->database_->connection_, sql.data(), sql.size(), &s, nullptr); + StatementPtr stmt_ptr(s); if (r != SQLITE_OK) { THROW_ERR_SQLITE_ERROR(isolate, session->database_.get()); - sqlite3_finalize(s); return BaseObjectPtr(); } @@ -3718,15 +3722,17 @@ BaseObjectPtr SQLTagStore::PrepareStatement( return BaseObjectPtr(); } - BaseObjectPtr stmt_obj = StatementSync::Create( - env, BaseObjectPtr(session->database_), s); + BaseObjectPtr stmt_obj = + StatementSync::Create(env, + BaseObjectPtr(session->database_), + std::move(stmt_ptr)); if (!stmt_obj) { THROW_ERR_SQLITE_ERROR(isolate, "Failed to create StatementSync"); - sqlite3_finalize(s); return BaseObjectPtr(); } + session->database_->statements_.insert(stmt_obj.get()); session->sql_tags_.Put(sql, stmt_obj); stmt = stmt_obj; } @@ -3789,7 +3795,7 @@ Local StatementSync::GetConstructorTemplate( } BaseObjectPtr StatementSync::Create( - Environment* env, BaseObjectPtr db, sqlite3_stmt* stmt) { + Environment* env, BaseObjectPtr db, StatementPtr stmt) { Local obj; if (!GetConstructorTemplate(env) ->InstanceTemplate() @@ -3798,7 +3804,8 @@ BaseObjectPtr StatementSync::Create( return nullptr; } - return MakeBaseObject(env, obj, std::move(db), stmt); + return MakeBaseObject( + env, obj, std::move(db), std::move(stmt)); } StatementSyncIterator::StatementSyncIterator(Environment* env, @@ -3871,14 +3878,14 @@ void StatementSyncIterator::Next(const FunctionCallbackInfo& args) { iter->statement_reset_generation_ != iter->stmt_->reset_generation_, "iterator was invalidated"); - int r = sqlite3_step(iter->stmt_->statement_); + int r = sqlite3_step(iter->stmt_->statement_.get()); if (r != SQLITE_ROW) { CHECK_ERROR_OR_THROW( env->isolate(), iter->stmt_->db_.get(), r, SQLITE_DONE, void()); iter->done_ = true; RESET_OR_THROW(env->isolate(), iter->stmt_->db_.get(), - iter->stmt_->statement_, + iter->stmt_->statement_.get(), void()); MaybeLocal values[] = {Boolean::New(isolate, true), Null(isolate)}; Local result; @@ -3889,13 +3896,13 @@ void StatementSyncIterator::Next(const FunctionCallbackInfo& args) { return; } - int num_cols = sqlite3_column_count(iter->stmt_->statement_); + int num_cols = sqlite3_column_count(iter->stmt_->statement_.get()); Local row_value; LocalVector row_keys(isolate); LocalVector row_values(isolate); if (ExtractRowValues(env, - iter->stmt_->statement_, + iter->stmt_->statement_.get(), num_cols, iter->stmt_->use_big_ints_, &row_values) @@ -3935,7 +3942,7 @@ void StatementSyncIterator::Return(const FunctionCallbackInfo& args) { // is invoked by the language during abrupt completion (e.g. a `throw` // inside a `for...of` body), and throwing on a deferred SQLite error // would discard the caller's already-pending exception. - sqlite3_reset(iter->stmt_->statement_); + sqlite3_reset(iter->stmt_->statement_.get()); iter->done_ = true; auto iter_template = getLazyIterTemplate(env); diff --git a/src/node_sqlite.h b/src/node_sqlite.h index b4446e5db859..17025a528622 100644 --- a/src/node_sqlite.h +++ b/src/node_sqlite.h @@ -135,6 +135,12 @@ class StatementSync; class BackupJob; class Session; +inline void FinalizeStatement(sqlite3_stmt* stmt) { + sqlite3_finalize(stmt); +} + +using StatementPtr = DeleteFnPtr; + class StatementExecutionHelper { public: static v8::MaybeLocal All(Environment* env, @@ -263,13 +269,13 @@ class StatementSync : public BaseObject { StatementSync(Environment* env, v8::Local object, BaseObjectPtr db, - sqlite3_stmt* stmt); + StatementPtr stmt); void MemoryInfo(MemoryTracker* tracker) const override; static v8::Local GetConstructorTemplate( Environment* env); static BaseObjectPtr Create(Environment* env, BaseObjectPtr db, - sqlite3_stmt* stmt); + StatementPtr stmt); static void All(const v8::FunctionCallbackInfo& args); static void Iterate(const v8::FunctionCallbackInfo& args); static void Get(const v8::FunctionCallbackInfo& args); @@ -299,7 +305,7 @@ class StatementSync : public BaseObject { ~StatementSync() override; void Close(); BaseObjectPtr db_; - sqlite3_stmt* statement_; + StatementPtr statement_; bool return_arrays_ = false; bool use_big_ints_; bool allow_bare_named_params_; diff --git a/test/parallel/test-sqlite-template-tag.js b/test/parallel/test-sqlite-template-tag.js index 20376e199d1b..eaa6d19fc7cd 100644 --- a/test/parallel/test-sqlite-template-tag.js +++ b/test/parallel/test-sqlite-template-tag.js @@ -371,3 +371,20 @@ test('tag store prevents circular reference leaks', async () => { return after < before * 1.5; }, 20); }); + +test('cached statements are finalized when the database is closed', () => { + const db = new DatabaseSync(':memory:'); + const sql = db.createTagStore(); + + db.exec('CREATE TABLE foo (id INTEGER PRIMARY KEY)'); + db.exec('INSERT INTO foo (id) VALUES (1)'); + assert.deepStrictEqual(sql.all`SELECT id FROM foo`, [{ __proto__: null, id: 1 }]); + + db.close(); + db.open(); + + assert.throws(() => sql.all`SELECT id FROM foo`, { + code: 'ERR_SQLITE_ERROR', + message: /no such table/i, + }); +}); From 6f9e0846eaa4aed3d4b9237fff859602055ae026 Mon Sep 17 00:00:00 2001 From: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:46:06 -0700 Subject: [PATCH 121/344] ffi: validate fast pointer BigInt argument ranges Optimized V8 fast API calls truncate out-of-range pointer BigInts. Validate them against uintptrMax before invoking the raw function so optimized calls match the generic and shared-buffer paths. Signed-off-by: Kamat, Trivikram <16024985+trivikr@users.noreply.github.com> Assisted-by: codex:gpt-5.6-sol PR-URL: https://github.com/nodejs/node/pull/65032 Fixes: https://github.com/nodejs/node/issues/65031 Reviewed-By: Matteo Collina --- lib/internal/ffi/fast-api.js | 11 +++++++ src/node_ffi.cc | 6 ++-- test/ffi/test-ffi-fast-integer-validation.js | 31 ++++++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/lib/internal/ffi/fast-api.js b/lib/internal/ffi/fast-api.js index 486a119a2e07..a232caa1af24 100644 --- a/lib/internal/ffi/fast-api.js +++ b/lib/internal/ffi/fast-api.js @@ -24,6 +24,7 @@ const { getRawPointer, kFastArguments, kFastBufferInvoke, + uintptrMax, } = internalBinding('ffi'); const { @@ -110,6 +111,14 @@ function needsPointerConversion(type) { needsNullPointerConversion(type) || needsStringPointerConversion(type); } +function validateFastPointerArg(type, value, index) { + if (needsPointerConversion(type) && typeof value === 'bigint' && + (value < 0n || value > uintptrMax)) { + throwFFIArgError( + `Argument ${index} must be a non-negative pointer bigint`); + } +} + function hasStringPointerArg(type, value) { return typeof value === 'string' && needsStringPointerConversion(type); } @@ -159,6 +168,7 @@ function getStringConversionPointer(state, value, index) { } function convertPointerArg(type, value, stringState, index) { + validateFastPointerArg(type, value, index); if (needsNullPointerConversion(type) && (value === null || value === undefined)) { return 0n; @@ -261,6 +271,7 @@ function wrapWithRawPointerConversions(rawFn, argumentTypes, owner) { throwFFIArgCountError(1, arguments.length); } validateFastIntegerArg(t0, a0, 0); + validateFastPointerArg(t0, a0, 0); let arg = a0; if (needsNullPointerConversion(t0) && (arg === null || arg === undefined)) { diff --git a/src/node_ffi.cc b/src/node_ffi.cc index 1e1fc5654591..42c62c829168 100644 --- a/src/node_ffi.cc +++ b/src/node_ffi.cc @@ -1341,9 +1341,9 @@ static void Initialize(Local target, Boolean::New(isolate, CHAR_MIN < 0)) .Check(); - // The shared-buffer fast path uses `uintptrMax` to reject pointer BigInts - // that would otherwise be silently truncated by `ReadFFIArgFromBuffer`'s - // `memcpy(..., type->size, ...)` on 32-bit platforms. The slow path + // The JavaScript fast paths use `uintptrMax` to reject pointer BigInts that + // would otherwise be silently truncated by V8 or, on 32-bit platforms, by + // `ReadFFIArgFromBuffer`'s `memcpy(..., type->size, ...)`. The slow path // rejects the same values through `ToFFIArgument`. target ->Set(context, diff --git a/test/ffi/test-ffi-fast-integer-validation.js b/test/ffi/test-ffi-fast-integer-validation.js index 26d51ae4248f..1243391ac87c 100644 --- a/test/ffi/test-ffi-fast-integer-validation.js +++ b/test/ffi/test-ffi-fast-integer-validation.js @@ -68,3 +68,34 @@ test('fast FFI validates integer argument ranges', () => { lib.close(); } }); + +test('fast FFI validates pointer BigInt ranges', () => { + const lib = new ffi.DynamicLibrary(libraryPath); + try { + for (const type of ['pointer', 'ptr', 'string', 'str', + 'buffer', 'arraybuffer']) { + const identityPointer = lib.getFunction('identity_pointer', { + arguments: [type], + return: 'pointer', + }); + const sumBuffer = lib.getFunction('sum_buffer', { + arguments: [type, 'u64'], + return: 'u64', + }); + function callSingle(value) { return identityPointer(value); } + + function callMultiple(value) { return sumBuffer(value, 0n); } + + optimize(callSingle, 0n); + optimize(callMultiple, 0n); + + const expect = { code: 'ERR_INVALID_ARG_VALUE' }; + for (const call of [callSingle, callMultiple]) { + assert.throws(() => call(-1n), expect); + assert.throws(() => call((2n ** 64n) + 5n), expect); + } + } + } finally { + lib.close(); + } +}); From 9fc811a0f2c9da0fa5b80e96b56bb2ffcda414e6 Mon Sep 17 00:00:00 2001 From: Donghoon Kang Date: Wed, 12 Aug 2026 18:51:21 +0900 Subject: [PATCH 122/344] typings: add credentials internal binding types Add a CredentialsBinding declaration for internalBinding('credentials') and wire it into InternalBindingMap. Signed-off-by: HoonDongKang PR-URL: https://github.com/nodejs/node/pull/65036 Reviewed-By: Daeyeon Jeong --- typings/globals.d.ts | 2 ++ typings/internalBinding/credentials.d.ts | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 typings/internalBinding/credentials.d.ts diff --git a/typings/globals.d.ts b/typings/globals.d.ts index 83fcec8e19a5..536a8c4c2822 100644 --- a/typings/globals.d.ts +++ b/typings/globals.d.ts @@ -5,6 +5,7 @@ import { BufferBinding } from './internalBinding/buffer'; import { CJSLexerBinding } from './internalBinding/cjs_lexer'; import { ConfigBinding } from './internalBinding/config'; import { ConstantsBinding } from './internalBinding/constants'; +import { CredentialsBinding } from './internalBinding/credentials'; import { CryptoBinding } from './internalBinding/crypto'; import { DebugBinding } from './internalBinding/debug'; import { EncodingBinding } from './internalBinding/encoding_binding'; @@ -44,6 +45,7 @@ interface InternalBindingMap { cjs_lexer: CJSLexerBinding; config: ConfigBinding; constants: ConstantsBinding; + credentials: CredentialsBinding; crypto: CryptoBinding; debug: DebugBinding; encoding_binding: EncodingBinding; diff --git a/typings/internalBinding/credentials.d.ts b/typings/internalBinding/credentials.d.ts new file mode 100644 index 000000000000..8880e7e38a6f --- /dev/null +++ b/typings/internalBinding/credentials.d.ts @@ -0,0 +1,18 @@ +export interface CredentialsBinding { + implementsPosixCredentials?: true; + safeGetenv(key: string): string | undefined; + getTempDir(): string | undefined; + + getuid?(): number; + geteuid?(): number; + getgid?(): number; + getegid?(): number; + getgroups?(): number[]; + + initgroups?(user: string | number, extraGroup: string | number): 0 | 1 | 2; + setegid?(id: string | number): 0 | 1; + seteuid?(id: string | number): 0 | 1; + setgid?(id: string | number): 0 | 1; + setuid?(id: string | number): 0 | 1; + setgroups?(groups: Array): number; +} From c63e873073cd675942ac78d5025fe730ea71a84c Mon Sep 17 00:00:00 2001 From: greenhead Date: Wed, 12 Aug 2026 22:38:41 +0900 Subject: [PATCH 123/344] doc: fix broken fs.BigIntStats link in vfs.md fs.md describes the bigint variant inside the fs.Stats section and has no separate fs.BigIntStats section to link to. Signed-off-by: greenhead PR-URL: https://github.com/nodejs/node/pull/65045 Reviewed-By: Daeyeon Jeong Reviewed-By: James M Snell --- doc/api/vfs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/api/vfs.md b/doc/api/vfs.md index 7a1695d8b355..1551a18feac7 100644 --- a/doc/api/vfs.md +++ b/doc/api/vfs.md @@ -320,6 +320,6 @@ fields use synthetic but stable values: [`RealFSProvider`]: #class-realfsprovider [`VirtualFileSystem`]: #class-virtualfilesystem [`VirtualProvider`]: #class-virtualprovider -[`fs.BigIntStats`]: fs.md#class-fsbigintstats +[`fs.BigIntStats`]: fs.md#class-fsstats [`fs.Stats`]: fs.md#class-fsstats [`node:fs`]: fs.md From 4018f3a776b28181eaba99b01003c77f1b0ce415 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sat, 8 Aug 2026 12:05:24 -0700 Subject: [PATCH 124/344] src: shave about 20 bytes off each TLSWrap instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit By shifting from individual bool fields to a packed struct we can save 20 bytes per TLSWrap instance Signed-off-by: James M Snell PR-URL: https://github.com/nodejs/node/pull/65144 Reviewed-By: Tim Perry Reviewed-By: Gürgün Dayıoğlu Reviewed-By: Tobias Nießen --- src/crypto/crypto_tls.cc | 74 +++++++++++++++++++--------------------- src/crypto/crypto_tls.h | 62 +++++++++++++++++++-------------- 2 files changed, 73 insertions(+), 63 deletions(-) diff --git a/src/crypto/crypto_tls.cc b/src/crypto/crypto_tls.cc index 8ef74aee2d0e..936c201de99d 100644 --- a/src/crypto/crypto_tls.cc +++ b/src/crypto/crypto_tls.cc @@ -240,7 +240,7 @@ int SelectALPNCallback( unsigned int inlen, void* arg) { TLSWrap* w = static_cast(SSL_get_app_data(s)); - if (w->alpn_callback_enabled_) { + if (w->get_alpn_callback_enabled()) { Environment* env = w->env(); HandleScope handle_scope(env->isolate()); @@ -275,7 +275,7 @@ int SelectALPNCallback( return SSL_TLSEXT_ERR_OK; } - const std::vector& alpn_protos = w->alpn_protos_; + auto& alpn_protos = w->get_alpn_protos(); if (alpn_protos.empty()) return SSL_TLSEXT_ERR_NOACK; @@ -403,9 +403,9 @@ TLSWrap::TLSWrap(Environment* env, StreamBase(env), env_(env), kind_(kind), - sc_(sc), - has_active_write_issued_by_prev_listener_( - under_stream_ws == UnderlyingStreamWriteStatus::kHasActive) { + sc_(sc) { + flags_.has_active_write_issued_by_prev_listener = + under_stream_ws == UnderlyingStreamWriteStatus::kHasActive; MakeWeak(); CHECK(sc_); ssl_ = sc_->CreateSSL(); @@ -444,8 +444,7 @@ SSL_SESSION* TLSWrap::ReleaseSession() { void TLSWrap::InvokeQueued(int status, const char* error_str) { Debug(this, "Invoking queued write callbacks (%d, %s)", status, error_str); - if (!write_callback_scheduled_) - return; + if (!flags_.write_callback_scheduled) return; if (current_write_) { BaseObjectPtr current_write = std::move(current_write_); @@ -465,8 +464,8 @@ void TLSWrap::NewSessionDoneCb() { bool TLSWrap::OnEarlyClientHello(const unsigned char* session_id, size_t session_id_len, bool has_ticket) { - if (!hello_emitted_) { - hello_emitted_ = true; + if (!flags_.hello_emitted) { + flags_.hello_emitted = true; Debug(this, "Scheduling onclienthello"); // The hello data is only valid inside the library callback, and JS must @@ -480,7 +479,7 @@ bool TLSWrap::OnEarlyClientHello(const unsigned char* session_id, if (ssl_) EmitClientHello(id, has_ticket); }); } - return hello_answered_; + return flags_.hello_answered; } void TLSWrap::EmitClientHello(const std::vector& session_id, @@ -658,8 +657,8 @@ void TLSWrap::Start(const FunctionCallbackInfo& args) { TLSWrap* wrap; ASSIGN_OR_RETURN_UNWRAP(&wrap, args.This()); - CHECK(!wrap->started_); - wrap->started_ = true; + CHECK(!wrap->flags_.started); + wrap->flags_.started = true; // Send ClientHello handshake CHECK(wrap->is_client()); @@ -703,7 +702,7 @@ void TLSWrap::SSLInfoCallback(const SSL* ssl_, int where, int ret) { CHECK(!SSL_renegotiate_pending(ssl)); Local callback; - c->established_ = true; + c->flags_.established = true; if (object->Get(env->context(), env->onhandshakedone_string()) .ToLocal(&callback) && callback->IsFunction()) { @@ -727,7 +726,7 @@ void TLSWrap::EncOut() { return; } - if (has_active_write_issued_by_prev_listener_) [[unlikely]] { + if (flags_.has_active_write_issued_by_prev_listener) [[unlikely]] { Debug(this, "Returning from EncOut(), " "has_active_write_issued_by_prev_listener_ is true"); @@ -735,9 +734,9 @@ void TLSWrap::EncOut() { } // Split-off queue - if (established_ && current_write_) { + if (flags_.established && current_write_) { Debug(this, "EncOut() write is scheduled"); - write_callback_scheduled_ = true; + flags_.write_callback_scheduled = true; } if (ssl_ == nullptr) { @@ -750,7 +749,7 @@ void TLSWrap::EncOut() { Debug(this, "No pending encrypted output"); if (!pending_cleartext_input_ || pending_cleartext_input_->ByteLength() == 0) { - if (!in_dowrite_) { + if (!flags_.in_dowrite) { Debug(this, "No pending cleartext input, not inside DoWrite()"); InvokeQueued(0); } else { @@ -805,7 +804,7 @@ void TLSWrap::EncOut() { void TLSWrap::OnStreamAfterWrite(WriteWrap* req_wrap, int status) { Debug(this, "OnStreamAfterWrite(status = %d)", status); - if (has_active_write_issued_by_prev_listener_) [[unlikely]] { + if (flags_.has_active_write_issued_by_prev_listener) [[unlikely]] { Debug(this, "Notify write finish to the previous_listener_"); CHECK_EQ(write_size_, 0); // we must have restrained writes @@ -830,7 +829,7 @@ void TLSWrap::OnStreamAfterWrite(WriteWrap* req_wrap, int status) { // Handle error if (status) { - if (shutdown_) { + if (flags_.shutdown) { Debug(this, "Ignoring error after shutdown"); return; } @@ -855,7 +854,7 @@ void TLSWrap::ClearOut() { Debug(this, "Trying to read cleartext output"); // No reads after EOF - if (eof_) { + if (flags_.eof) { Debug(this, "Returning from ClearOut(), EOF reached"); return; } @@ -911,8 +910,8 @@ void TLSWrap::ClearOut() { int err = SSL_get_error(ssl_.get(), read); switch (err) { case SSL_ERROR_ZERO_RETURN: - if (!eof_) { - eof_ = true; + if (!flags_.eof) { + flags_.eof = true; EmitRead(UV_EOF); } return; @@ -1005,7 +1004,7 @@ void TLSWrap::ClearIn() { int err = SSL_get_error(ssl_.get(), written); if (err == SSL_ERROR_SSL || err == SSL_ERROR_SYSCALL) { Debug(this, "Got SSL error (%d)", err); - write_callback_scheduled_ = true; + flags_.write_callback_scheduled = true; // TODO(@sam-github) Should forward an error object with // .code/.function/.etc, if possible. InvokeQueued(UV_EPROTO, GetBIOError().c_str()); @@ -1049,7 +1048,7 @@ bool TLSWrap::IsClosing() { int TLSWrap::ReadStart() { Debug(this, "ReadStart()"); - if (underlying_stream() != nullptr && !eof_) + if (underlying_stream() != nullptr && !flags_.eof) return underlying_stream()->ReadStart(); return 0; } @@ -1197,9 +1196,9 @@ int TLSWrap::DoWrite(WriteWrap* w, // Write any encrypted/handshake output that may be ready. // Guard against sync call of current_write_->Done(), its unsupported. - in_dowrite_ = true; + flags_.in_dowrite = true; EncOut(); - in_dowrite_ = false; + flags_.in_dowrite = false; return 0; } @@ -1216,8 +1215,7 @@ void TLSWrap::OnStreamRead(ssize_t nread, const uv_buf_t& buf) { Debug(this, "Read %zd bytes from underlying stream", nread); // Ignore everything after close_notify (rfc5246#section-7.2.1) - if (eof_) - return; + if (flags_.eof) return; if (nread < 0) { // Error should be emitted only after all data was read @@ -1225,7 +1223,7 @@ void TLSWrap::OnStreamRead(ssize_t nread, const uv_buf_t& buf) { if (nread == UV_EOF) { // underlying stream already should have also called ReadStop on itself - eof_ = true; + flags_.eof = true; } EmitRead(nread); @@ -1256,7 +1254,7 @@ int TLSWrap::DoShutdown(ShutdownWrap* req_wrap) { if (ssl_ && SSL_shutdown(ssl_.get()) == 0) SSL_shutdown(ssl_.get()); - shutdown_ = true; + flags_.shutdown = true; EncOut(); return underlying_stream()->DoShutdown(req_wrap); } @@ -1352,7 +1350,7 @@ void TLSWrap::Destroy() { return; // If there is a write happening, mark it as finished. - write_callback_scheduled_ = true; + flags_.write_callback_scheduled = true; // And destroy InvokeQueued(UV_ECANCELED, "Canceled because of SSL destruction"); @@ -1389,7 +1387,7 @@ void TLSWrap::ResumeAfterCertCb(void* arg) { void TLSWrap::EnableALPNCb(const FunctionCallbackInfo& args) { TLSWrap* wrap; ASSIGN_OR_RETURN_UNWRAP(&wrap, args.This()); - wrap->alpn_callback_enabled_ = true; + wrap->flags_.alpn_callback_enabled = true; SSL* ssl = wrap->ssl_.get(); SSL_CTX* ssl_ctx = SSL_get_SSL_CTX(ssl); @@ -1418,7 +1416,7 @@ void TLSWrap::SetServername(const FunctionCallbackInfo& args) { CHECK_EQ(args.Length(), 1); CHECK(args[0]->IsString()); - CHECK(!wrap->started_); + CHECK(!wrap->flags_.started); CHECK(wrap->is_client()); CHECK(wrap->ssl_); @@ -1633,7 +1631,7 @@ void TLSWrap::CertCbDone(const FunctionCallbackInfo& args) { TLSWrap* w; ASSIGN_OR_RETURN_UNWRAP(&w, args.This()); - CHECK(w->is_waiting_cert_cb() && w->cert_cb_running_); + CHECK(w->is_waiting_cert_cb() && w->flags_.cert_cb_running); Local object = w->object(); Local ctx = object->Get(env->context(), env->sni_context_string()) @@ -1685,7 +1683,7 @@ void TLSWrap::CertCbDone(const FunctionCallbackInfo& args) { cb = w->cert_cb_; arg = w->cert_cb_arg_; - w->cert_cb_running_ = false; + w->flags_.cert_cb_running = false; w->cert_cb_ = nullptr; w->cert_cb_arg_ = nullptr; @@ -2072,7 +2070,7 @@ void TLSWrap::ExportKeyingMaterial(const FunctionCallbackInfo& args) { void TLSWrap::ClientHelloDone(const FunctionCallbackInfo& args) { TLSWrap* w; ASSIGN_OR_RETURN_UNWRAP(&w, args.This()); - w->hello_answered_ = true; + w->flags_.hello_answered = true; w->Cycle(); } @@ -2107,7 +2105,7 @@ void TLSWrap::GetTLSTicket(const FunctionCallbackInfo& args) { void TLSWrap::NewSessionDone(const FunctionCallbackInfo& args) { TLSWrap* w; ASSIGN_OR_RETURN_UNWRAP(&w, args.This()); - w->awaiting_new_session_ = false; + w->flags_.awaiting_new_session = false; w->NewSessionDoneCb(); } @@ -2188,7 +2186,7 @@ void TLSWrap::WritesIssuedByPrevListenerDone( ASSIGN_OR_RETURN_UNWRAP(&w, args.This()); Debug(w, "WritesIssuedByPrevListenerDone is called"); - w->has_active_write_issued_by_prev_listener_ = false; + w->flags_.has_active_write_issued_by_prev_listener = false; w->EncOut(); // resume all of our restrained writes } diff --git a/src/crypto/crypto_tls.h b/src/crypto/crypto_tls.h index a5ded3392915..9a5f59ed472e 100644 --- a/src/crypto/crypto_tls.h +++ b/src/crypto/crypto_tls.h @@ -62,22 +62,26 @@ class TLSWrap : public AsyncWrap, ~TLSWrap() override; - inline bool is_cert_cb_running() const { return cert_cb_running_; } + inline bool is_cert_cb_running() const { return flags_.cert_cb_running; } inline bool is_waiting_cert_cb() const { return cert_cb_ != nullptr; } - inline bool has_session_callbacks() const { return session_callbacks_; } + inline bool has_session_callbacks() const { return flags_.session_callbacks; } // We need to suspend the ClientHello only for server session id // callbacks, and only on the first pass. inline bool should_suspend_for_client_hello() const { - return is_server() && session_callbacks_ && !hello_answered_; + return is_server() && flags_.session_callbacks && !flags_.hello_answered; + } + inline void set_cert_cb_running(bool on = true) { + flags_.cert_cb_running = on; } - inline void set_cert_cb_running(bool on = true) { cert_cb_running_ = on; } inline void set_awaiting_new_session(bool on = true) { - awaiting_new_session_ = on; + flags_.awaiting_new_session = on; } - inline void enable_session_callbacks() { session_callbacks_ = true; } + inline void enable_session_callbacks() { flags_.session_callbacks = true; } inline bool is_server() const { return kind_ == Kind::kServer; } inline bool is_client() const { return kind_ == Kind::kClient; } - inline bool is_awaiting_new_session() const { return awaiting_new_session_; } + inline bool is_awaiting_new_session() const { + return flags_.awaiting_new_session; + } // Implement StreamBase: bool IsAlive() override; @@ -125,6 +129,14 @@ class TLSWrap : public AsyncWrap, std::string diagnostic_name() const override; + bool get_alpn_callback_enabled() const { + return flags_.alpn_callback_enabled; + } + + const std::vector& get_alpn_protos() const { + return alpn_protos_; + } + private: // OpenSSL structures are opaque. Estimate SSL memory size for OpenSSL 1.1.1b: // SSL: 6224 @@ -284,26 +296,30 @@ class TLSWrap : public AsyncWrap, BaseObjectPtr current_empty_write_; std::string error_; - bool session_callbacks_ = false; - bool awaiting_new_session_ = false; - // 'onclienthello' has been emitted for this connection. - bool hello_emitted_ = false; - // JS has answered it by calling clientHelloDone(). - bool hello_answered_ = false; - bool in_dowrite_ = false; - bool started_ = false; - bool shutdown_ = false; - bool cert_cb_running_ = false; - bool eof_ = false; - // TODO(@jasnell): These state flags should be revisited. // The established_ flag indicates that the handshake is // completed. The write_callback_scheduled_ flag is less // clear -- once it is set to true, it is never set to // false and it is only set to true after established_ // is set to true, so it's likely redundant. - bool established_ = false; - bool write_callback_scheduled_ = false; + struct Flags { + bool session_callbacks : 1; + bool awaiting_new_session : 1; + // 'onclienthello' has been emitted for this connection. + bool hello_emitted : 1; + // JS has answered it by calling clientHelloDone(). + bool hello_answered : 1; + bool in_dowrite : 1; + bool started : 1; + bool shutdown : 1; + bool cert_cb_running : 1; + bool eof : 1; + bool established : 1; + bool write_callback_scheduled : 1; + bool has_active_write_issued_by_prev_listener : 1; + bool alpn_callback_enabled : 1; + }; + Flags flags_{}; int cycle_depth_ = 0; @@ -313,11 +329,7 @@ class TLSWrap : public AsyncWrap, ncrypto::BIOPointer bio_trace_; - bool has_active_write_issued_by_prev_listener_ = false; - - public: std::vector alpn_protos_; // Accessed by SelectALPNCallback. - bool alpn_callback_enabled_ = false; // Accessed by SelectALPNCallback. }; } // namespace crypto From 753033c110f259c0532720139f0508bbee291dc5 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Tue, 4 Aug 2026 08:39:49 -0700 Subject: [PATCH 125/344] lib,src: improve histogram implementation Several improvements: 1. In histogram-inl, Add previous locked only this->mutex while reading the other's fields unsafely. 2. In histogram.cc, PrepareCB now uses ContainerOf 3. In histogram.cc, BigInt value range is checked 4. In histogram.js, simplified impl and reduced duplication 5. In event_loop_delay.js, use a more consistent constructor Adds new analytical APIs to Histogram * histogram.ccdf(value) * histogram.cdf(value) * histogram.countAt(value) * histogram.ksTest(other) * histogram.kurtosis * histogram.linearBuckets(stepSize) * histogram.logBuckets(first, base) * histogram.percentilesAt(percentiles) * histogram.shewness On RecordableHistogram * histogram.recordCorrected(val, expectedInterval) * histogram.subtract(other) Signed-off-by: James M Snell Assisted-by: Opencode/Opus PR-URL: https://github.com/nodejs/node/pull/65024 Reviewed-By: Matteo Collina --- doc/api/perf_hooks.md | 240 +++++++++ lib/internal/histogram.js | 212 +++++++- lib/internal/perf/event_loop_delay.js | 23 +- src/histogram-inl.h | 108 +++- src/histogram.cc | 469 +++++++++++++--- src/histogram.h | 133 +++-- .../test-perf-hooks-histogram-analysis.js | 501 ++++++++++++++++++ 7 files changed, 1530 insertions(+), 156 deletions(-) create mode 100644 test/parallel/test-perf-hooks-histogram-analysis.js diff --git a/doc/api/perf_hooks.md b/doc/api/perf_hooks.md index 78a3b6210326..544c25e9fdc9 100644 --- a/doc/api/perf_hooks.md +++ b/doc/api/perf_hooks.md @@ -1866,6 +1866,45 @@ added: The number of samples recorded by the histogram. +### `histogram.ccdf(value)` + + + +* `value` {number} The value to query. +* Returns: {number} A probability between 0.0 and 1.0. + +Returns the complementary cumulative distribution function (CCDF) value +for the given value, representing the probability that a recorded value +will exceed `value`. Equivalent to `1 - histogram.cdf(value)`. + +### `histogram.cdf(value)` + + + +* `value` {number} The value to query. +* Returns: {number} A probability between 0.0 and 1.0. + +Returns the cumulative distribution function (CDF) value for the given +value, representing the probability that a recorded value will be less +than or equal to `value`. This is the inverse operation of +`histogram.percentile()`. + +### `histogram.countAt(value)` + + + +* `value` {number} The value to query. +* Returns: {number} + +Returns the number of recorded values that fall within the equivalent +value range of the given value. + ### `histogram.exceeds` + +* `other` {Histogram} The histogram to compare against. +* Returns: {number} The KS D-statistic, between 0.0 and 1.0. + +Computes the Kolmogorov-Smirnov test statistic comparing this histogram's +distribution to `other`. A value of 0 indicates identical distributions; +values close to 1 indicate completely disjoint distributions. Useful for +detecting performance regressions by comparing before/after histograms. + +### `histogram.kurtosis` + + + +* Type: {number} + +The excess kurtosis of the recorded values. Measures the heaviness of the +distribution's tails relative to a normal distribution. Positive values +indicate heavier tails (more extreme outliers); negative values indicate +lighter tails. + +### `histogram.linearBuckets(stepSize)` + + + +* `stepSize` {number} The width of each linear bucket. +* Returns: {Map} A map of bucket boundary values to counts. + +Returns the histogram data rebucketed into linearly-spaced intervals +of `stepSize`. Useful for visualization and export. + +### `histogram.logBuckets(firstBucket, base)` + + + +* `firstBucket` {number} The value of the first bucket boundary. +* `base` {number} The logarithmic base for bucket width growth. Must be > 1. +* Returns: {Map} A map of bucket boundary values to counts. + +Returns the histogram data rebucketed into logarithmically-spaced +intervals, where each bucket's width is multiplied by `base`. +Useful for visualization and export. + ### `histogram.max` + +* `percentiles` {number\[]} An array of percentile values in the range (0, 100]. +* Returns: {Map} A map of percentile values to their corresponding histogram + values. + +Returns the values at the specified percentiles, computed in a single +efficient pass over the histogram data. More efficient than calling +`histogram.percentile()` multiple times. + ### `histogram.reset()` + +* Type: {number} + +The skewness of the recorded values. Measures the asymmetry of the +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.stddev` + +* `val` {number|bigint} The value to record. +* `expectedInterval` {number|bigint} The expected recording interval. + +Records a value with coordinated omission correction. When a system stall +prevents timely recording, this method backfills intermediate values at +`expectedInterval` steps between the previously recorded value and `val`. +This compensates for measurement gaps that would otherwise underrepresent +latency. + +### `histogram.subtract(other)` + + + +* `other` {RecordableHistogram} + +Subtracts the values of `other` from this histogram. Both histograms should +have compatible configurations. Bucket counts that would become negative +are clamped to zero. + +## Histogram analysis examples + +The `Histogram` class provides statistical analysis methods useful for +performance monitoring, SLO enforcement, and regression detection. + +### Distribution shape analysis + +```js +const { createHistogram } = require('node:perf_hooks'); + +const h = createHistogram(); + +// Simulate a right-skewed latency distribution +for (let i = 0; i < 1000; i++) { + h.record(Math.ceil(Math.random() * 100)); +} +// Add some outliers +for (let i = 0; i < 10; i++) { + h.record(500 + Math.ceil(Math.random() * 500)); +} + +console.log('Skewness:', h.skewness.toFixed(4)); // Positive = right-skewed +console.log('Kurtosis:', h.kurtosis.toFixed(4)); // Positive = heavy tails +``` + +### SLO monitoring with CDF + +```js +const { createHistogram } = require('node:perf_hooks'); + +const latency = createHistogram(); + +// Record request latencies (in nanoseconds)... + +// "What fraction of requests complete within 100ms?" +const withinSLO = latency.cdf(100_000_000); +console.log(`${(withinSLO * 100).toFixed(1)}% of requests within SLO`); + +// "What fraction of requests exceed 500ms?" +const violating = latency.ccdf(500_000_000); +console.log(`${(violating * 100).toFixed(1)}% of requests violating SLO`); +``` + +### Regression detection with KS test + +```js +const { createHistogram } = require('node:perf_hooks'); + +const baseline = createHistogram(); +const current = createHistogram(); + +// Record baseline and current latencies... + +// D-statistic: 0 = identical, 1 = completely different +const d = baseline.ksTest(current); +if (d > 0.1) { + console.log(`Possible regression detected (D=${d.toFixed(4)})`); +} +``` + +### Batch percentile queries + +```js +const { createHistogram } = require('node:perf_hooks'); + +const h = createHistogram(); +// Record values... + +// Efficiently query common monitoring percentiles in one pass +const p = h.percentilesAt([50, 75, 90, 95, 99, 99.9]); +console.log('p50:', p.get(50)); +console.log('p99:', p.get(99)); +``` + +### Snapshot diffing with subtract + +```js +const { createHistogram } = require('node:perf_hooks'); + +const total = createHistogram(); +const snapshot = createHistogram(); + +// Record values into total... +// Periodically snapshot for "last interval" analysis: +snapshot.add(total); + +// Later, take a new snapshot and diff: +const newSnapshot = createHistogram(); +newSnapshot.add(total); +newSnapshot.subtract(snapshot); +// newSnapshot now contains only the values recorded since the last snapshot +console.log('Recent p99:', newSnapshot.percentile(99)); +``` + ## Examples ### Measuring the duration of async operations diff --git a/lib/internal/histogram.js b/lib/internal/histogram.js index f2cf3835b9a6..c16c894dd147 100644 --- a/lib/internal/histogram.js +++ b/lib/internal/histogram.js @@ -1,13 +1,13 @@ 'use strict'; const { + ArrayIsArray, + Float64Array, Map, - MapPrototypeClear, MapPrototypeEntries, NumberIsNaN, NumberMAX_SAFE_INTEGER, ObjectFromEntries, - ReflectConstruct, Symbol, } = primordials; @@ -40,7 +40,6 @@ const { const kDestroy = Symbol('kDestroy'); const kHandle = Symbol('kHandle'); -const kMap = Symbol('kMap'); const kRecordable = Symbol('kRecordable'); const { @@ -77,6 +76,8 @@ class Histogram { mean: this.mean, exceeds: this.exceeds, stddev: this.stddev, + skewness: this.skewness, + kurtosis: this.kurtosis, count: this.count, percentiles: this.percentiles, }, opts)}`; @@ -102,6 +103,46 @@ class Histogram { return this[kHandle]?.countBigInt(); } + /** + * Returns the probability that a recorded value will exceed `value` + * (the complement of the cumulative distribution function). + * @param {number} value + * @returns {number} A value between 0.0 and 1.0. + */ + ccdf(value) { + if (!isHistogram(this)) + throw new ERR_INVALID_THIS('Histogram'); + validateNumber(value, 'value'); + return 1 - this[kHandle]?.cdf(value); + } + + /** + * Returns the cumulative distribution function (CDF) value for the + * given value, representing the probability that a recorded value + * will be less than or equal to `value`. + * @param {number} value + * @returns {number} A value between 0.0 and 1.0. + */ + cdf(value) { + if (!isHistogram(this)) + throw new ERR_INVALID_THIS('Histogram'); + validateNumber(value, 'value'); + return this[kHandle]?.cdf(value); + } + + /** + * Returns the number of recorded values that fall within the + * equivalent value range of the given value. + * @param {number} value + * @returns {number} + */ + countAt(value) { + if (!isHistogram(this)) + throw new ERR_INVALID_THIS('Histogram'); + validateNumber(value, 'value'); + return this[kHandle]?.countAt(value); + } + /** * @readonly * @type {number} @@ -172,6 +213,81 @@ class Histogram { return this[kHandle]?.exceedsBigInt(); } + /** + * Returns the Kolmogorov-Smirnov test statistic comparing this + * histogram's distribution to another's. Returns a value between + * 0.0 (identical distributions) and 1.0 (completely disjoint). + * @param {Histogram} other + * @returns {number} + */ + ksTest(other) { + if (!isHistogram(this)) + throw new ERR_INVALID_THIS('Histogram'); + if (!isHistogram(other)) + throw new ERR_INVALID_ARG_TYPE('other', 'Histogram', other); + return this[kHandle]?.ksTest(other[kHandle]); + } + + /** + * Returns the excess kurtosis of the recorded values, a measure of + * the heaviness of the distribution's tails. A positive value indicates + * heavier tails (more outliers) than a normal distribution. + * @readonly + * @type {number} + */ + get kurtosis() { + if (!isHistogram(this)) + throw new ERR_INVALID_THIS('Histogram'); + return this[kHandle]?.kurtosis(); + } + + /** + * Returns a {Map} containing the histogram data bucketed into + * linearly-spaced intervals of `stepSize`. + * @param {number} stepSize The width of each linear bucket. + * @returns {Map} + */ + linearBuckets(stepSize) { + if (!isHistogram(this)) + throw new ERR_INVALID_THIS('Histogram'); + validateInteger(stepSize, 'stepSize', 1); + const map = new Map(); + this[kHandle]?.linearBuckets(stepSize, map); + return map; + } + + /** + * Returns a {Map} containing the histogram data bucketed into + * logarithmically-spaced intervals. + * @param {number} firstBucket The value of the first bucket boundary. + * @param {number} base The logarithmic base for bucket width growth. + * @returns {Map} + */ + logBuckets(firstBucket, base) { + if (!isHistogram(this)) + throw new ERR_INVALID_THIS('Histogram'); + validateInteger(firstBucket, 'firstBucket', 1); + validateNumber(base, 'base'); + if (base <= 1) + throw new ERR_OUT_OF_RANGE('base', '> 1', base); + const map = new Map(); + this[kHandle]?.logBuckets(firstBucket, base, map); + return map; + } + + /** + * Returns the skewness of the recorded values, a measure of the + * asymmetry of the distribution. A positive value indicates a + * right-skewed distribution (longer right tail). + * @readonly + * @type {number} + */ + get skewness() { + if (!isHistogram(this)) + throw new ERR_INVALID_THIS('Histogram'); + return this[kHandle]?.skewness(); + } + /** * @readonly * @type {number} @@ -217,9 +333,9 @@ class Histogram { get percentiles() { if (!isHistogram(this)) throw new ERR_INVALID_THIS('Histogram'); - MapPrototypeClear(this[kMap]); - this[kHandle]?.percentiles(this[kMap]); - return this[kMap]; + const map = new Map(); + this[kHandle]?.percentiles(map); + return map; } /** @@ -229,9 +345,34 @@ class Histogram { get percentilesBigInt() { if (!isHistogram(this)) throw new ERR_INVALID_THIS('Histogram'); - MapPrototypeClear(this[kMap]); - this[kHandle]?.percentilesBigInt(this[kMap]); - return this[kMap]; + const map = new Map(); + this[kHandle]?.percentilesBigInt(map); + return map; + } + + /** + * Returns a {Map} of values at the specified percentiles, computed + * in a single efficient pass over the histogram. + * @param {number[]} percentiles Array of percentile values (0, 100]. + * @returns {Map} + */ + percentilesAt(percentiles) { + if (!isHistogram(this)) + throw new ERR_INVALID_THIS('Histogram'); + if (!ArrayIsArray(percentiles)) + throw new ERR_INVALID_ARG_TYPE('percentiles', 'Array', percentiles); + for (let i = 0; i < percentiles.length; i++) { + validateNumber(percentiles[i], `percentiles[${i}]`); + if (NumberIsNaN(percentiles[i]) || + percentiles[i] <= 0 || percentiles[i] > 100) + throw new ERR_OUT_OF_RANGE( + `percentiles[${i}]`, '> 0 && <= 100', percentiles[i]); + } + const sorted = [...percentiles].sort((a, b) => a - b); + const input = new Float64Array(sorted); + const map = new Map(); + this[kHandle]?.percentilesAt(map, input); + return map; } /** @@ -263,6 +404,8 @@ class Histogram { mean: this.mean, exceeds: this.exceeds, stddev: this.stddev, + skewness: this.skewness, + kurtosis: this.kurtosis, percentiles: ObjectFromEntries(MapPrototypeEntries(this.percentiles)), }; } @@ -303,6 +446,44 @@ class RecordableHistogram extends Histogram { this[kHandle]?.recordDelta(); } + /** + * Records a value with coordinated omission correction, backfilling + * intermediate values at `expectedInterval` steps between the last + * recorded value and `val`. This compensates for measurement gaps + * caused by the system being stalled. + * @param {number|bigint} val The amount to record. + * @param {number|bigint} expectedInterval The expected recording interval. + * @returns {void} + */ + recordCorrected(val, expectedInterval) { + if (this[kRecordable] === undefined) + throw new ERR_INVALID_THIS('RecordableHistogram'); + if (typeof val === 'bigint') { + if (typeof expectedInterval !== 'bigint') + throw new ERR_INVALID_ARG_TYPE( + 'expectedInterval', 'bigint', expectedInterval); + this[kHandle]?.recordCorrected(val, expectedInterval); + return; + } + validateInteger(val, 'val', 1); + validateInteger(expectedInterval, 'expectedInterval', 1); + this[kHandle]?.recordCorrected(val, expectedInterval); + } + + /** + * Subtracts the values of `other` from this histogram. Both + * histograms must have compatible configurations. Counts that would + * become negative are clamped to zero. + * @param {RecordableHistogram} other + */ + subtract(other) { + if (this[kRecordable] === undefined) + throw new ERR_INVALID_THIS('RecordableHistogram'); + if (other[kRecordable] === undefined) + throw new ERR_INVALID_ARG_TYPE('other', 'RecordableHistogram', other); + this[kHandle]?.subtract(other[kHandle]); + } + /** * @param {RecordableHistogram} other */ @@ -328,12 +509,10 @@ class RecordableHistogram extends Histogram { } function ClonedHistogram(handle) { - return ReflectConstruct( - function() { - markTransferMode(this, true, false); - this[kHandle] = handle; - this[kMap] = new Map(); - }, [], Histogram); + const histogram = new Histogram(kSkipThrow); + markTransferMode(histogram, true, false); + histogram[kHandle] = handle; + return histogram; } ClonedHistogram.prototype[kDeserialize] = () => { }; @@ -343,7 +522,6 @@ function ClonedRecordableHistogram(handle) { markTransferMode(histogram, true, false); histogram[kRecordable] = true; - histogram[kMap] = new Map(); histogram[kHandle] = handle; histogram.constructor = RecordableHistogram; @@ -391,6 +569,6 @@ module.exports = { isHistogram, kDestroy, kHandle, - kMap, + kSkipThrow, createHistogram, }; diff --git a/lib/internal/perf/event_loop_delay.js b/lib/internal/perf/event_loop_delay.js index ebf0017b70df..4d14182c83fa 100644 --- a/lib/internal/perf/event_loop_delay.js +++ b/lib/internal/perf/event_loop_delay.js @@ -1,7 +1,5 @@ 'use strict'; const { - ReflectConstruct, - SafeMap, Symbol, SymbolDispose, } = primordials; @@ -26,7 +24,7 @@ const { const { Histogram, kHandle, - kMap, + kSkipThrow, } = require('internal/histogram'); const { @@ -40,8 +38,11 @@ const { const kEnabled = Symbol('kEnabled'); class ELDHistogram extends Histogram { - constructor() { - throw new ERR_ILLEGAL_CONSTRUCTOR(); + constructor(skipThrowSymbol = undefined) { + if (skipThrowSymbol !== kSkipThrow) { + throw new ERR_ILLEGAL_CONSTRUCTOR(); + } + super(skipThrowSymbol); } /** @@ -87,13 +88,11 @@ function monitorEventLoopDelay(options = kEmptyObject) { validateBoolean(samplePerIteration, 'options.samplePerIteration'); validateInteger(resolution, 'options.resolution', 1); - return ReflectConstruct( - function() { - markTransferMode(this, true, false); - this[kEnabled] = false; - this[kHandle] = createELDHistogram(resolution, samplePerIteration); - this[kMap] = new SafeMap(); - }, [], ELDHistogram); + const histogram = new ELDHistogram(kSkipThrow); + markTransferMode(histogram, true, false); + histogram[kEnabled] = false; + histogram[kHandle] = createELDHistogram(resolution, samplePerIteration); + return histogram; } module.exports = monitorEventLoopDelay; diff --git a/src/histogram-inl.h b/src/histogram-inl.h index 3b8712c87879..7c3545f53aad 100644 --- a/src/histogram-inl.h +++ b/src/histogram-inl.h @@ -10,57 +10,80 @@ namespace node { void Histogram::Reset() { - Mutex::ScopedLock lock(mutex_); + RwLock::ScopedWriteLock lock(mutex_); hdr_reset(histogram_.get()); exceeds_ = 0; - count_ = 0; prev_ = 0; } double Histogram::Add(const Histogram& other) { - Mutex::ScopedLock lock(mutex_); - count_ += other.count_; - exceeds_ += other.exceeds_; - if (other.prev_ > prev_) - prev_ = other.prev_; - return static_cast(hdr_add(histogram_.get(), other.histogram_.get())); + auto do_add = [&]() { + exceeds_ += other.exceeds_; + if (other.prev_ > prev_) prev_ = other.prev_; + // hdr_add merges all bucket counts and total_count internally. + return static_cast( + hdr_add(histogram_.get(), other.histogram_.get())); + }; + + // When adding a histogram to itself, a single write lock suffices. + if (this == &other) { + RwLock::ScopedWriteLock lock(mutex_); + return do_add(); + } + + // Write-lock this (modified), read-lock other (only read). + // Lock in pointer order to prevent deadlock. + if (this < &other) { + RwLock::ScopedWriteLock lock1(mutex_); + RwLock::ScopedReadLock lock2(other.mutex_); + return do_add(); + } + + RwLock::ScopedReadLock lock1(other.mutex_); + RwLock::ScopedWriteLock lock2(mutex_); + return do_add(); } size_t Histogram::Count() const { - Mutex::ScopedLock lock(mutex_); - return count_; + RwLock::ScopedReadLock lock(mutex_); + return static_cast(histogram_->total_count); +} + +size_t Histogram::Exceeds() const { + RwLock::ScopedReadLock lock(mutex_); + return exceeds_; } int64_t Histogram::Min() const { - Mutex::ScopedLock lock(mutex_); + RwLock::ScopedReadLock lock(mutex_); return hdr_min(histogram_.get()); } int64_t Histogram::Max() const { - Mutex::ScopedLock lock(mutex_); + RwLock::ScopedReadLock lock(mutex_); return hdr_max(histogram_.get()); } double Histogram::Mean() const { - Mutex::ScopedLock lock(mutex_); + RwLock::ScopedReadLock lock(mutex_); return hdr_mean(histogram_.get()); } double Histogram::Stddev() const { - Mutex::ScopedLock lock(mutex_); + RwLock::ScopedReadLock lock(mutex_); return hdr_stddev(histogram_.get()); } int64_t Histogram::Percentile(double percentile) const { - Mutex::ScopedLock lock(mutex_); + RwLock::ScopedReadLock lock(mutex_); CHECK_GT(percentile, 0); CHECK_LE(percentile, 100); return hdr_value_at_percentile(histogram_.get(), percentile); } template -void Histogram::Percentiles(Iterator&& fn) { - Mutex::ScopedLock lock(mutex_); +void Histogram::Percentiles(Iterator&& fn) const { + RwLock::ScopedReadLock lock(mutex_); hdr_iter iter; hdr_iter_percentile_init(&iter, histogram_.get(), 1); while (hdr_iter_next(&iter)) { @@ -69,37 +92,66 @@ void Histogram::Percentiles(Iterator&& fn) { } } +int64_t Histogram::CountAt(int64_t value) const { + RwLock::ScopedReadLock lock(mutex_); + return hdr_count_at_value(histogram_.get(), value); +} + +bool Histogram::RecordCorrected(int64_t value, int64_t expected_interval) { + RwLock::ScopedWriteLock lock(mutex_); + bool recorded = + hdr_record_corrected_value(histogram_.get(), value, expected_interval); + if (!recorded) exceeds_++; + return recorded; +} + bool Histogram::Record(int64_t value) { - Mutex::ScopedLock lock(mutex_); + RwLock::ScopedWriteLock lock(mutex_); bool recorded = hdr_record_value(histogram_.get(), value); - if (!recorded) - exceeds_++; - else - count_++; + if (!recorded) exceeds_++; return recorded; } uint64_t Histogram::RecordDelta() { - Mutex::ScopedLock lock(mutex_); + RwLock::ScopedWriteLock lock(mutex_); uint64_t time = uv_hrtime(); int64_t delta = 0; if (prev_ > 0) { CHECK_GE(time, prev_); delta = time - prev_; - if (hdr_record_value(histogram_.get(), delta)) - count_++; - else - exceeds_++; + if (!hdr_record_value(histogram_.get(), delta)) exceeds_++; } prev_ = time; return delta; } size_t Histogram::GetMemorySize() const { - Mutex::ScopedLock lock(mutex_); + RwLock::ScopedReadLock lock(mutex_); return hdr_get_memory_size(histogram_.get()); } +template +void Histogram::LinearBuckets(int64_t step_size, Iterator&& fn) const { + RwLock::ScopedReadLock lock(mutex_); + hdr_iter iter; + hdr_iter_linear_init(&iter, histogram_.get(), step_size); + while (hdr_iter_next(&iter)) { + fn(iter.value, iter.specifics.linear.count_added_in_this_iteration_step); + } +} + +template +void Histogram::LogBuckets(int64_t first_bucket, + double log_base, + Iterator&& fn) const { + RwLock::ScopedReadLock lock(mutex_); + hdr_iter iter; + hdr_iter_log_init(&iter, histogram_.get(), first_bucket, log_base); + while (hdr_iter_next(&iter)) { + fn(iter.value, iter.specifics.log.count_added_in_this_iteration_step); + } +} + } // namespace node #endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS diff --git a/src/histogram.cc b/src/histogram.cc index 5dd82c305bf7..3aa451685e86 100644 --- a/src/histogram.cc +++ b/src/histogram.cc @@ -7,6 +7,8 @@ #include "node_external_reference.h" #include "util.h" +#include + namespace node { using v8::BigInt; @@ -52,6 +54,169 @@ void Histogram::MemoryInfo(MemoryTracker* tracker) const { tracker->TrackFieldWithSize("histogram", GetMemorySize()); } +bool Histogram::IsCompatible(const Histogram& other) const { + return histogram_->counts_len == other.histogram_->counts_len && + histogram_->lowest_discernible_value == + other.histogram_->lowest_discernible_value && + histogram_->highest_trackable_value == + other.histogram_->highest_trackable_value && + histogram_->significant_figures == + other.histogram_->significant_figures; +} + +double Histogram::Cdf(int64_t value) const { + RwLock::ScopedReadLock lock(mutex_); + int64_t total = histogram_->total_count; + if (total == 0) return 0.0; + + hdr_iter iter; + hdr_iter_init(&iter, histogram_.get()); + while (hdr_iter_next(&iter)) { + if (iter.highest_equivalent_value >= value) { + return static_cast(iter.cumulative_count) / + static_cast(total); + } + // All recorded data accounted for; remaining buckets are empty. + if (iter.cumulative_count >= total) break; + } + return 1.0; +} + +double Histogram::Skewness() const { + RwLock::ScopedReadLock lock(mutex_); + int64_t total = histogram_->total_count; + if (total < 3) return 0.0; + + // Compute mean in one pass, then variance and skewness in a second + // pass. This avoids calling hdr_stddev (which internally recomputes + // hdr_mean), reducing the total from 4 iterations to 2. + double mean = hdr_mean(histogram_.get()); + + double m2 = 0.0; + double m3 = 0.0; + hdr_iter iter; + hdr_iter_recorded_init(&iter, histogram_.get()); + while (hdr_iter_next(&iter)) { + double dev = static_cast(hdr_median_equivalent_value( + histogram_.get(), iter.value)) - + mean; + double d2 = dev * dev; + m2 += static_cast(iter.count) * d2; + m3 += static_cast(iter.count) * d2 * dev; + } + + double n = static_cast(total); + double variance = m2 / n; + if (variance == 0.0) return 0.0; + double s3 = variance * std::sqrt(variance); // stddev^3 + return (m3 / n) / s3; +} + +double Histogram::Kurtosis() const { + RwLock::ScopedReadLock lock(mutex_); + int64_t total = histogram_->total_count; + if (total < 4) return 0.0; + + // Same single-pass approach as Skewness: compute mean first, then + // variance and excess kurtosis together in one iteration. + double mean = hdr_mean(histogram_.get()); + + double m2 = 0.0; + double m4 = 0.0; + hdr_iter iter; + hdr_iter_recorded_init(&iter, histogram_.get()); + while (hdr_iter_next(&iter)) { + double dev = static_cast(hdr_median_equivalent_value( + histogram_.get(), iter.value)) - + mean; + double d2 = dev * dev; + m2 += static_cast(iter.count) * d2; + m4 += static_cast(iter.count) * d2 * d2; + } + + double n = static_cast(total); + double variance = m2 / n; + if (variance == 0.0) return 0.0; + double s4 = variance * variance; // stddev^4 + return (m4 / n) / s4 - 3.0; +} + +double Histogram::Subtract(const Histogram& other) { + auto do_subtract = [&]() -> double { + int64_t dropped = 0; + int32_t len = + std::min(histogram_->counts_len, other.histogram_->counts_len); + for (int32_t i = 0; i < len; i++) { + int64_t count = histogram_->counts[i] - other.histogram_->counts[i]; + if (count < 0) { + dropped += -count; + count = 0; + } + histogram_->counts[i] = count; + } + hdr_reset_internal_counters(histogram_.get()); + exceeds_ = (exceeds_ > other.exceeds_) ? exceeds_ - other.exceeds_ : 0; + return static_cast(dropped); + }; + + if (this == &other) { + RwLock::ScopedWriteLock lock(mutex_); + return do_subtract(); + } + + if (this < &other) { + RwLock::ScopedWriteLock lock1(mutex_); + RwLock::ScopedReadLock lock2(other.mutex_); + return do_subtract(); + } + + RwLock::ScopedReadLock lock1(other.mutex_); + RwLock::ScopedWriteLock lock2(mutex_); + return do_subtract(); +} + +double Histogram::KsTest(const Histogram& other) const { + auto do_ks = [&]() -> double { + int64_t n1 = histogram_->total_count; + int64_t n2 = other.histogram_->total_count; + if (n1 == 0 || n2 == 0) return 0.0; + + double max_d = 0.0; + int64_t cum1 = 0, cum2 = 0; + int32_t len = + std::max(histogram_->counts_len, other.histogram_->counts_len); + + for (int32_t i = 0; i < len; i++) { + if (i < histogram_->counts_len) cum1 += histogram_->counts[i]; + if (i < other.histogram_->counts_len) cum2 += other.histogram_->counts[i]; + double cdf1 = static_cast(cum1) / static_cast(n1); + double cdf2 = static_cast(cum2) / static_cast(n2); + double d = cdf1 > cdf2 ? cdf1 - cdf2 : cdf2 - cdf1; + if (d > max_d) max_d = d; + } + return max_d; + }; + + if (this == &other) return 0.0; + + if (this < &other) { + RwLock::ScopedReadLock lock1(mutex_); + RwLock::ScopedReadLock lock2(other.mutex_); + return do_ks(); + } + + RwLock::ScopedReadLock lock1(other.mutex_); + RwLock::ScopedReadLock lock2(mutex_); + return do_ks(); +} + +void Histogram::PercentilesAt(const double* percentiles, + int64_t* values, + size_t length) const { + RwLock::ScopedReadLock lock(mutex_); + hdr_value_at_percentiles(histogram_.get(), percentiles, values, length); +} + HistogramImpl::HistogramImpl(const Histogram::Options& options) : histogram_(new Histogram(options)) {} @@ -74,6 +239,14 @@ CFunction HistogramImpl::fast_get_stddev_( CFunction::Make(&HistogramImpl::FastGetStddev)); CFunction HistogramImpl::fast_get_percentile_( CFunction::Make(&HistogramImpl::FastGetPercentile)); +CFunction HistogramImpl::fast_get_skewness_( + CFunction::Make(&HistogramImpl::FastGetSkewness)); +CFunction HistogramImpl::fast_get_kurtosis_( + CFunction::Make(&HistogramImpl::FastGetKurtosis)); +CFunction HistogramImpl::fast_get_cdf_( + CFunction::Make(&HistogramImpl::FastGetCdf)); +CFunction HistogramImpl::fast_get_count_at_( + CFunction::Make(&HistogramImpl::FastGetCountAt)); CFunction HistogramBase::fast_record_( CFunction::Make(&HistogramBase::FastRecord)); CFunction HistogramBase::fast_record_delta_( @@ -112,6 +285,17 @@ void HistogramImpl::AddMethods(Isolate* isolate, Local tmpl) { isolate, instance, "stddev", GetStddev, &fast_get_stddev_); SetFastMethodNoSideEffect( isolate, instance, "percentile", GetPercentile, &fast_get_percentile_); + SetFastMethodNoSideEffect( + isolate, instance, "skewness", GetSkewness, &fast_get_skewness_); + SetFastMethodNoSideEffect( + isolate, instance, "kurtosis", GetKurtosis, &fast_get_kurtosis_); + SetFastMethodNoSideEffect(isolate, instance, "cdf", GetCdf, &fast_get_cdf_); + SetFastMethodNoSideEffect( + isolate, instance, "countAt", GetCountAt, &fast_get_count_at_); + SetProtoMethodNoSideEffect(isolate, tmpl, "ksTest", GetKsTest); + SetProtoMethodNoSideEffect(isolate, tmpl, "percentilesAt", GetPercentilesAt); + SetProtoMethodNoSideEffect(isolate, tmpl, "linearBuckets", GetLinearBuckets); + SetProtoMethodNoSideEffect(isolate, tmpl, "logBuckets", GetLogBuckets); SetFastMethod(isolate, instance, "reset", DoReset, &fast_reset_); } @@ -142,6 +326,18 @@ void HistogramImpl::RegisterExternalReferences( registry->Register(fast_get_exceeds_); registry->Register(fast_get_stddev_); registry->Register(fast_get_percentile_); + registry->Register(GetSkewness); + registry->Register(GetKurtosis); + registry->Register(GetCdf); + registry->Register(GetCountAt); + registry->Register(GetKsTest); + registry->Register(GetPercentilesAt); + registry->Register(GetLinearBuckets); + registry->Register(GetLogBuckets); + registry->Register(fast_get_skewness_); + registry->Register(fast_get_kurtosis_); + registry->Register(fast_get_cdf_); + registry->Register(fast_get_count_at_); is_registered = true; } @@ -223,6 +419,39 @@ void HistogramBase::Add(const FunctionCallbackInfo& args) { args.GetReturnValue().Set(count); } +void HistogramBase::Subtract(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + HistogramBase* histogram; + ASSIGN_OR_RETURN_UNWRAP(&histogram, args.This()); + + CHECK(GetConstructorTemplate(env->isolate_data())->HasInstance(args[0])); + HistogramBase* other; + ASSIGN_OR_RETURN_UNWRAP(&other, args[0]); + + double dropped = (*histogram)->Subtract(*(other->histogram())); + args.GetReturnValue().Set(dropped); +} + +void HistogramBase::RecordCorrected(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + CHECK_IMPLIES(!args[0]->IsNumber(), args[0]->IsBigInt()); + CHECK_IMPLIES(!args[1]->IsNumber(), args[1]->IsBigInt()); + bool lossless = true; + int64_t value = args[0]->IsBigInt() + ? args[0].As()->Int64Value(&lossless) + : static_cast(args[0].As()->Value()); + if (!lossless || value < 1) + return THROW_ERR_OUT_OF_RANGE(env, "value is out of range"); + int64_t expected_interval = + args[1]->IsBigInt() ? args[1].As()->Int64Value(&lossless) + : static_cast(args[1].As()->Value()); + if (!lossless || expected_interval < 1) + return THROW_ERR_OUT_OF_RANGE(env, "expected_interval is out of range"); + HistogramBase* histogram; + ASSIGN_OR_RETURN_UNWRAP(&histogram, args.This()); + (*histogram)->RecordCorrected(value, expected_interval); +} + BaseObjectPtr HistogramBase::Create( Environment* env, const Histogram::Options& options) { @@ -261,18 +490,22 @@ void HistogramBase::New(const FunctionCallbackInfo& args) { int64_t lowest = 1; int64_t highest = std::numeric_limits::max(); - bool lossless_ignored; + bool lossless = true; if (args[0]->IsNumber()) { lowest = args[0].As()->Value(); } else if (args[0]->IsBigInt()) { - lowest = args[0].As()->Int64Value(&lossless_ignored); + lowest = args[0].As()->Int64Value(&lossless); + if (!lossless) + return THROW_ERR_OUT_OF_RANGE(env, "options.lowest is out of range"); } if (args[1]->IsNumber()) { highest = args[1].As()->Value(); } else if (args[1]->IsBigInt()) { - highest = args[1].As()->Int64Value(&lossless_ignored); + highest = args[1].As()->Int64Value(&lossless); + if (!lossless) + return THROW_ERR_OUT_OF_RANGE(env, "options.highest is out of range"); } int32_t figures = args[2].As()->Value(); @@ -295,6 +528,8 @@ Local HistogramBase::GetConstructorTemplate( SetFastMethod( isolate, instance, "recordDelta", RecordDelta, &fast_record_delta_); SetProtoMethod(isolate, tmpl, "add", Add); + SetProtoMethod(isolate, tmpl, "subtract", Subtract); + SetProtoMethod(isolate, tmpl, "recordCorrected", RecordCorrected); HistogramImpl::AddMethods(isolate, tmpl); isolate_data->set_histogram_ctor_template(tmpl); } @@ -305,8 +540,10 @@ void HistogramBase::RegisterExternalReferences( ExternalReferenceRegistry* registry) { registry->Register(New); registry->Register(Add); + registry->Register(Subtract); registry->Register(Record); registry->Register(RecordDelta); + registry->Register(RecordCorrected); registry->Register(fast_record_); registry->Register(fast_record_delta_); HistogramImpl::RegisterExternalReferences(registry); @@ -345,11 +582,7 @@ Local IntervalHistogram::GetConstructorTemplate( tmpl = NewFunctionTemplate(isolate, nullptr); tmpl->Inherit(HandleWrap::GetConstructorTemplate(env)); tmpl->SetClassName(FIXED_ONE_BYTE_STRING(isolate, "Histogram")); - auto instance = tmpl->InstanceTemplate(); - instance->SetInternalFieldCount(IntervalHistogram::kInternalFieldCount); - HistogramImpl::AddMethods(isolate, tmpl); - SetFastMethod(isolate, instance, "start", Start, &fast_start_); - SetFastMethod(isolate, instance, "stop", Stop, &fast_stop_); + InitTemplate(isolate, tmpl, IntervalHistogram::kInternalFieldCount); env->set_intervalhistogram_constructor_template(tmpl); } return tmpl; @@ -364,21 +597,16 @@ void IntervalHistogram::RegisterExternalReferences( HistogramImpl::RegisterExternalReferences(registry); } -IntervalHistogram::IntervalHistogram( - Environment* env, - Local wrap, - AsyncWrap::ProviderType type, - int32_t interval, - std::function on_interval, - const Histogram::Options& options) - : HandleWrap( - env, - wrap, - reinterpret_cast(&timer_), - type), +IntervalHistogram::IntervalHistogram(Environment* env, + Local wrap, + AsyncWrap::ProviderType type, + int32_t interval, + OnInterval on_interval, + const Histogram::Options& options) + : HandleWrap(env, wrap, reinterpret_cast(&timer_), type), HistogramImpl(options), interval_(interval), - on_interval_(std::move(on_interval)) { + on_interval_(on_interval) { MakeWeak(); wrap->SetAlignedPointerInInternalField( HistogramImpl::InternalFields::kImplField, @@ -390,8 +618,9 @@ IntervalHistogram::IntervalHistogram( BaseObjectPtr IntervalHistogram::Create( Environment* env, int32_t interval, - std::function on_interval, - const Histogram::Options& options) { + OnInterval on_interval, + const Histogram::Options& options, + AsyncWrap::ProviderType type) { Local obj; if (!GetConstructorTemplate(env) ->InstanceTemplate() @@ -400,12 +629,7 @@ BaseObjectPtr IntervalHistogram::Create( } return MakeBaseObject( - env, - obj, - AsyncWrap::PROVIDER_ELDHISTOGRAM, - interval, - std::move(on_interval), - options); + env, obj, type, interval, on_interval, options); } void IntervalHistogram::TimerCB(uv_timer_t* handle) { @@ -436,19 +660,11 @@ void IntervalHistogram::OnStop() { uv_timer_stop(&timer_); } -void IntervalHistogram::Start(const FunctionCallbackInfo& args) { - StartHandleHistogram(args.This(), args[0]->IsTrue()); -} - void IntervalHistogram::FastStart(Local receiver, bool reset) { TRACK_V8_FAST_API_CALL("histogram.start"); StartHandleHistogram(receiver, reset); } -void IntervalHistogram::Stop(const FunctionCallbackInfo& args) { - StopHandleHistogram(args.This()); -} - void IntervalHistogram::FastStop(Local receiver) { TRACK_V8_FAST_API_CALL("histogram.stop"); StopHandleHistogram(receiver); @@ -462,11 +678,7 @@ Local IterationHistogram::GetConstructorTemplate( tmpl = NewFunctionTemplate(isolate, nullptr); tmpl->Inherit(HandleWrap::GetConstructorTemplate(env)); tmpl->SetClassName(FIXED_ONE_BYTE_STRING(isolate, "Histogram")); - auto instance = tmpl->InstanceTemplate(); - instance->SetInternalFieldCount(IterationHistogram::kInternalFieldCount); - HistogramImpl::AddMethods(isolate, tmpl); - SetFastMethod(isolate, instance, "start", Start, &fast_start_); - SetFastMethod(isolate, instance, "stop", Stop, &fast_stop_); + InitTemplate(isolate, tmpl, IterationHistogram::kInternalFieldCount); env->set_iterationhistogram_constructor_template(tmpl); } return tmpl; @@ -497,11 +709,12 @@ IterationHistogram::IterationHistogram(Environment* env, uv_prepare_init(env->event_loop(), &prepare_handle_); uv_unref(reinterpret_cast(&check_handle_)); uv_unref(reinterpret_cast(&prepare_handle_)); - prepare_handle_.data = this; } BaseObjectPtr IterationHistogram::Create( - Environment* env, const Histogram::Options& options) { + Environment* env, + const Histogram::Options& options, + AsyncWrap::ProviderType type) { Local obj; if (!GetConstructorTemplate(env) ->InstanceTemplate() @@ -510,12 +723,12 @@ BaseObjectPtr IterationHistogram::Create( return nullptr; } - return MakeBaseObject( - env, obj, AsyncWrap::PROVIDER_ELDHISTOGRAM, options); + return MakeBaseObject(env, obj, type, options); } void IterationHistogram::PrepareCB(uv_prepare_t* handle) { - IterationHistogram* self = static_cast(handle->data); + IterationHistogram* self = + ContainerOf(&IterationHistogram::prepare_handle_, handle); if (!self->enabled_) return; self->prepare_time_ = uv_hrtime(); self->timeout_ = uv_backend_timeout(handle->loop); @@ -572,19 +785,11 @@ void IterationHistogram::Close(Local close_callback) { uv_close(reinterpret_cast(&prepare_handle_), nullptr); } -void IterationHistogram::Start(const FunctionCallbackInfo& args) { - StartHandleHistogram(args.This(), args[0]->IsTrue()); -} - void IterationHistogram::FastStart(Local receiver, bool reset) { TRACK_V8_FAST_API_CALL("histogram.eventLoopDelay.start"); StartHandleHistogram(receiver, reset); } -void IterationHistogram::Stop(const FunctionCallbackInfo& args) { - StopHandleHistogram(args.This()); -} - void IterationHistogram::FastStop(Local receiver) { TRACK_V8_FAST_API_CALL("histogram.eventLoopDelay.stop"); StopHandleHistogram(receiver); @@ -670,12 +875,19 @@ void HistogramImpl::GetPercentiles(const FunctionCallbackInfo& args) { HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This()); CHECK(args[0]->IsMap()); Local map = args[0].As(); - (*histogram)->Percentiles([map, env](double key, int64_t value) { - USE(map->Set( - env->context(), - Number::New(env->isolate(), key), - Number::New(env->isolate(), static_cast(value)))); + + // Collect percentile data under the histogram lock, then populate the + // V8 Map after releasing it to avoid V8 allocations under the lock. + std::vector> entries; + (*histogram)->Percentiles([&entries](double key, int64_t value) { + entries.emplace_back(key, value); }); + for (const auto& entry : entries) { + USE(map->Set( + env->context(), + Number::New(env->isolate(), entry.first), + Number::New(env->isolate(), static_cast(entry.second)))); + } } void HistogramImpl::GetPercentilesBigInt( @@ -684,12 +896,16 @@ void HistogramImpl::GetPercentilesBigInt( HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This()); CHECK(args[0]->IsMap()); Local map = args[0].As(); - (*histogram)->Percentiles([map, env](double key, int64_t value) { - USE(map->Set( - env->context(), - Number::New(env->isolate(), key), - BigInt::New(env->isolate(), value))); + + std::vector> entries; + (*histogram)->Percentiles([&entries](double key, int64_t value) { + entries.emplace_back(key, value); }); + for (const auto& entry : entries) { + USE(map->Set(env->context(), + Number::New(env->isolate(), entry.first), + BigInt::New(env->isolate(), entry.second))); + } } void HistogramImpl::DoReset(const FunctionCallbackInfo& args) { @@ -746,6 +962,129 @@ double HistogramImpl::FastGetPercentile(Local receiver, return static_cast((*histogram)->Percentile(percentile)); } +void HistogramImpl::GetSkewness(const FunctionCallbackInfo& args) { + HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This()); + args.GetReturnValue().Set((*histogram)->Skewness()); +} + +double HistogramImpl::FastGetSkewness(Local receiver) { + TRACK_V8_FAST_API_CALL("histogram.skewness"); + HistogramImpl* histogram = HistogramImpl::FromJSObject(receiver); + return (*histogram)->Skewness(); +} + +void HistogramImpl::GetKurtosis(const FunctionCallbackInfo& args) { + HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This()); + args.GetReturnValue().Set((*histogram)->Kurtosis()); +} + +double HistogramImpl::FastGetKurtosis(Local receiver) { + TRACK_V8_FAST_API_CALL("histogram.kurtosis"); + HistogramImpl* histogram = HistogramImpl::FromJSObject(receiver); + return (*histogram)->Kurtosis(); +} + +void HistogramImpl::GetCdf(const FunctionCallbackInfo& args) { + HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This()); + CHECK(args[0]->IsNumber()); + int64_t value = static_cast(args[0].As()->Value()); + args.GetReturnValue().Set((*histogram)->Cdf(value)); +} + +double HistogramImpl::FastGetCdf(Local receiver, const int64_t value) { + TRACK_V8_FAST_API_CALL("histogram.cdf"); + HistogramImpl* histogram = HistogramImpl::FromJSObject(receiver); + return (*histogram)->Cdf(value); +} + +void HistogramImpl::GetCountAt(const FunctionCallbackInfo& args) { + HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This()); + CHECK(args[0]->IsNumber()); + int64_t value = static_cast(args[0].As()->Value()); + double count = static_cast((*histogram)->CountAt(value)); + args.GetReturnValue().Set(count); +} + +double HistogramImpl::FastGetCountAt(Local receiver, + const int64_t value) { + TRACK_V8_FAST_API_CALL("histogram.countAt"); + HistogramImpl* histogram = HistogramImpl::FromJSObject(receiver); + return static_cast((*histogram)->CountAt(value)); +} + +void HistogramImpl::GetKsTest(const FunctionCallbackInfo& args) { + HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This()); + HistogramImpl* other = HistogramImpl::FromJSObject(args[0]); + args.GetReturnValue().Set((*histogram)->KsTest(*(other->histogram()))); +} + +void HistogramImpl::GetPercentilesAt(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This()); + CHECK(args[0]->IsMap()); + Local map = args[0].As(); + CHECK(args[1]->IsFloat64Array()); + Local input = args[1].As(); + size_t length = input->Length(); + auto backing = input->Buffer()->GetBackingStore(); + double* percentiles = reinterpret_cast( + static_cast(backing->Data()) + input->ByteOffset()); + + std::vector values(length); + (*histogram)->PercentilesAt(percentiles, values.data(), length); + + for (size_t i = 0; i < length; i++) { + USE(map->Set(env->context(), + Number::New(env->isolate(), percentiles[i]), + Number::New(env->isolate(), static_cast(values[i])))); + } +} + +void HistogramImpl::GetLinearBuckets(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This()); + CHECK(args[0]->IsNumber()); + CHECK(args[1]->IsMap()); + int64_t step_size = static_cast(args[0].As()->Value()); + Local map = args[1].As(); + + std::vector> entries; + (*histogram) + ->LinearBuckets(step_size, [&entries](int64_t value, int64_t count) { + entries.emplace_back(value, count); + }); + for (const auto& entry : entries) { + USE(map->Set( + env->context(), + Number::New(env->isolate(), static_cast(entry.first)), + Number::New(env->isolate(), static_cast(entry.second)))); + } +} + +void HistogramImpl::GetLogBuckets(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + HistogramImpl* histogram = HistogramImpl::FromJSObject(args.This()); + CHECK(args[0]->IsNumber()); + CHECK(args[1]->IsNumber()); + CHECK(args[2]->IsMap()); + int64_t first_bucket = static_cast(args[0].As()->Value()); + double log_base = args[1].As()->Value(); + Local map = args[2].As(); + + std::vector> entries; + (*histogram) + ->LogBuckets( + first_bucket, log_base, [&entries](int64_t value, int64_t count) { + entries.emplace_back(value, count); + }); + for (const auto& entry : entries) { + USE(map->Set( + env->context(), + Number::New(env->isolate(), static_cast(entry.first)), + Number::New(env->isolate(), static_cast(entry.second)))); + } +} + HistogramImpl* HistogramImpl::FromJSObject(Local value) { auto obj = value.As(); DCHECK_GE(obj->InternalFieldCount(), HistogramImpl::kInternalFieldCount); diff --git a/src/histogram.h b/src/histogram.h index b9f968e8347c..5fbffa2a4879 100644 --- a/src/histogram.h +++ b/src/histogram.h @@ -11,10 +11,7 @@ #include "uv.h" #include "v8.h" -#include #include -#include -#include namespace node { @@ -45,7 +42,7 @@ class Histogram : public MemoryRetainer { inline double Mean() const; inline double Stddev() const; inline int64_t Percentile(double percentile) const; - inline size_t Exceeds() const { return exceeds_; } + inline size_t Exceeds() const; inline size_t Count() const; inline uint64_t RecordDelta(); @@ -55,10 +52,31 @@ class Histogram : public MemoryRetainer { // Iterator is a function type that takes two doubles as argument, one for // percentile and one for the value at that percentile. template - inline void Percentiles(Iterator&& fn); + inline void Percentiles(Iterator&& fn) const; inline size_t GetMemorySize() const; + // Analysis methods + inline int64_t CountAt(int64_t value) const; + double Cdf(int64_t value) const; + double Skewness() const; + double Kurtosis() const; + double KsTest(const Histogram& other) const; + double Subtract(const Histogram& other); + void PercentilesAt(const double* percentiles, + int64_t* values, + size_t length) const; + + inline bool RecordCorrected(int64_t value, int64_t expected_interval); + + template + void LinearBuckets(int64_t step_size, Iterator&& fn) const; + + template + void LogBuckets(int64_t first_bucket, double log_base, Iterator&& fn) const; + + bool IsCompatible(const Histogram& other) const; + void MemoryInfo(MemoryTracker* tracker) const override; SET_MEMORY_INFO_NAME(Histogram) SET_SELF_SIZE(Histogram) @@ -68,8 +86,7 @@ class Histogram : public MemoryRetainer { HistogramPointer histogram_; uint64_t prev_ = 0; size_t exceeds_ = 0; - size_t count_ = 0; - Mutex mutex_; + RwLock mutex_; }; class HistogramImpl { @@ -106,6 +123,15 @@ class HistogramImpl { static void GetPercentilesBigInt( const v8::FunctionCallbackInfo& args); + static void GetSkewness(const v8::FunctionCallbackInfo& args); + static void GetKurtosis(const v8::FunctionCallbackInfo& args); + static void GetCdf(const v8::FunctionCallbackInfo& args); + static void GetCountAt(const v8::FunctionCallbackInfo& args); + static void GetKsTest(const v8::FunctionCallbackInfo& args); + static void GetPercentilesAt(const v8::FunctionCallbackInfo& args); + static void GetLinearBuckets(const v8::FunctionCallbackInfo& args); + static void GetLogBuckets(const v8::FunctionCallbackInfo& args); + static void FastReset(v8::Local receiver); static double FastGetCount(v8::Local receiver); static double FastGetMin(v8::Local receiver); @@ -115,6 +141,11 @@ class HistogramImpl { static double FastGetStddev(v8::Local receiver); static double FastGetPercentile(v8::Local receiver, const double percentile); + static double FastGetSkewness(v8::Local receiver); + static double FastGetKurtosis(v8::Local receiver); + static double FastGetCdf(v8::Local receiver, const int64_t value); + static double FastGetCountAt(v8::Local receiver, + const int64_t value); static void AddMethods(v8::Isolate* isolate, v8::Local tmpl); @@ -134,6 +165,10 @@ class HistogramImpl { static v8::CFunction fast_get_exceeds_; static v8::CFunction fast_get_stddev_; static v8::CFunction fast_get_percentile_; + static v8::CFunction fast_get_skewness_; + static v8::CFunction fast_get_kurtosis_; + static v8::CFunction fast_get_cdf_; + static v8::CFunction fast_get_count_at_; }; class HistogramBase final : public BaseObject, public HistogramImpl { @@ -165,7 +200,9 @@ class HistogramBase final : public BaseObject, public HistogramImpl { static void Record(const v8::FunctionCallbackInfo& args); static void RecordDelta(const v8::FunctionCallbackInfo& args); + static void RecordCorrected(const v8::FunctionCallbackInfo& args); static void Add(const v8::FunctionCallbackInfo& args); + static void Subtract(const v8::FunctionCallbackInfo& args); static void FastRecord(v8::Local receiver, const int64_t value); static void FastRecordDelta(v8::Local receiver); @@ -211,17 +248,48 @@ class HistogramBase final : public BaseObject, public HistogramImpl { static v8::CFunction fast_record_delta_; }; -class IntervalHistogram final : public HandleWrap, public HistogramImpl { +// CRTP mixin for HandleWrap-based histograms with start/stop support. +// Provides: StartFlags enum, Start/Stop slow-path handlers, enabled_ flag, +// and InitTemplate (shared GetConstructorTemplate body). +// Derived must provide: fast_start_, fast_stop_ (static CFunction), +// FastStart, FastStop, OnStart, OnStop. +template +class HandleHistogramMixin { + public: + enum class StartFlags { NONE, RESET }; + + static void Start(const v8::FunctionCallbackInfo& args) { + StartHandleHistogram(args.This(), args[0]->IsTrue()); + } + + static void Stop(const v8::FunctionCallbackInfo& args) { + StopHandleHistogram(args.This()); + } + + protected: + static void InitTemplate(v8::Isolate* isolate, + v8::Local tmpl, + uint32_t internal_field_count) { + auto instance = tmpl->InstanceTemplate(); + instance->SetInternalFieldCount(internal_field_count); + HistogramImpl::AddMethods(isolate, tmpl); + SetFastMethod(isolate, instance, "start", Start, &Derived::fast_start_); + SetFastMethod(isolate, instance, "stop", Stop, &Derived::fast_stop_); + } + + bool enabled_ = false; +}; + +class IntervalHistogram final : public HandleWrap, + public HistogramImpl, + public HandleHistogramMixin { public: enum InternalFields { kInternalFieldCount = std::max( HandleWrap::kInternalFieldCount, HistogramImpl::kInternalFieldCount), }; - enum class StartFlags { - NONE, - RESET - }; + using OnInterval = void (*)(Histogram&); static void RegisterExternalReferences(ExternalReferenceRegistry* registry); @@ -231,19 +299,16 @@ class IntervalHistogram final : public HandleWrap, public HistogramImpl { static BaseObjectPtr Create( Environment* env, int32_t interval, - std::function on_interval, - const Histogram::Options& options); - - IntervalHistogram( - Environment* env, - v8::Local wrap, - AsyncWrap::ProviderType type, - int32_t interval, - std::function on_interval, - const Histogram::Options& options = Histogram::Options {}); + OnInterval on_interval, + const Histogram::Options& options, + AsyncWrap::ProviderType type = AsyncWrap::PROVIDER_ELDHISTOGRAM); - static void Start(const v8::FunctionCallbackInfo& args); - static void Stop(const v8::FunctionCallbackInfo& args); + IntervalHistogram(Environment* env, + v8::Local wrap, + AsyncWrap::ProviderType type, + int32_t interval, + OnInterval on_interval, + const Histogram::Options& options = Histogram::Options{}); static void FastStart(v8::Local receiver, bool reset); static void FastStop(v8::Local receiver); @@ -262,45 +327,45 @@ class IntervalHistogram final : public HandleWrap, public HistogramImpl { void OnStart(StartFlags flags = StartFlags::RESET); void OnStop(); + friend class HandleHistogramMixin; template friend void StartHandleHistogram(v8::Local, bool); template friend void StopHandleHistogram(v8::Local); - bool enabled_ = false; int32_t interval_ = 0; - std::function on_interval_; + OnInterval on_interval_ = nullptr; uv_timer_t timer_; static v8::CFunction fast_start_; static v8::CFunction fast_stop_; }; -class IterationHistogram final : public HandleWrap, public HistogramImpl { +class IterationHistogram final + : public HandleWrap, + public HistogramImpl, + public HandleHistogramMixin { public: enum InternalFields { kInternalFieldCount = std::max( HandleWrap::kInternalFieldCount, HistogramImpl::kInternalFieldCount), }; - enum class StartFlags { NONE, RESET }; - static void RegisterExternalReferences(ExternalReferenceRegistry* registry); static v8::Local GetConstructorTemplate( Environment* env); static BaseObjectPtr Create( - Environment* env, const Histogram::Options& options); + Environment* env, + const Histogram::Options& options, + AsyncWrap::ProviderType type = AsyncWrap::PROVIDER_ELDHISTOGRAM); IterationHistogram(Environment* env, v8::Local wrap, AsyncWrap::ProviderType type, const Histogram::Options& options = Histogram::Options{}); - static void Start(const v8::FunctionCallbackInfo& args); - static void Stop(const v8::FunctionCallbackInfo& args); - static void FastStart(v8::Local receiver, bool reset); static void FastStop(v8::Local receiver); @@ -322,12 +387,12 @@ class IterationHistogram final : public HandleWrap, public HistogramImpl { void OnStart(StartFlags flags = StartFlags::RESET); void OnStop(); + friend class HandleHistogramMixin; template friend void StartHandleHistogram(v8::Local, bool); template friend void StopHandleHistogram(v8::Local); - bool enabled_ = false; uv_prepare_t prepare_handle_; uv_check_t check_handle_; uint64_t prepare_time_ = 0; diff --git a/test/parallel/test-perf-hooks-histogram-analysis.js b/test/parallel/test-perf-hooks-histogram-analysis.js new file mode 100644 index 000000000000..acc2b5a7eb2d --- /dev/null +++ b/test/parallel/test-perf-hooks-histogram-analysis.js @@ -0,0 +1,501 @@ +// Flags: --expose-internals --no-warnings --allow-natives-syntax +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { createHistogram } = require('perf_hooks'); +const { internalBinding } = require('internal/test/binding'); +const { inspect } = require('util'); + +// --------------------------------------------------------------------------- +// cdf(value) — cumulative distribution function +// --------------------------------------------------------------------------- +{ + const h = createHistogram(); + + // Empty histogram returns 0 + assert.strictEqual(h.cdf(1), 0); + + for (let i = 1; i <= 5; i++) h.record(i); + + // Below min → 0 + assert.strictEqual(h.cdf(0), 0); + + // At or above some values → monotonically increasing + assert.ok(h.cdf(1) > 0); + assert.ok(h.cdf(3) >= h.cdf(1)); + assert.ok(h.cdf(5) >= h.cdf(3)); + + // Well above max → 1.0 + assert.strictEqual(h.cdf(1000000), 1.0); + + // Validation + assert.throws(() => h.cdf('hello'), { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => h.cdf(), { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => h.cdf(undefined), { code: 'ERR_INVALID_ARG_TYPE' }); +} + +// --------------------------------------------------------------------------- +// ccdf(value) — complementary CDF = 1 - cdf +// --------------------------------------------------------------------------- +{ + const h = createHistogram(); + + // Empty: cdf=0 so ccdf=1 + assert.strictEqual(h.ccdf(1), 1); + + for (let i = 1; i <= 5; i++) h.record(i); + + // CCDF + CDF === 1 for all values + for (const v of [0, 1, 3, 5, 1000000]) { + const sum = h.ccdf(v) + h.cdf(v); + assert.ok(Math.abs(sum - 1) < 1e-10, `ccdf(${v})+cdf(${v})=${sum}`); + } + + // Well above max → 0 + assert.strictEqual(h.ccdf(1000000), 0); + + // Validation + assert.throws(() => h.ccdf('hello'), { code: 'ERR_INVALID_ARG_TYPE' }); +} + +// --------------------------------------------------------------------------- +// countAt(value) — count in equivalent bucket +// --------------------------------------------------------------------------- +{ + const h = createHistogram(); + + // Empty → 0 + assert.strictEqual(h.countAt(1), 0); + + h.record(1); + h.record(1); + h.record(1); + h.record(100); + + assert.strictEqual(h.countAt(1), 3); + assert.strictEqual(h.countAt(100), 1); + assert.strictEqual(h.countAt(999999), 0); + + // Validation + assert.throws(() => h.countAt('hello'), { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => h.countAt(), { code: 'ERR_INVALID_ARG_TYPE' }); +} + +// --------------------------------------------------------------------------- +// skewness getter +// --------------------------------------------------------------------------- +{ + const h = createHistogram(); + + // Too few values returns 0 + assert.strictEqual(h.skewness, 0); + h.record(1); + assert.strictEqual(h.skewness, 0); + h.record(2); + assert.strictEqual(h.skewness, 0); + + // With 3+ values, returns a number + h.record(3); + assert.strictEqual(typeof h.skewness, 'number'); + assert.ok(!Number.isNaN(h.skewness)); + + // Right-skewed distribution → positive skewness + const right = createHistogram(); + for (let i = 0; i < 100; i++) right.record(1); + for (let i = 0; i < 10; i++) right.record(10000); + assert.ok(right.skewness > 0); + + // Appears in inspect output + assert.ok(inspect(right, { depth: null }).includes('skewness')); + + // Appears in toJSON + const json = right.toJSON(); + assert.ok('skewness' in json); + assert.strictEqual(typeof json.skewness, 'number'); + + // Uniform distribution: zero stddev → returns 0 + const uniform = createHistogram(); + for (let i = 0; i < 10; i++) uniform.record(1); + assert.strictEqual(uniform.skewness, 0); +} + +// --------------------------------------------------------------------------- +// kurtosis getter +// --------------------------------------------------------------------------- +{ + const h = createHistogram(); + + // Too few values returns 0 + assert.strictEqual(h.kurtosis, 0); + h.record(1); + h.record(2); + h.record(3); + assert.strictEqual(h.kurtosis, 0); + + // With 4+ values, returns a number + h.record(4); + assert.strictEqual(typeof h.kurtosis, 'number'); + assert.ok(!Number.isNaN(h.kurtosis)); + + // Appears in inspect and toJSON + const h2 = createHistogram(); + for (let i = 1; i <= 100; i++) h2.record(i); + assert.ok(inspect(h2, { depth: null }).includes('kurtosis')); + const json = h2.toJSON(); + assert.ok('kurtosis' in json); + assert.strictEqual(typeof json.kurtosis, 'number'); + + // Uniform distribution: zero stddev → returns 0 + const uniform = createHistogram(); + for (let i = 0; i < 10; i++) uniform.record(1); + assert.strictEqual(uniform.kurtosis, 0); +} + +// --------------------------------------------------------------------------- +// ksTest(other) — Kolmogorov-Smirnov D-statistic +// --------------------------------------------------------------------------- +{ + const h1 = createHistogram(); + const h2 = createHistogram(); + + // Both empty → 0 + assert.strictEqual(h1.ksTest(h2), 0); + + // Identical distributions → 0 + for (let i = 1; i <= 100; i++) { h1.record(i); h2.record(i); } + assert.strictEqual(h1.ksTest(h2), 0); + + // Same histogram against itself → 0 + assert.strictEqual(h1.ksTest(h1), 0); + + // Different distributions → D > 0 + const h3 = createHistogram(); + for (let i = 1000; i <= 2000; i++) h3.record(i); + const d = h1.ksTest(h3); + assert.ok(d > 0); + assert.ok(d <= 1); + + // Symmetry: D(a,b) === D(b,a) + assert.strictEqual(h1.ksTest(h3), h3.ksTest(h1)); + + // Completely disjoint → D close to 1 + const hLow = createHistogram(); + const hHigh = createHistogram(); + for (let i = 0; i < 100; i++) hLow.record(1); + for (let i = 0; i < 100; i++) hHigh.record(100000); + assert.ok(hLow.ksTest(hHigh) > 0.9); + + // One empty → 0 + const empty = createHistogram(); + assert.strictEqual(h1.ksTest(empty), 0); + + // Validation: non-histogram throws + assert.throws(() => h1.ksTest('not a histogram'), + { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => h1.ksTest(42), + { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => h1.ksTest({}), + { code: 'ERR_INVALID_ARG_TYPE' }); +} + +// --------------------------------------------------------------------------- +// percentilesAt(percentiles) — batch percentile query +// --------------------------------------------------------------------------- +{ + const h = createHistogram(); + for (let i = 1; i <= 100; i++) h.record(i); + + // Returns a Map + const result = h.percentilesAt([50, 90, 99]); + assert.ok(result instanceof Map); + assert.strictEqual(result.size, 3); + + // Keys are the requested percentiles + assert.ok(result.has(50)); + assert.ok(result.has(90)); + assert.ok(result.has(99)); + + // Values match individual percentile() calls + assert.strictEqual(result.get(50), h.percentile(50)); + assert.strictEqual(result.get(90), h.percentile(90)); + assert.strictEqual(result.get(99), h.percentile(99)); + + // Single element + const single = h.percentilesAt([50]); + assert.strictEqual(single.size, 1); + + // Unsorted input still works (internally sorted) + const unsorted = h.percentilesAt([99, 50, 90]); + assert.strictEqual(unsorted.get(50), h.percentile(50)); + + // Validation + assert.throws(() => h.percentilesAt('not array'), + { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => h.percentilesAt([0]), + { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => h.percentilesAt([101]), + { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => h.percentilesAt([NaN]), + { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => h.percentilesAt([-1]), + { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => h.percentilesAt(['hello']), + { code: 'ERR_INVALID_ARG_TYPE' }); +} + +// --------------------------------------------------------------------------- +// linearBuckets(stepSize) — linearly-spaced bucket iteration +// --------------------------------------------------------------------------- +{ + const h = createHistogram(); + for (let i = 1; i <= 100; i++) h.record(i); + + const buckets = h.linearBuckets(10); + assert.ok(buckets instanceof Map); + assert.ok(buckets.size > 0); + + // All keys and values are numbers + for (const [key, value] of buckets) { + assert.strictEqual(typeof key, 'number'); + assert.strictEqual(typeof value, 'number'); + assert.ok(value >= 0); + } + + // Total count across buckets equals histogram count + let total = 0; + for (const [, count] of buckets) total += count; + assert.strictEqual(total, h.count); + + // Different step sizes produce different bucket counts + const finer = h.linearBuckets(5); + assert.ok(finer.size >= buckets.size); + + // Validation + assert.throws(() => h.linearBuckets(0), { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => h.linearBuckets(-1), { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => h.linearBuckets('hello'), + { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => h.linearBuckets(1.5), { code: 'ERR_OUT_OF_RANGE' }); +} + +// --------------------------------------------------------------------------- +// logBuckets(firstBucket, base) — logarithmically-spaced bucket iteration +// --------------------------------------------------------------------------- +{ + const h = createHistogram(); + for (let i = 1; i <= 1000; i++) h.record(i); + + const buckets = h.logBuckets(1, 2); + assert.ok(buckets instanceof Map); + assert.ok(buckets.size > 0); + + for (const [key, value] of buckets) { + assert.strictEqual(typeof key, 'number'); + assert.strictEqual(typeof value, 'number'); + assert.ok(value >= 0); + } + + // Total count across buckets equals histogram count + let total = 0; + for (const [, count] of buckets) total += count; + assert.strictEqual(total, h.count); + + // Validation + assert.throws(() => h.logBuckets(0, 2), { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => h.logBuckets(-1, 2), { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => h.logBuckets(1, 1), { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => h.logBuckets(1, 0.5), { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => h.logBuckets(1, -2), { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => h.logBuckets('hello', 2), + { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => h.logBuckets(1, 'hello'), + { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => h.logBuckets(1.5, 2), { code: 'ERR_OUT_OF_RANGE' }); +} + +// --------------------------------------------------------------------------- +// subtract(other) — subtract histogram counts +// --------------------------------------------------------------------------- +{ + const h1 = createHistogram(); + const h2 = createHistogram(); + + for (let i = 1; i <= 10; i++) h1.record(i); + for (let i = 1; i <= 5; i++) h2.record(i); + + const countBefore = h1.count; + h1.subtract(h2); + + // Count should decrease + assert.ok(h1.count < countBefore); + + // Subtracting from self zeros out + const h3 = createHistogram(); + for (let i = 1; i <= 10; i++) h3.record(i); + h3.subtract(h3); + assert.strictEqual(h3.count, 0); + + // Clamping: subtracting more than present doesn't go negative + const hSmall = createHistogram(); + const hBig = createHistogram(); + hSmall.record(1); + for (let i = 0; i < 100; i++) hBig.record(1); + hSmall.subtract(hBig); + assert.strictEqual(hSmall.count, 0); + + // Validation + assert.throws(() => h1.subtract('not a histogram'), + { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => h1.subtract(42), + { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => h1.subtract({}), + { code: 'ERR_INVALID_ARG_TYPE' }); +} + +// --------------------------------------------------------------------------- +// recordCorrected(val, expectedInterval) — coordinated omission correction +// --------------------------------------------------------------------------- +{ + // Basic recording with number args + const h = createHistogram(); + h.recordCorrected(100, 10); + assert.ok(h.count > 0); + + // Should record more values than a plain record (backfilling) + const hPlain = createHistogram(); + hPlain.record(100); + assert.ok(h.count > hPlain.count); + + // BigInt variant + const hBig = createHistogram(); + hBig.recordCorrected(100n, 10n); + assert.ok(hBig.count > 0); + + // Mixed types should throw (bigint val, number interval) + assert.throws(() => h.recordCorrected(100n, 10), + { code: 'ERR_INVALID_ARG_TYPE' }); + + // Validation: non-integer + assert.throws(() => h.recordCorrected('hello', 10), + { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => h.recordCorrected(100, 'hello'), + { code: 'ERR_INVALID_ARG_TYPE' }); + + // Out of range + assert.throws(() => h.recordCorrected(0, 10), + { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => h.recordCorrected(100, 0), + { code: 'ERR_OUT_OF_RANGE' }); +} + +// --------------------------------------------------------------------------- +// ERR_INVALID_THIS for all new methods on wrong receiver +// --------------------------------------------------------------------------- +{ + const { Histogram } = require('internal/histogram'); + const h = createHistogram(); + const wrongThis = {}; + + // Methods + const methods = [ + ['cdf', [1]], + ['ccdf', [1]], + ['countAt', [1]], + ['ksTest', [h]], + ['linearBuckets', [10]], + ['logBuckets', [1, 2]], + ['percentilesAt', [[50]]], + ]; + + for (const [method, args] of methods) { + assert.throws( + () => Histogram.prototype[method].call(wrongThis, ...args), + { code: 'ERR_INVALID_THIS' }, + `${method} should throw ERR_INVALID_THIS` + ); + } + + // Getters + for (const getter of ['skewness', 'kurtosis']) { + const desc = Object.getOwnPropertyDescriptor( + Histogram.prototype, getter); + assert.throws( + () => desc.get.call(wrongThis), + { code: 'ERR_INVALID_THIS' }, + `${getter} getter should throw ERR_INVALID_THIS` + ); + } +} + +// --------------------------------------------------------------------------- +// Empty histogram edge cases +// --------------------------------------------------------------------------- +{ + const h = createHistogram(); + + assert.strictEqual(h.cdf(1), 0); + assert.strictEqual(h.ccdf(1), 1); + assert.strictEqual(h.countAt(1), 0); + assert.strictEqual(h.skewness, 0); + assert.strictEqual(h.kurtosis, 0); + + const empty2 = createHistogram(); + assert.strictEqual(h.ksTest(empty2), 0); + + const pctAt = h.percentilesAt([50, 99]); + assert.ok(pctAt instanceof Map); + assert.strictEqual(pctAt.size, 2); + + const linear = h.linearBuckets(10); + assert.ok(linear instanceof Map); + + const log = h.logBuckets(1, 2); + assert.ok(log instanceof Map); +} + +// --------------------------------------------------------------------------- +// Single-value histogram edge cases +// --------------------------------------------------------------------------- +{ + const h = createHistogram(); + h.record(42); + + assert.strictEqual(h.skewness, 0); // Needs >= 3 + assert.strictEqual(h.kurtosis, 0); // Needs >= 4 + assert.strictEqual(h.cdf(42), 1); + assert.strictEqual(h.cdf(1), 0); + assert.strictEqual(h.ccdf(42), 0); + assert.strictEqual(h.countAt(42), 1); +} + +// --------------------------------------------------------------------------- +// Fast API call tests for new methods +// --------------------------------------------------------------------------- +{ + const h = createHistogram(); + h.record(1); + h.record(100); + + // Prepare cdf and countAt methods for optimization + eval('%PrepareFunctionForOptimization(h.cdf)'); + eval('%PrepareFunctionForOptimization(h.countAt)'); + + // Warmup call + h.cdf(50); + h.countAt(1); + + // Optimize + eval('%OptimizeFunctionOnNextCall(h.cdf)'); + eval('%OptimizeFunctionOnNextCall(h.countAt)'); + + // Fast-path call + h.cdf(50); + h.countAt(1); + + if (common.isDebug) { + const { getV8FastApiCallCount } = internalBinding('debug'); + assert.strictEqual(getV8FastApiCallCount('histogram.cdf'), 1); + assert.strictEqual(getV8FastApiCallCount('histogram.countAt'), 1); + } +} From be4e7de04c098acf786f99e6b43dc57914050db8 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Wed, 12 Aug 2026 17:55:10 +0200 Subject: [PATCH 126/344] tools: only include fast-tracked and old enough PRs in CQ Signed-off-by: Antoine du Hamel PR-URL: https://github.com/nodejs/node/pull/65197 Reviewed-By: Filip Skokan Reviewed-By: Moshe Atlow Reviewed-By: Luigi Pinca --- .github/workflows/commit-queue.yml | 4 +- doc/contributing/commit-queue.md | 77 ++++++++++++++++-------------- 2 files changed, 42 insertions(+), 39 deletions(-) diff --git a/.github/workflows/commit-queue.yml b/.github/workflows/commit-queue.yml index a4d61132deea..dff69b18564b 100644 --- a/.github/workflows/commit-queue.yml +++ b/.github/workflows/commit-queue.yml @@ -48,9 +48,7 @@ jobs: fast_track_prs=$(list_prs \ --label 'fast-track' \ --search "-label:blocked") - queued_prs=$(list_prs \ - --search "-label:blocked") - candidates=$(printf '%s %s %s\n' "$aged_prs" "$fast_track_prs" "$queued_prs" | + candidates=$(printf '%s %s\n' "$fast_track_prs" "$aged_prs" | jq -r -s 'reduce .[] as $pr ([]; if index($pr) then . else . + [$pr] end) | join(" ")') echo "candidates=$candidates" >> "$GITHUB_OUTPUT" env: diff --git a/doc/contributing/commit-queue.md b/doc/contributing/commit-queue.md index 08f01b32d4ee..6aa73f126390 100644 --- a/doc/contributing/commit-queue.md +++ b/doc/contributing/commit-queue.md @@ -20,32 +20,34 @@ From a high-level, the Commit Queue works as follows: 1. Collaborators will add `commit-queue` label to pull requests they want the queue to land. The label can be added before the pull request has completed - its wait time, or before requested CI has finished. Required approvals must - already be in place. The commit queue does not request CI on its own. + its wait time. Required approvals must already be in place, and any required + CI must have completed successfully. The commit queue does not request CI on + its own. 2. On each scheduled run, the queue builds a candidate list from open pull - requests with the `commit-queue` label and without the `blocked` label. The - workflow uses a five-minute cron, but GitHub Actions scheduled workflows are - not guaranteed to run exactly every five minutes. For each candidate, the - queue will: + requests with the `commit-queue` label and without the `blocked` label. A + candidate must also either have been created at least two days earlier or + have the `fast-track` label. Other labeled pull requests retain the label + until they become old enough or are fast-tracked. The workflow uses a + five-minute cron, but GitHub Actions scheduled workflows are not guaranteed + to run exactly every five minutes. For each candidate, the queue will: 1. In the landing job, install and configure `@node-core/utils`, then run a metadata-only readiness check without checking out the repository 2. If the metadata check exits with a deferrable readiness code, meaning the PR is only blocked on wait time, keep the `commit-queue` label and skip this PR until a later queue run - 3. Check if the PR also has a `request-ci` label (if it has, skip this PR - since it's pending a CI run) - 4. Check whether GitHub checks are still running (if they are, skip this PR) - 5. Remove the `commit-queue` label and run `git node land` - 6. If it fails: - 1. Add the `commit-queue-failed` label to the PR + 3. Run `git node land` for ready PRs and PRs with hard or mixed readiness + failures, keeping the `commit-queue` label in place during the attempt + 4. If it fails: + 1. Replace the `commit-queue` label with the `commit-queue-failed` label 2. Leave a comment on the PR with the output from `git node land` 3. Abort the `git node land` session. If the abort succeeds, continue to the next PR; otherwise, stop the queue in an unknown state - 7. If it succeeds: + 5. If it succeeds: 1. Push or merge the changes into nodejs/node 2. Leave a comment on the PR with `Landed in ...` 3. Close the PR - 4. Go to next PR in the queue + 4. Remove the `commit-queue` label + 5. Go to next PR in the queue To make the Commit Queue squash all the commits of a pull request into the first one, add the `commit-queue-squash` label. @@ -94,11 +96,11 @@ reasons: without rebasing them first. The workflow starts with a small candidate job that uses GitHub CLI to fetch -pull requests with the `commit-queue` label. It first fetches the same -age-based and fast-track buckets the queue used before accepting early queue -requests, then fetches the broader queue and de-duplicates the result. This -keeps not-yet-ready PRs from crowding out PRs that the previous query would -have selected if GitHub paginates or caps a query result. +open pull requests with the `commit-queue` label and without the `blocked` +label. It fetches two buckets: pull requests created at least two days earlier +and pull requests with the `fast-track` label. The job de-duplicates the +buckets before passing the candidates to the landing job. Pull requests in +neither bucket remain labeled but are not processed during that run. If there are candidate PRs, the landing job installs and configures `@node-core/utils` once with a personal token and a Jenkins token from @@ -123,9 +125,10 @@ states. Unknown filter failures fail the workflow before starting the landing script and leave PR labels unchanged so the queue can retry on a later scheduled run. PRs passed through with exit code `40`-`49` continue through `commit-queue.sh`. The workflow checks out the repository only when at least -one PR remains after filtering. The script still applies its existing -`request-ci` and pending-check deferrals before removing the queue label and -reporting a hard failure. +one PR remains after filtering. The script does not separately skip PRs with a +`request-ci` label or pending GitHub checks. Instead, `git node land` performs +the landing checks and the script reports any failure through the normal queue +failure path. > The personal token needs permission for public repositories and to read > profiles. It is used by `@node-core/utils` and by the landing job for @@ -139,18 +142,20 @@ reporting a hard failure. 3. Every positional argument starting at this one will be a pull request ID of a pull request with commit-queue set. -The script will iterate over the pull requests. GitHub CLI is used to check if -the PR is waiting for CI to start (`request-ci` label) or still has pending -GitHub checks. The PR is skipped if CI is pending. No other CI validation is -done here since `git node land` will fail if the last CI failed. - -The script removes the `commit-queue` label, then runs `git node land`, -forwarding stdout and stderr to a file. PRs that are only blocked on wait time -should have already been filtered by the metadata check. If a hard readiness -failure appears between the metadata filter and `git node land`, the landing -job adds a `commit-queue-failed` label to the PR, leaves a comment with the -output of `git node land`, and then aborts the landing session. If the abort -fails, the queue stops instead of continuing in an unknown state. +The script iterates over the pull requests. For each PR, it uses GitHub CLI to +fetch the labels and select the multiple-commit policy, then runs +`git node land`, forwarding stdout and stderr to a file. It does not perform a +separate CI preflight; `git node land` performs the current readiness and CI +validation. + +The script keeps the `commit-queue` label in place while `git node land` is +running. PRs that are only blocked on wait time should have already been +filtered by the metadata check. A hard or mixed readiness failure is passed +through so `git node land` can produce the failure output. If the landing +attempt fails for that or any other reason, the job replaces the +`commit-queue` label with `commit-queue-failed`, leaves a comment with the +output, and then aborts the landing session. If the abort fails, the queue +stops instead of continuing in an unknown state. Fast-tracked PRs use the metadata check before checkout and the landing script. If the fast-track request has not yet received enough collaborator thumbs-up, @@ -164,8 +169,8 @@ If no errors happen during `git node land`, the script either pushes the direct rebase landing to `main` or uses GitHub's squash merge API for single-commit and fixup landings. It then leaves a `Landed in ...` comment in the PR. GitHub closes PRs merged through the merge API automatically; for direct pushes, the -script closes the PR. Iteration continues until all PRs have done the steps -above. +script closes the PR. The script then removes the `commit-queue` label. +Iteration continues until all PRs have done the steps above. ## Reverting broken commits From f77aa2f703a14ed7fba8a43137db63b4b7c9a21b Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sat, 8 Aug 2026 16:33:16 -0700 Subject: [PATCH 127/344] src: cache permission strings Use env_property strings for permissions since those are fixed. Avoid creating new string instances each time. Also use ToV8Value for a couple since we're in here. Signed-off-by: James M Snell PR-URL: https://github.com/nodejs/node/pull/65158 Reviewed-By: Xuguang Mei Reviewed-By: Stephen Belanger --- src/env-inl.h | 16 ++++++++++++++++ src/env.cc | 37 ++++++++++++++++++++++++++++++++++++ src/env.h | 16 ++++++++++++++++ src/permission/permission.cc | 32 ++++++++++--------------------- src/permission/permission.h | 3 ++- 5 files changed, 81 insertions(+), 23 deletions(-) diff --git a/src/env-inl.h b/src/env-inl.h index 761a7bfc9955..bbeb47b72ce3 100644 --- a/src/env-inl.h +++ b/src/env-inl.h @@ -841,6 +841,14 @@ void Environment::set_process_exit_handler( #undef VY #undef VP +#define V(Name, label, _, __) \ + inline v8::Local \ + IsolateData::Name##_permission_string() const { \ + return Name##_permission_string##_.Get(isolate_); \ + } + PERMISSIONS(V) +#undef V + #define VM(PropertyName) V(PropertyName##_binding_template, v8::ObjectTemplate) #define V(PropertyName, TypeName) \ inline v8::Local IsolateData::PropertyName() const { \ @@ -870,6 +878,14 @@ void Environment::set_process_exit_handler( #undef VY #undef VP +#define V(Name, label, _, __) \ + inline v8::Local \ + Environment::Name##_permission_string() const { \ + return isolate_data()->Name##_permission_string(); \ + } + PERMISSIONS(V) +#undef V + #define V(PropertyName, TypeName) \ inline v8::Local Environment::PropertyName() const { \ return isolate_data()->PropertyName(); \ diff --git a/src/env.cc b/src/env.cc index 87340112fbeb..7b275e09b720 100644 --- a/src/env.cc +++ b/src/env.cc @@ -360,6 +360,12 @@ IsolateDataSerializeInfo IsolateData::Serialize(SnapshotCreator* creator) { #undef VS #undef VP +#define V(Name, label, _, __) \ + info.primitive_values.push_back( \ + creator->AddData(Name##_permission_string##_.Get(isolate))); + PERMISSIONS(V) +#undef V + info.primitive_values.reserve(info.primitive_values.size() + AsyncWrap::PROVIDERS_LENGTH); for (size_t i = 0; i < AsyncWrap::PROVIDERS_LENGTH; i++) { @@ -419,6 +425,21 @@ void IsolateData::DeserializeProperties(const IsolateDataSerializeInfo* info) { #undef VS #undef VP +#define V(Name, label, _, __) \ + do { \ + MaybeLocal maybe_field = \ + isolate_->GetDataFromSnapshotOnce( \ + info->primitive_values[i++]); \ + Local field; \ + if (!maybe_field.ToLocal(&field)) { \ + fprintf(stderr, \ + "Failed to deserialize " #Name "_permission_string\n"); \ + } \ + Name##_permission_string##_.Set(isolate_, field); \ + } while (0); + PERMISSIONS(V) +#undef V + for (size_t j = 0; j < AsyncWrap::PROVIDERS_LENGTH; j++) { MaybeLocal maybe_field = isolate_->GetDataFromSnapshotOnce(info->primitive_values[i++]); @@ -520,6 +541,17 @@ void IsolateData::CreateProperties() { PER_ISOLATE_STRING_PROPERTIES(V) #undef V +#define V(Name, label, _, __) \ + Name##_permission_string##_.Set( \ + isolate_, \ + String::NewFromOneByte(isolate_, \ + reinterpret_cast(#Name), \ + NewStringType::kInternalized, \ + sizeof(#Name) - 1) \ + .ToLocalChecked()); + PERMISSIONS(V) +#undef V + // Create all the provider strings that will be passed to JS. Place them in // an array so the array index matches the PROVIDER id offset. This way the // strings can be retrieved quickly. @@ -630,6 +662,11 @@ void IsolateData::MemoryInfo(MemoryTracker* tracker) const { PER_ISOLATE_STRING_PROPERTIES(V) #undef V +#define V(Name, label, _, __) \ + tracker->TrackField(#Name "_permission_string", Name##_permission_string()); + PERMISSIONS(V) +#undef V + tracker->TrackField("async_wrap_providers", async_wrap_providers_); if (node_allocator_ != nullptr) { diff --git a/src/env.h b/src/env.h index c2caf9790238..c2bf9fdd497a 100644 --- a/src/env.h +++ b/src/env.h @@ -189,6 +189,11 @@ class NODE_EXTERN_PRIVATE IsolateData : public MemoryRetainer { #undef VS #undef VP +#define V(Name, label, _, __) \ + inline v8::Local Name##_permission_string() const; + PERMISSIONS(V) +#undef V + #define VM(PropertyName) V(PropertyName##_binding_template, v8::ObjectTemplate) #define V(PropertyName, TypeName) \ inline v8::Local PropertyName() const; \ @@ -234,6 +239,12 @@ class NODE_EXTERN_PRIVATE IsolateData : public MemoryRetainer { #undef VS #undef VY #undef VP + +#define V(Name, label, _, __) \ + v8::Eternal Name##_permission_string##_; + PERMISSIONS(V) +#undef V + // Keep a list of all Persistent strings used for AsyncWrap Provider types. std::array, AsyncWrap::PROVIDERS_LENGTH> async_wrap_providers_; @@ -875,6 +886,11 @@ class Environment final : public MemoryRetainer { #undef VY #undef VP +#define V(Name, label, _, __) \ + inline v8::Local Name##_permission_string() const; + PERMISSIONS(V) +#undef V + #define V(PropertyName, TypeName) \ inline v8::Local PropertyName() const; \ inline void set_ ## PropertyName(v8::Local value); diff --git a/src/permission/permission.cc b/src/permission/permission.cc index 919edd0821fd..9815073c987a 100644 --- a/src/permission/permission.cc +++ b/src/permission/permission.cc @@ -106,10 +106,11 @@ static void Has(const FunctionCallbackInfo& args) { } // namespace #define V(Name, label, _, __) \ - if (perm == PermissionScope::k##Name) return #Name; -const char* Permission::PermissionToString(const PermissionScope perm) { + if (perm == PermissionScope::k##Name) return env->Name##_permission_string(); +v8::Local Permission::PermissionToString( + Environment* env, const PermissionScope perm) { PERMISSIONS(V) - return nullptr; + UNREACHABLE(); } #undef V @@ -192,12 +193,9 @@ MaybeLocal CreateAccessDeniedError(Environment* env, Local err = ERR_ACCESS_DENIED( env->isolate(), "Access to this API has been restricted. %s", suggestion); - Local perm_string; Local resource_string; - std::string_view perm_str = Permission::PermissionToString(perm); - if (!ToV8Value(env->context(), perm_str, env->isolate()) - .ToLocal(&perm_string) || - !ToV8Value(env->context(), res, env->isolate()) + Local perm_string = Permission::PermissionToString(env, perm); + if (!ToV8Value(env->context(), res, env->isolate()) .ToLocal(&resource_string) || err->Set(env->context(), env->permission_string(), perm_string) .IsNothing() || @@ -263,18 +261,13 @@ bool Permission::is_scope_granted(Environment* env, v8::Local context = env->context(); v8::Local msg = v8::Object::New(isolate, v8::Null(isolate), nullptr, nullptr, 0); - const char* perm_str = PermissionToString(permission); msg->Set(context, env->permission_string(), - v8::String::NewFromUtf8(isolate, perm_str).ToLocalChecked()) + PermissionToString(env, permission)) .Check(); msg->Set(context, env->resource_string(), - v8::String::NewFromUtf8(isolate, - res.data(), - v8::NewStringType::kNormal, - static_cast(res.size())) - .ToLocalChecked()) + ToV8Value(context, res).ToLocalChecked()) .Check(); ch->Publish(env, msg); publishing_ = false; @@ -333,18 +326,13 @@ void Permission::Drop(Environment* env, v8::Local context = env->context(); v8::Local msg = v8::Object::New(isolate, v8::Null(isolate), nullptr, nullptr, 0); - const char* perm_str = PermissionToString(scope); msg->Set(context, env->permission_string(), - v8::String::NewFromUtf8(isolate, perm_str).ToLocalChecked()) + PermissionToString(env, scope)) .Check(); msg->Set(context, env->resource_string(), - v8::String::NewFromUtf8(isolate, - param.data(), - v8::NewStringType::kNormal, - static_cast(param.size())) - .ToLocalChecked()) + ToV8Value(context, param).ToLocalChecked()) .Check(); msg->Set(context, FIXED_ONE_BYTE_STRING(isolate, "drop"), diff --git a/src/permission/permission.h b/src/permission/permission.h index 5597e5ce2445..674d99c1409a 100644 --- a/src/permission/permission.h +++ b/src/permission/permission.h @@ -113,7 +113,8 @@ class Permission { FORCE_INLINE bool warning_only() const { return warning_only_; } static PermissionScope StringToPermission(const std::string& perm); - static const char* PermissionToString(PermissionScope perm); + static v8::Local PermissionToString(Environment* env, + PermissionScope perm); static void ThrowAccessDenied(Environment* env, PermissionScope perm, const std::string_view& res); From 968bdd013591da9338a5747c9f276f1c99626956 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sat, 8 Aug 2026 17:01:32 -0700 Subject: [PATCH 128/344] src: use DictionaryTemplate for permission diag channel message Since DiagnosticChannel permission messages always have the same shape and should be as low cost as possible, use a cached DictionaryTemplate for creating them Signed-off-by: James M Snell PR-URL: https://github.com/nodejs/node/pull/65158 Reviewed-By: Xuguang Mei Reviewed-By: Stephen Belanger --- src/env-inl.h | 10 +++--- src/env.cc | 5 ++- src/env_properties.h | 1 + src/permission/permission.cc | 60 ++++++++++++++++++++---------------- 4 files changed, 41 insertions(+), 35 deletions(-) diff --git a/src/env-inl.h b/src/env-inl.h index bbeb47b72ce3..e9f940c63e53 100644 --- a/src/env-inl.h +++ b/src/env-inl.h @@ -842,9 +842,8 @@ void Environment::set_process_exit_handler( #undef VP #define V(Name, label, _, __) \ - inline v8::Local \ - IsolateData::Name##_permission_string() const { \ - return Name##_permission_string##_.Get(isolate_); \ + inline v8::Local IsolateData::Name##_permission_string() const { \ + return Name##_permission_string##_.Get(isolate_); \ } PERMISSIONS(V) #undef V @@ -879,9 +878,8 @@ void Environment::set_process_exit_handler( #undef VP #define V(Name, label, _, __) \ - inline v8::Local \ - Environment::Name##_permission_string() const { \ - return isolate_data()->Name##_permission_string(); \ + inline v8::Local Environment::Name##_permission_string() const { \ + return isolate_data()->Name##_permission_string(); \ } PERMISSIONS(V) #undef V diff --git a/src/env.cc b/src/env.cc index 7b275e09b720..658141f60817 100644 --- a/src/env.cc +++ b/src/env.cc @@ -432,10 +432,9 @@ void IsolateData::DeserializeProperties(const IsolateDataSerializeInfo* info) { info->primitive_values[i++]); \ Local field; \ if (!maybe_field.ToLocal(&field)) { \ - fprintf(stderr, \ - "Failed to deserialize " #Name "_permission_string\n"); \ + fprintf(stderr, "Failed to deserialize " #Name "_permission_string\n"); \ } \ - Name##_permission_string##_.Set(isolate_, field); \ + Name##_permission_string##_.Set(isolate_, field); \ } while (0); PERMISSIONS(V) #undef V diff --git a/src/env_properties.h b/src/env_properties.h index 513f5875a8d2..d501170442e7 100644 --- a/src/env_properties.h +++ b/src/env_properties.h @@ -460,6 +460,7 @@ V(naptr_record_template, v8::DictionaryTemplate) \ V(object_stats_template, v8::DictionaryTemplate) \ V(page_stats_template, v8::DictionaryTemplate) \ + V(permission_diagnostic_channel_message, v8::DictionaryTemplate) \ V(pipe_constructor_template, v8::FunctionTemplate) \ V(script_context_constructor_template, v8::FunctionTemplate) \ V(secure_context_constructor_template, v8::FunctionTemplate) \ diff --git a/src/permission/permission.cc b/src/permission/permission.cc index 9815073c987a..512d95333b47 100644 --- a/src/permission/permission.cc +++ b/src/permission/permission.cc @@ -8,6 +8,7 @@ #include "node_external_reference.h" #include "node_file.h" +#include "v8-template.h" #include "v8.h" #include @@ -17,11 +18,13 @@ namespace node { using v8::Context; +using v8::DictionaryTemplate; using v8::FunctionCallbackInfo; using v8::IntegrityLevel; using v8::Local; using v8::MaybeLocal; using v8::Object; +using v8::Undefined; using v8::Value; namespace permission { @@ -55,6 +58,20 @@ constexpr std::string_view GetDiagnosticsChannelName(PermissionScope scope) { } } +Local GetPermissionDiagnosicsTemplate(Environment* env) { + auto tmpl = env->permission_diagnostic_channel_message(); + if (tmpl.IsEmpty()) { + static constexpr std::string_view names[] = { + "permission", + "resource", + "drop", + }; + tmpl = DictionaryTemplate::New(env->isolate(), names); + env->set_permission_diagnostic_channel_message(tmpl); + } + return tmpl; +} + // permission.drop('fs.read', '/tmp/') // permission.drop('child') static void Drop(const FunctionCallbackInfo& args) { @@ -259,17 +276,14 @@ bool Permission::is_scope_granted(Environment* env, v8::Isolate* isolate = env->isolate(); v8::HandleScope handle_scope(isolate); v8::Local context = env->context(); - v8::Local msg = - v8::Object::New(isolate, v8::Null(isolate), nullptr, nullptr, 0); - msg->Set(context, - env->permission_string(), - PermissionToString(env, permission)) - .Check(); - msg->Set(context, - env->resource_string(), - ToV8Value(context, res).ToLocalChecked()) - .Check(); - ch->Publish(env, msg); + v8::MaybeLocal values[] = { + PermissionToString(env, permission), + ToV8Value(context, res), + Undefined(isolate), + }; + ch->Publish( + env, + GetPermissionDiagnosicsTemplate(env)->NewInstance(context, values)); publishing_ = false; } } @@ -324,21 +338,15 @@ void Permission::Drop(Environment* env, v8::Isolate* isolate = env->isolate(); v8::HandleScope handle_scope(isolate); v8::Local context = env->context(); - v8::Local msg = - v8::Object::New(isolate, v8::Null(isolate), nullptr, nullptr, 0); - msg->Set(context, - env->permission_string(), - PermissionToString(env, scope)) - .Check(); - msg->Set(context, - env->resource_string(), - ToV8Value(context, param).ToLocalChecked()) - .Check(); - msg->Set(context, - FIXED_ONE_BYTE_STRING(isolate, "drop"), - v8::Boolean::New(isolate, true)) - .Check(); - ch->Publish(env, msg); + + v8::MaybeLocal values[] = { + PermissionToString(env, scope), + ToV8Value(context, param), + v8::True(isolate), + }; + ch->Publish( + env, + GetPermissionDiagnosicsTemplate(env)->NewInstance(context, values)); publishing_ = false; } } From ec8fe275a13434ca717f66c243359e721ff2f964 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sat, 8 Aug 2026 17:10:40 -0700 Subject: [PATCH 129/344] src: make minor cleanup to permission checks Getting the name of the channel is unnecessary. Signed-off-by: James M Snell PR-URL: https://github.com/nodejs/node/pull/65158 Reviewed-By: Xuguang Mei Reviewed-By: Stephen Belanger --- src/permission/permission.cc | 43 +++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/src/permission/permission.cc b/src/permission/permission.cc index 512d95333b47..aad50a7f4039 100644 --- a/src/permission/permission.cc +++ b/src/permission/permission.cc @@ -8,6 +8,7 @@ #include "node_external_reference.h" #include "node_file.h" +#include "permission/permission_base.h" #include "v8-template.h" #include "v8.h" @@ -261,6 +262,8 @@ void Permission::EnableWarningOnly() { bool Permission::is_scope_granted(Environment* env, const PermissionScope permission, const std::string_view& res) const { + CHECK(permission != PermissionScope::kPermissionsRoot && + permission != PermissionScope::kPermissionsCount); auto perm_node = nodes_.find(permission); bool result = false; if (perm_node != nodes_.end()) { @@ -268,24 +271,21 @@ bool Permission::is_scope_granted(Environment* env, } if (!result && !publishing_) { - auto channel_name = GetDiagnosticsChannelName(permission); - if (!channel_name.empty()) { - auto ch = GetOrCreateChannel(env, permission); - if (ch && ch->HasSubscribers()) { - publishing_ = true; - v8::Isolate* isolate = env->isolate(); - v8::HandleScope handle_scope(isolate); - v8::Local context = env->context(); - v8::MaybeLocal values[] = { - PermissionToString(env, permission), - ToV8Value(context, res), - Undefined(isolate), - }; - ch->Publish( - env, - GetPermissionDiagnosicsTemplate(env)->NewInstance(context, values)); - publishing_ = false; - } + auto ch = GetOrCreateChannel(env, permission); + if (ch && ch->HasSubscribers()) { + publishing_ = true; + v8::Isolate* isolate = env->isolate(); + v8::HandleScope handle_scope(isolate); + v8::Local context = env->context(); + v8::MaybeLocal values[] = { + PermissionToString(env, permission), + ToV8Value(context, res), + Undefined(isolate), + }; + ch->Publish( + env, + GetPermissionDiagnosicsTemplate(env)->NewInstance(context, values)); + publishing_ = false; } } @@ -294,6 +294,8 @@ bool Permission::is_scope_granted(Environment* env, BaseObjectPtr Permission::GetOrCreateChannel( Environment* env, PermissionScope scope) const { + CHECK(scope != PermissionScope::kPermissionsRoot && + scope != PermissionScope::kPermissionsCount); auto it = channels_.find(scope); if (it != channels_.end()) { // Promote weak ref to strong for the duration of this call. @@ -324,14 +326,15 @@ void Permission::Apply(Environment* env, void Permission::Drop(Environment* env, PermissionScope scope, const std::string_view& param) { + CHECK(scope != PermissionScope::kPermissionsRoot && + scope != PermissionScope::kPermissionsCount); auto permission = nodes_.find(scope); if (permission != nodes_.end()) { permission->second->Drop(env, scope, param); } // Publish to diagnostics channel so observers can track drops - auto channel_name = GetDiagnosticsChannelName(scope); - if (!channel_name.empty() && !publishing_) { + if (!publishing_) { auto ch = GetOrCreateChannel(env, scope); if (ch && ch->HasSubscribers()) { publishing_ = true; From ac920e0d2ba050e6b90cf4a4c529d3733fe9da9b Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sat, 8 Aug 2026 17:33:26 -0700 Subject: [PATCH 130/344] src: simplify c++ diagnostics channel API Signed-off-by: James M Snell PR-URL: https://github.com/nodejs/node/pull/65158 Reviewed-By: Xuguang Mei Reviewed-By: Stephen Belanger --- src/node_diagnostics_channel.cc | 14 ++++++++------ src/node_diagnostics_channel.h | 3 +-- src/permission/permission.cc | 6 ++---- test/cctest/test_diagnostics_channel.cc | 9 +++++---- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/node_diagnostics_channel.cc b/src/node_diagnostics_channel.cc index cfae019da62f..ab67e6d7c5fa 100644 --- a/src/node_diagnostics_channel.cc +++ b/src/node_diagnostics_channel.cc @@ -178,11 +178,11 @@ void Channel::Unlink() { publish_fn_.Reset(); } -Channel* Channel::Get(Environment* env, const char* name) { +BaseObjectPtr Channel::Get(Environment* env, std::string_view name) { Realm* realm = env->principal_realm(); BindingData* binding = realm->GetBindingData(); if (binding == nullptr) { - return nullptr; + return {}; } uint32_t index = binding->GetOrCreateChannelIndex(std::string(name)); @@ -208,22 +208,24 @@ Channel* Channel::Get(Environment* env, const char* name) { .ToLocalChecked() ->NewInstance(context) .ToLocal(&wrap)) { - return nullptr; + return {}; } binding->channels_[index] = MakeDetachedBaseObject( env, wrap, binding, index, std::string(name)); } - Channel* channel = binding->channels_[index].get(); + auto& channel = binding->channels_[index]; // Late-bind: link to the JS channel when the callback is available. if (!binding->link_callback_.IsEmpty() && !channel->IsLinked()) { Isolate* isolate = env->isolate(); HandleScope handle_scope(isolate); Local context = env->context(); - Local js_name = String::NewFromUtf8(isolate, name).ToLocalChecked(); - Local argv[] = {js_name, Integer::NewFromUnsigned(isolate, index)}; + Local argv[] = { + ToV8Value(context, name).ToLocalChecked(), + Integer::NewFromUnsigned(isolate, index), + }; Local result; if (binding->link_callback_.Get(isolate) ->Call(context, v8::Undefined(isolate), arraysize(argv), argv) diff --git a/src/node_diagnostics_channel.h b/src/node_diagnostics_channel.h index 073e4e4f273b..ca68e75a4361 100644 --- a/src/node_diagnostics_channel.h +++ b/src/node_diagnostics_channel.h @@ -73,8 +73,7 @@ class Channel : public BaseObject { uint32_t index, std::string name); - // Returns a non-owning pointer. Lifetime is managed by BindingData. - static Channel* Get(Environment* env, const char* name); + static BaseObjectPtr Get(Environment* env, std::string_view name); inline bool HasSubscribers() const { return binding_data_ != nullptr && binding_data_->subscribers_[index_] > 0; diff --git a/src/permission/permission.cc b/src/permission/permission.cc index aad50a7f4039..6bf3b0fe04eb 100644 --- a/src/permission/permission.cc +++ b/src/permission/permission.cc @@ -304,12 +304,10 @@ BaseObjectPtr Permission::GetOrCreateChannel( channels_.erase(it); } auto channel_name = GetDiagnosticsChannelName(scope); - diagnostics_channel::Channel* ch = - diagnostics_channel::Channel::Get(env, channel_name.data()); - if (ch != nullptr) { + if (auto ch = diagnostics_channel::Channel::Get(env, channel_name)) { channels_.emplace(scope, BaseObjectWeakPtr(ch)); - return BaseObjectPtr(ch); + return ch; } return {}; } diff --git a/test/cctest/test_diagnostics_channel.cc b/test/cctest/test_diagnostics_channel.cc index 30a004b59070..d4d1fd1c9fac 100644 --- a/test/cctest/test_diagnostics_channel.cc +++ b/test/cctest/test_diagnostics_channel.cc @@ -3,6 +3,7 @@ #include "gtest/gtest.h" #include "node_test_fixture.h" +using node::BaseObjectPtr; using node::diagnostics_channel::Channel; class DiagnosticsChannelTest : public EnvironmentTestFixture {}; @@ -279,15 +280,15 @@ TEST_F(DiagnosticsChannelTest, NativeChannelsGrowSubscriberStorage) { "globalThis.__dc.subscribe('test:cctest:grow:0', " " globalThis.__firstSubscriber);"); - Channel* first = Channel::Get(*env, "test:cctest:grow:0"); - ASSERT_NE(first, nullptr); + auto first = Channel::Get(*env, "test:cctest:grow:0"); + ASSERT_TRUE(first); ASSERT_TRUE(first->HasSubscribers()); - Channel* last = nullptr; + BaseObjectPtr last; for (size_t i = 1; i <= 1024; i++) { std::string name = "test:cctest:grow:" + std::to_string(i); last = Channel::Get(*env, name.c_str()); - ASSERT_NE(last, nullptr); + ASSERT_TRUE(last); } RunJS(isolate_, From 521aaf10fce05255ca72234e61191a135574f68c Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sat, 8 Aug 2026 18:19:23 -0700 Subject: [PATCH 131/344] src: apply multiple general cleanups to permissions Signed-off-by: James M Snell PR-URL: https://github.com/nodejs/node/pull/65158 Reviewed-By: Xuguang Mei Reviewed-By: Stephen Belanger --- src/env.cc | 17 ++++++------- src/node_diagnostics_channel.cc | 4 ++-- src/permission/addon_permission.cc | 6 ++--- src/permission/addon_permission.h | 6 ++--- src/permission/child_process_permission.cc | 6 ++--- src/permission/child_process_permission.h | 6 ++--- src/permission/ffi_permission.cc | 6 ++--- src/permission/ffi_permission.h | 6 ++--- src/permission/fs_permission.cc | 10 ++++---- src/permission/fs_permission.h | 10 ++++---- src/permission/inspector_permission.cc | 6 ++--- src/permission/inspector_permission.h | 6 ++--- src/permission/net_permission.cc | 6 ++--- src/permission/net_permission.h | 6 ++--- src/permission/openssl_store_permission.cc | 6 ++--- src/permission/openssl_store_permission.h | 6 ++--- src/permission/permission.cc | 28 +++++++++++----------- src/permission/permission.h | 22 ++++++++--------- src/permission/permission_base.h | 10 ++++---- src/permission/wasi_permission.cc | 6 ++--- src/permission/wasi_permission.h | 6 ++--- src/permission/worker_permission.cc | 6 ++--- src/permission/worker_permission.h | 6 ++--- 23 files changed, 98 insertions(+), 99 deletions(-) diff --git a/src/env.cc b/src/env.cc index 658141f60817..13344ad135e2 100644 --- a/src/env.cc +++ b/src/env.cc @@ -954,6 +954,7 @@ Environment::Environment(IsolateData* isolate_data, if (options_->permission || options_->permission_audit) { permission()->EnablePermissions(); + static const std::array args = {std::string("*")}; if (options_->permission_audit) { permission()->EnableWarningOnly(); } @@ -962,29 +963,29 @@ Environment::Environment(IsolateData* isolate_data, // unless explicitly allowed by the user if (!options_->allow_addons) { options_->allow_native_addons = false; - permission()->Apply(this, {"*"}, permission::PermissionScope::kAddon); + permission()->Apply(this, args, permission::PermissionScope::kAddon); } if (!options_->allow_inspector) { flags_ = flags_ | EnvironmentFlags::kNoCreateInspector; - permission()->Apply(this, {"*"}, permission::PermissionScope::kInspector); + permission()->Apply(this, args, permission::PermissionScope::kInspector); } if (!options_->allow_child_process) { permission()->Apply( - this, {"*"}, permission::PermissionScope::kChildProcess); + this, args, permission::PermissionScope::kChildProcess); } if (!options_->allow_ffi) { - permission()->Apply(this, {"*"}, permission::PermissionScope::kFFI); + permission()->Apply(this, args, permission::PermissionScope::kFFI); } if (!options_->allow_openssl_store) { permission()->Apply( - this, {"*"}, permission::PermissionScope::kOpenSSLStore); + this, args, permission::PermissionScope::kOpenSSLStore); } if (!options_->allow_worker_threads) { permission()->Apply( - this, {"*"}, permission::PermissionScope::kWorkerThreads); + this, args, permission::PermissionScope::kWorkerThreads); } if (!options_->allow_wasi) { - permission()->Apply(this, {"*"}, permission::PermissionScope::kWASI); + permission()->Apply(this, args, permission::PermissionScope::kWASI); } // Implicit allow entrypoint to kFileSystemRead @@ -1019,7 +1020,7 @@ Environment::Environment(IsolateData* isolate_data, } if (options_->allow_net) { - permission()->Apply(this, {"*"}, permission::PermissionScope::kNet); + permission()->Apply(this, args, permission::PermissionScope::kNet); } } } diff --git a/src/node_diagnostics_channel.cc b/src/node_diagnostics_channel.cc index ab67e6d7c5fa..ba0492a0df5e 100644 --- a/src/node_diagnostics_channel.cc +++ b/src/node_diagnostics_channel.cc @@ -223,8 +223,8 @@ BaseObjectPtr Channel::Get(Environment* env, std::string_view name) { HandleScope handle_scope(isolate); Local context = env->context(); Local argv[] = { - ToV8Value(context, name).ToLocalChecked(), - Integer::NewFromUnsigned(isolate, index), + ToV8Value(context, name).ToLocalChecked(), + Integer::NewFromUnsigned(isolate, index), }; Local result; if (binding->link_callback_.Get(isolate) diff --git a/src/permission/addon_permission.cc b/src/permission/addon_permission.cc index 66035556102f..249b266c5919 100644 --- a/src/permission/addon_permission.cc +++ b/src/permission/addon_permission.cc @@ -9,20 +9,20 @@ namespace permission { // Currently, Addon manage a single state // Once denied, it's always denied void AddonPermission::Apply(Environment* env, - const std::vector& allow, + std::span allow, PermissionScope scope) { deny_all_ = true; } void AddonPermission::Drop(Environment* env, PermissionScope scope, - const std::string_view& param) { + std::string_view param) { deny_all_ = true; } bool AddonPermission::is_granted(Environment* env, PermissionScope perm, - const std::string_view& param) const { + std::string_view param) const { return deny_all_ == false; } diff --git a/src/permission/addon_permission.h b/src/permission/addon_permission.h index b3eed910fe9f..04e2ed6fed9a 100644 --- a/src/permission/addon_permission.h +++ b/src/permission/addon_permission.h @@ -13,14 +13,14 @@ namespace permission { class AddonPermission final : public PermissionBase { public: void Apply(Environment* env, - const std::vector& allow, + std::span allow, PermissionScope scope) override; void Drop(Environment* env, PermissionScope scope, - const std::string_view& param = "") override; + std::string_view param) override; bool is_granted(Environment* env, PermissionScope perm, - const std::string_view& param = "") const override; + std::string_view param) const override; private: bool deny_all_; diff --git a/src/permission/child_process_permission.cc b/src/permission/child_process_permission.cc index 7d31ff24f813..25c9713e0570 100644 --- a/src/permission/child_process_permission.cc +++ b/src/permission/child_process_permission.cc @@ -10,20 +10,20 @@ namespace permission { // Currently, ChildProcess manage a single state // Once denied, it's always denied void ChildProcessPermission::Apply(Environment* env, - const std::vector& allow, + std::span allow, PermissionScope scope) { deny_all_ = true; } void ChildProcessPermission::Drop(Environment* env, PermissionScope scope, - const std::string_view& param) { + std::string_view param) { deny_all_ = true; } bool ChildProcessPermission::is_granted(Environment* env, PermissionScope perm, - const std::string_view& param) const { + std::string_view param) const { return deny_all_ == false; } diff --git a/src/permission/child_process_permission.h b/src/permission/child_process_permission.h index 33612b1c10a9..59ab74bb5116 100644 --- a/src/permission/child_process_permission.h +++ b/src/permission/child_process_permission.h @@ -13,14 +13,14 @@ namespace permission { class ChildProcessPermission final : public PermissionBase { public: void Apply(Environment* env, - const std::vector& allow, + std::span allow, PermissionScope scope) override; void Drop(Environment* env, PermissionScope scope, - const std::string_view& param = "") override; + std::string_view param) override; bool is_granted(Environment* env, PermissionScope perm, - const std::string_view& param = "") const override; + std::string_view param) const override; private: bool deny_all_; diff --git a/src/permission/ffi_permission.cc b/src/permission/ffi_permission.cc index 4b00d4c07b9c..674394144471 100644 --- a/src/permission/ffi_permission.cc +++ b/src/permission/ffi_permission.cc @@ -9,20 +9,20 @@ namespace permission { // Currently, FFIPermission manages a single global deny state for FFI. void FFIPermission::Apply(Environment* env, - const std::vector& allow, + std::span allow, PermissionScope scope) { deny_all_ = true; } void FFIPermission::Drop(Environment* env, PermissionScope scope, - const std::string_view& param) { + std::string_view param) { deny_all_ = true; } bool FFIPermission::is_granted(Environment* env, PermissionScope perm, - const std::string_view& param) const { + std::string_view param) const { return perm != PermissionScope::kFFI || !deny_all_; } diff --git a/src/permission/ffi_permission.h b/src/permission/ffi_permission.h index 3acd3c4642a0..fcb8b403c725 100644 --- a/src/permission/ffi_permission.h +++ b/src/permission/ffi_permission.h @@ -13,14 +13,14 @@ namespace permission { class FFIPermission final : public PermissionBase { public: void Apply(Environment* env, - const std::vector& allow, + std::span allow, PermissionScope scope) override; void Drop(Environment* env, PermissionScope scope, - const std::string_view& param = "") override; + std::string_view param) override; bool is_granted(Environment* env, PermissionScope perm, - const std::string_view& param = "") const override; + std::string_view param) const override; private: bool deny_all_ = false; diff --git a/src/permission/fs_permission.cc b/src/permission/fs_permission.cc index 98146cf825ad..89f1c6d70c98 100644 --- a/src/permission/fs_permission.cc +++ b/src/permission/fs_permission.cc @@ -52,7 +52,7 @@ void FreeRecursivelyNode( bool is_tree_granted( node::Environment* env, const node::permission::FSPermission::RadixTree* granted_tree, - const std::string_view& param) { + std::string_view param) { std::string resolved_param = node::PathResolve(env, {param}); #ifdef _WIN32 // Remove leading "\\?\" from UNC path @@ -137,7 +137,7 @@ namespace permission { // allow = '*' // allow = '/tmp/,/home/example.js' void FSPermission::Apply(Environment* env, - const std::vector& allow, + std::span allow, PermissionScope scope) { for (const std::string& res : allow) { if (res == "*") { @@ -156,7 +156,7 @@ void FSPermission::Apply(Environment* env, void FSPermission::Drop(Environment* env, PermissionScope scope, - const std::string_view& param) { + std::string_view param) { if (param.empty()) { // Drop all access for this scope if (scope == PermissionScope::kFileSystemRead || @@ -250,7 +250,7 @@ void FSPermission::GrantAccess(PermissionScope perm, const std::string& res) { bool FSPermission::is_granted(Environment* env, PermissionScope perm, - const std::string_view& param = "") const { + std::string_view param = "") const { switch (perm) { case PermissionScope::kFileSystem: return allow_all_in_ && allow_all_out_; @@ -287,7 +287,7 @@ void FSPermission::RadixTree::Clear() { root_node_->is_leaf = false; } -bool FSPermission::RadixTree::Lookup(const std::string_view& s, +bool FSPermission::RadixTree::Lookup(std::string_view s, bool when_empty_return) const { FSPermission::RadixTree::Node* current_node = root_node_; if (current_node->children.empty()) { diff --git a/src/permission/fs_permission.h b/src/permission/fs_permission.h index 0048ea2de36a..8b15b86426b9 100644 --- a/src/permission/fs_permission.h +++ b/src/permission/fs_permission.h @@ -16,14 +16,14 @@ namespace permission { class FSPermission final : public PermissionBase { public: void Apply(Environment* env, - const std::vector& allow, + std::span allow, PermissionScope scope) override; void Drop(Environment* env, PermissionScope scope, - const std::string_view& param = "") override; + std::string_view param) override; bool is_granted(Environment* env, PermissionScope perm, - const std::string_view& param) const override; + std::string_view param) const override; struct RadixTree { struct Node { @@ -146,8 +146,8 @@ class FSPermission final : public PermissionBase { ~RadixTree(); void Insert(const std::string& s); void Clear(); - bool Lookup(const std::string_view& s) const { return Lookup(s, false); } - bool Lookup(const std::string_view& s, bool when_empty_return) const; + bool Lookup(std::string_view s) const { return Lookup(s, false); } + bool Lookup(std::string_view s, bool when_empty_return) const; private: Node* root_node_; diff --git a/src/permission/inspector_permission.cc b/src/permission/inspector_permission.cc index ee775e778dcf..d884ec4d8208 100644 --- a/src/permission/inspector_permission.cc +++ b/src/permission/inspector_permission.cc @@ -9,20 +9,20 @@ namespace permission { // Currently, Inspector manage a single state // Once denied, it's always denied void InspectorPermission::Apply(Environment* env, - const std::vector& allow, + std::span allow, PermissionScope scope) { deny_all_ = true; } void InspectorPermission::Drop(Environment* env, PermissionScope scope, - const std::string_view& param) { + std::string_view param) { deny_all_ = true; } bool InspectorPermission::is_granted(Environment* env, PermissionScope perm, - const std::string_view& param) const { + std::string_view param) const { return deny_all_ == false; } diff --git a/src/permission/inspector_permission.h b/src/permission/inspector_permission.h index d851fb2fa253..2490149caa91 100644 --- a/src/permission/inspector_permission.h +++ b/src/permission/inspector_permission.h @@ -13,14 +13,14 @@ namespace permission { class InspectorPermission final : public PermissionBase { public: void Apply(Environment* env, - const std::vector& allow, + std::span allow, PermissionScope scope) override; void Drop(Environment* env, PermissionScope scope, - const std::string_view& param = "") override; + std::string_view param) override; bool is_granted(Environment* env, PermissionScope perm, - const std::string_view& param = "") const override; + std::string_view param) const override; private: bool deny_all_; diff --git a/src/permission/net_permission.cc b/src/permission/net_permission.cc index 5f1cc139aa74..da42215ecfdc 100644 --- a/src/permission/net_permission.cc +++ b/src/permission/net_permission.cc @@ -8,20 +8,20 @@ namespace node { namespace permission { void NetPermission::Apply(Environment* env, - const std::vector& allow, + std::span allow, PermissionScope scope) { allow_net_ = true; } void NetPermission::Drop(Environment* env, PermissionScope scope, - const std::string_view& param) { + std::string_view param) { allow_net_ = false; } bool NetPermission::is_granted(Environment* env, PermissionScope perm, - const std::string_view& param) const { + std::string_view param) const { return allow_net_; } diff --git a/src/permission/net_permission.h b/src/permission/net_permission.h index 26b055b255a6..23b643a50222 100644 --- a/src/permission/net_permission.h +++ b/src/permission/net_permission.h @@ -13,14 +13,14 @@ namespace permission { class NetPermission final : public PermissionBase { public: void Apply(Environment* env, - const std::vector& allow, + std::span allow, PermissionScope scope) override; void Drop(Environment* env, PermissionScope scope, - const std::string_view& param = "") override; + std::string_view param) override; bool is_granted(Environment* env, PermissionScope perm, - const std::string_view& param) const override; + std::string_view param) const override; private: bool allow_net_ = false; diff --git a/src/permission/openssl_store_permission.cc b/src/permission/openssl_store_permission.cc index fcae2772b3f5..e799b5373344 100644 --- a/src/permission/openssl_store_permission.cc +++ b/src/permission/openssl_store_permission.cc @@ -10,20 +10,20 @@ namespace permission { // OpenSSLStorePermission manages a single global deny state for the use of // OpenSSL STORE loaders. void OpenSSLStorePermission::Apply(Environment* env, - const std::vector& allow, + std::span allow, PermissionScope scope) { deny_all_ = true; } void OpenSSLStorePermission::Drop(Environment* env, PermissionScope scope, - const std::string_view& param) { + std::string_view param) { deny_all_ = true; } bool OpenSSLStorePermission::is_granted(Environment* env, PermissionScope perm, - const std::string_view& param) const { + std::string_view param) const { return perm != PermissionScope::kOpenSSLStore || !deny_all_; } diff --git a/src/permission/openssl_store_permission.h b/src/permission/openssl_store_permission.h index d64475228e18..8cac21f9f288 100644 --- a/src/permission/openssl_store_permission.h +++ b/src/permission/openssl_store_permission.h @@ -13,14 +13,14 @@ namespace permission { class OpenSSLStorePermission final : public PermissionBase { public: void Apply(Environment* env, - const std::vector& allow, + std::span allow, PermissionScope scope) override; void Drop(Environment* env, PermissionScope scope, - const std::string_view& param = "") override; + std::string_view param) override; bool is_granted(Environment* env, PermissionScope perm, - const std::string_view& param = "") const override; + std::string_view param) const override; private: bool deny_all_ = false; diff --git a/src/permission/permission.cc b/src/permission/permission.cc index 6bf3b0fe04eb..f16fafe5876e 100644 --- a/src/permission/permission.cc +++ b/src/permission/permission.cc @@ -59,7 +59,7 @@ constexpr std::string_view GetDiagnosticsChannelName(PermissionScope scope) { } } -Local GetPermissionDiagnosicsTemplate(Environment* env) { +Local GetPermissionDiagnosticsTemplate(Environment* env) { auto tmpl = env->permission_diagnostic_channel_message(); if (tmpl.IsEmpty()) { static constexpr std::string_view names[] = { @@ -93,7 +93,7 @@ static void Drop(const FunctionCallbackInfo& args) { } } - env->permission()->Drop(env, scope); + env->permission()->Drop(env, scope, ""); } // permission.has('fs.in', '/tmp/') @@ -125,8 +125,8 @@ static void Has(const FunctionCallbackInfo& args) { #define V(Name, label, _, __) \ if (perm == PermissionScope::k##Name) return env->Name##_permission_string(); -v8::Local Permission::PermissionToString( - Environment* env, const PermissionScope perm) { +v8::Local Permission::PermissionToString(Environment* env, + PermissionScope perm) { PERMISSIONS(V) UNREACHABLE(); } @@ -134,7 +134,7 @@ v8::Local Permission::PermissionToString( #define V(Name, label, _, __) \ if (perm == label) return PermissionScope::k##Name; -PermissionScope Permission::StringToPermission(const std::string& perm) { +PermissionScope Permission::StringToPermission(std::string_view perm) { PERMISSIONS(V) return PermissionScope::kPermissionsRoot; } @@ -206,7 +206,7 @@ const char* GetErrorFlagSuggestion(node::permission::PermissionScope perm) { MaybeLocal CreateAccessDeniedError(Environment* env, PermissionScope perm, - const std::string_view& res) { + std::string_view res) { const char* suggestion = GetErrorFlagSuggestion(perm); Local err = ERR_ACCESS_DENIED( env->isolate(), "Access to this API has been restricted. %s", suggestion); @@ -226,7 +226,7 @@ MaybeLocal CreateAccessDeniedError(Environment* env, void Permission::ThrowAccessDenied(Environment* env, PermissionScope perm, - const std::string_view& res) { + std::string_view res) { Local err; if (CreateAccessDeniedError(env, perm, res).ToLocal(&err)) { env->isolate()->ThrowException(err); @@ -238,7 +238,7 @@ void Permission::ThrowAccessDenied(Environment* env, void Permission::AsyncThrowAccessDenied(Environment* env, fs::FSReqBase* req_wrap, PermissionScope perm, - const std::string_view& res) { + std::string_view res) { Local err; if (CreateAccessDeniedError(env, perm, res).ToLocal(&err)) { return req_wrap->Reject(err); @@ -260,8 +260,8 @@ void Permission::EnableWarningOnly() { } bool Permission::is_scope_granted(Environment* env, - const PermissionScope permission, - const std::string_view& res) const { + PermissionScope permission, + std::string_view res) const { CHECK(permission != PermissionScope::kPermissionsRoot && permission != PermissionScope::kPermissionsCount); auto perm_node = nodes_.find(permission); @@ -284,7 +284,7 @@ bool Permission::is_scope_granted(Environment* env, }; ch->Publish( env, - GetPermissionDiagnosicsTemplate(env)->NewInstance(context, values)); + GetPermissionDiagnosticsTemplate(env)->NewInstance(context, values)); publishing_ = false; } } @@ -313,7 +313,7 @@ BaseObjectPtr Permission::GetOrCreateChannel( } void Permission::Apply(Environment* env, - const std::vector& allow, + std::span allow, PermissionScope scope) { auto permission = nodes_.find(scope); if (permission != nodes_.end()) { @@ -323,7 +323,7 @@ void Permission::Apply(Environment* env, void Permission::Drop(Environment* env, PermissionScope scope, - const std::string_view& param) { + std::string_view param) { CHECK(scope != PermissionScope::kPermissionsRoot && scope != PermissionScope::kPermissionsCount); auto permission = nodes_.find(scope); @@ -347,7 +347,7 @@ void Permission::Drop(Environment* env, }; ch->Publish( env, - GetPermissionDiagnosicsTemplate(env)->NewInstance(context, values)); + GetPermissionDiagnosticsTemplate(env)->NewInstance(context, values)); publishing_ = false; } } diff --git a/src/permission/permission.h b/src/permission/permission.h index 674d99c1409a..dd2d9b16ef81 100644 --- a/src/permission/permission.h +++ b/src/permission/permission.h @@ -100,8 +100,8 @@ class Permission { Permission(); FORCE_INLINE bool is_granted(Environment* env, - const PermissionScope permission, - const std::string_view& res = "") const { + PermissionScope permission, + std::string_view res = "") const { if (!enabled_) [[likely]] { return true; } @@ -112,32 +112,30 @@ class Permission { FORCE_INLINE bool warning_only() const { return warning_only_; } - static PermissionScope StringToPermission(const std::string& perm); + static PermissionScope StringToPermission(std::string_view perm); static v8::Local PermissionToString(Environment* env, PermissionScope perm); static void ThrowAccessDenied(Environment* env, PermissionScope perm, - const std::string_view& res); + std::string_view res); static void AsyncThrowAccessDenied(Environment* env, fs::FSReqBase* req_wrap, PermissionScope perm, - const std::string_view& res); + std::string_view res); // CLI Call void Apply(Environment* env, - const std::vector& allow, + std::span allow, PermissionScope scope); // Runtime Call - void Drop(Environment* env, - PermissionScope scope, - const std::string_view& param = ""); + void Drop(Environment* env, PermissionScope scope, std::string_view param); void EnablePermissions(); void EnableWarningOnly(); private: COLD_NOINLINE bool is_scope_granted(Environment* env, - const PermissionScope permission, - const std::string_view& res = "") const; + PermissionScope permission, + std::string_view res = "") const; BaseObjectPtr GetOrCreateChannel( Environment* env, PermissionScope scope) const; @@ -155,7 +153,7 @@ class Permission { v8::MaybeLocal CreateAccessDeniedError(Environment* env, PermissionScope perm, - const std::string_view& res); + std::string_view res); } // namespace permission diff --git a/src/permission/permission_base.h b/src/permission/permission_base.h index 66bfd33a8787..658175611ecd 100644 --- a/src/permission/permission_base.h +++ b/src/permission/permission_base.h @@ -3,10 +3,9 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS -#include +#include #include #include -#include "v8.h" namespace node { @@ -60,15 +59,16 @@ enum class PermissionScope { class PermissionBase { public: + virtual ~PermissionBase() = default; virtual void Apply(Environment* env, - const std::vector& allow, + std::span allow, PermissionScope scope) = 0; virtual void Drop(Environment* env, PermissionScope scope, - const std::string_view& param = "") = 0; + std::string_view param) = 0; virtual bool is_granted(Environment* env, PermissionScope perm, - const std::string_view& param = "") const = 0; + std::string_view param) const = 0; }; } // namespace permission diff --git a/src/permission/wasi_permission.cc b/src/permission/wasi_permission.cc index 00ce927eb625..cf3be848c31f 100644 --- a/src/permission/wasi_permission.cc +++ b/src/permission/wasi_permission.cc @@ -10,20 +10,20 @@ namespace permission { // Currently, WASIPermission manage a single state // Once denied, it's always denied void WASIPermission::Apply(Environment* env, - const std::vector& allow, + std::span allow, PermissionScope scope) { deny_all_ = true; } void WASIPermission::Drop(Environment* env, PermissionScope scope, - const std::string_view& param) { + std::string_view param) { deny_all_ = true; } bool WASIPermission::is_granted(Environment* env, PermissionScope perm, - const std::string_view& param) const { + std::string_view param) const { return deny_all_ == false; } diff --git a/src/permission/wasi_permission.h b/src/permission/wasi_permission.h index b5cdaca928dd..1d341c1a7334 100644 --- a/src/permission/wasi_permission.h +++ b/src/permission/wasi_permission.h @@ -13,14 +13,14 @@ namespace permission { class WASIPermission final : public PermissionBase { public: void Apply(Environment* env, - const std::vector& allow, + std::span allow, PermissionScope scope) override; void Drop(Environment* env, PermissionScope scope, - const std::string_view& param = "") override; + std::string_view param) override; bool is_granted(Environment* env, PermissionScope perm, - const std::string_view& param = "") const override; + std::string_view param) const override; private: bool deny_all_; diff --git a/src/permission/worker_permission.cc b/src/permission/worker_permission.cc index aa6867eb1e0f..395876d29cce 100644 --- a/src/permission/worker_permission.cc +++ b/src/permission/worker_permission.cc @@ -10,20 +10,20 @@ namespace permission { // Currently, PolicyDenyWorker manage a single state // Once denied, it's always denied void WorkerPermission::Apply(Environment* env, - const std::vector& allow, + std::span allow, PermissionScope scope) { deny_all_ = true; } void WorkerPermission::Drop(Environment* env, PermissionScope scope, - const std::string_view& param) { + std::string_view param) { deny_all_ = true; } bool WorkerPermission::is_granted(Environment* env, PermissionScope perm, - const std::string_view& param) const { + std::string_view param) const { return deny_all_ == false; } diff --git a/src/permission/worker_permission.h b/src/permission/worker_permission.h index fc7abe0d50f4..4480b75646d2 100644 --- a/src/permission/worker_permission.h +++ b/src/permission/worker_permission.h @@ -13,14 +13,14 @@ namespace permission { class WorkerPermission final : public PermissionBase { public: void Apply(Environment* env, - const std::vector& allow, + std::span allow, PermissionScope scope) override; void Drop(Environment* env, PermissionScope scope, - const std::string_view& param = "") override; + std::string_view param) override; bool is_granted(Environment* env, PermissionScope perm, - const std::string_view& param = "") const override; + std::string_view param) const override; private: bool deny_all_; From efb649ec2668c5b39593dd80118e1bc49e843c42 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sat, 8 Aug 2026 18:31:22 -0700 Subject: [PATCH 132/344] src: make permission storage a bit more efficient Use a fixed array rather than an unordered list Signed-off-by: James M Snell PR-URL: https://github.com/nodejs/node/pull/65158 Reviewed-By: Xuguang Mei Reviewed-By: Stephen Belanger --- src/permission/permission.cc | 72 ++++++++++++++++-------------------- src/permission/permission.h | 11 ++++-- 2 files changed, 39 insertions(+), 44 deletions(-) diff --git a/src/permission/permission.cc b/src/permission/permission.cc index f16fafe5876e..fd4b1ab34907 100644 --- a/src/permission/permission.cc +++ b/src/permission/permission.cc @@ -141,53 +141,49 @@ PermissionScope Permission::StringToPermission(std::string_view perm) { #undef V Permission::Permission() : enabled_(false), warning_only_(false) { - std::shared_ptr fs = std::make_shared(); - std::shared_ptr child_p = - std::make_shared(); - std::shared_ptr worker_t = - std::make_shared(); - std::shared_ptr inspector = - std::make_shared(); - std::shared_ptr wasi = std::make_shared(); - std::shared_ptr net = std::make_shared(); - std::shared_ptr addon = std::make_shared(); - std::shared_ptr ffi = std::make_shared(); - std::shared_ptr openssl_store = - std::make_shared(); + auto fs = std::make_shared(); + auto child_p = std::make_shared(); + auto worker_t = std::make_shared(); + auto inspector = std::make_shared(); + auto wasi = std::make_shared(); + auto net = std::make_shared(); + auto addon = std::make_shared(); + auto ffi = std::make_shared(); + auto openssl_store = std::make_shared(); #define V(Name, _, __, ___) \ - nodes_.insert(std::make_pair(PermissionScope::k##Name, fs)); + nodes_[static_cast(PermissionScope::k##Name)] = fs; FILESYSTEM_PERMISSIONS(V) #undef V #define V(Name, _, __, ___) \ - nodes_.insert(std::make_pair(PermissionScope::k##Name, child_p)); + nodes_[static_cast(PermissionScope::k##Name)] = child_p; CHILD_PROCESS_PERMISSIONS(V) #undef V #define V(Name, _, __, ___) \ - nodes_.insert(std::make_pair(PermissionScope::k##Name, worker_t)); + nodes_[static_cast(PermissionScope::k##Name)] = worker_t; WORKER_THREADS_PERMISSIONS(V) #undef V #define V(Name, _, __, ___) \ - nodes_.insert(std::make_pair(PermissionScope::k##Name, inspector)); + nodes_[static_cast(PermissionScope::k##Name)] = inspector; INSPECTOR_PERMISSIONS(V) #undef V #define V(Name, _, __, ___) \ - nodes_.insert(std::make_pair(PermissionScope::k##Name, wasi)); + nodes_[static_cast(PermissionScope::k##Name)] = wasi; WASI_PERMISSIONS(V) #undef V #define V(Name, _, __, ___) \ - nodes_.insert(std::make_pair(PermissionScope::k##Name, net)); + nodes_[static_cast(PermissionScope::k##Name)] = net; NET_PERMISSIONS(V) #undef V #define V(Name, _, __, ___) \ - nodes_.insert(std::make_pair(PermissionScope::k##Name, addon)); + nodes_[static_cast(PermissionScope::k##Name)] = addon; ADDON_PERMISSIONS(V) #undef V #define V(Name, _, __, ___) \ - nodes_.insert(std::make_pair(PermissionScope::k##Name, ffi)); + nodes_[static_cast(PermissionScope::k##Name)] = ffi; FFI_PERMISSIONS(V) #undef V #define V(Name, _, __, ___) \ - nodes_.insert(std::make_pair(PermissionScope::k##Name, openssl_store)); + nodes_[static_cast(PermissionScope::k##Name)] = openssl_store; OPENSSL_STORE_PERMISSIONS(V) #undef V } @@ -264,10 +260,10 @@ bool Permission::is_scope_granted(Environment* env, std::string_view res) const { CHECK(permission != PermissionScope::kPermissionsRoot && permission != PermissionScope::kPermissionsCount); - auto perm_node = nodes_.find(permission); + auto& perm_node = nodes_[static_cast(permission)]; bool result = false; - if (perm_node != nodes_.end()) { - result = perm_node->second->is_granted(env, permission, res); + if (perm_node) { + result = perm_node->is_granted(env, permission, res); } if (!result && !publishing_) { @@ -296,17 +292,13 @@ BaseObjectPtr Permission::GetOrCreateChannel( Environment* env, PermissionScope scope) const { CHECK(scope != PermissionScope::kPermissionsRoot && scope != PermissionScope::kPermissionsCount); - auto it = channels_.find(scope); - if (it != channels_.end()) { - // Promote weak ref to strong for the duration of this call. - BaseObjectPtr ptr(it->second.get()); - if (ptr) return ptr; - channels_.erase(it); - } + auto& weak_ch = channels_[static_cast(scope)]; + // Promote weak ref to strong for the duration of this call. + BaseObjectPtr ptr(weak_ch.get()); + if (ptr) return ptr; auto channel_name = GetDiagnosticsChannelName(scope); if (auto ch = diagnostics_channel::Channel::Get(env, channel_name)) { - channels_.emplace(scope, - BaseObjectWeakPtr(ch)); + weak_ch = BaseObjectWeakPtr(ch.get()); return ch; } return {}; @@ -315,9 +307,9 @@ BaseObjectPtr Permission::GetOrCreateChannel( void Permission::Apply(Environment* env, std::span allow, PermissionScope scope) { - auto permission = nodes_.find(scope); - if (permission != nodes_.end()) { - permission->second->Apply(env, allow, scope); + auto& perm_node = nodes_[static_cast(scope)]; + if (perm_node) { + perm_node->Apply(env, allow, scope); } } @@ -326,9 +318,9 @@ void Permission::Drop(Environment* env, std::string_view param) { CHECK(scope != PermissionScope::kPermissionsRoot && scope != PermissionScope::kPermissionsCount); - auto permission = nodes_.find(scope); - if (permission != nodes_.end()) { - permission->second->Drop(env, scope, param); + auto& perm_node = nodes_[static_cast(scope)]; + if (perm_node) { + perm_node->Drop(env, scope, param); } // Publish to diagnostics channel so observers can track drops diff --git a/src/permission/permission.h b/src/permission/permission.h index dd2d9b16ef81..d93399d07642 100644 --- a/src/permission/permission.h +++ b/src/permission/permission.h @@ -18,8 +18,8 @@ #include "permission/worker_permission.h" #include "v8.h" +#include #include -#include namespace node { @@ -140,14 +140,17 @@ class Permission { BaseObjectPtr GetOrCreateChannel( Environment* env, PermissionScope scope) const; - std::unordered_map> nodes_; + static constexpr size_t kPermissionCount = + static_cast(PermissionScope::kPermissionsCount); + + std::array, kPermissionCount> nodes_; bool enabled_; bool warning_only_; mutable bool publishing_ = false; // Weak refs: BindingData (via BaseObjectPtr) is the sole owner of Channels. // Using weak refs here avoids keeping Channels alive past Realm teardown. - mutable std::unordered_map> + mutable std::array, + kPermissionCount> channels_; }; From d4ced88c0920460c284c3da68ad4b73ac69b1cc8 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sat, 8 Aug 2026 18:51:52 -0700 Subject: [PATCH 133/344] src: apply a modest performance perf to permissions Improve the way the RadixTree works and apply a fast api call. Signed-off-by: James M Snell PR-URL: https://github.com/nodejs/node/pull/65158 Reviewed-By: Xuguang Mei Reviewed-By: Stephen Belanger --- src/permission/fs_permission.cc | 21 +++++------- src/permission/fs_permission.h | 58 ++++++++++++++++++++------------- src/permission/permission.cc | 49 +++++++++++++++++++++++++++- 3 files changed, 93 insertions(+), 35 deletions(-) diff --git a/src/permission/fs_permission.cc b/src/permission/fs_permission.cc index 89f1c6d70c98..c283fb40f091 100644 --- a/src/permission/fs_permission.cc +++ b/src/permission/fs_permission.cc @@ -39,10 +39,8 @@ void FreeRecursivelyNode( return; } - if (node->children.size()) { - for (auto& c : node->children) { - FreeRecursivelyNode(c.second); - } + for (auto& [label, child] : node->children) { + FreeRecursivelyNode(child); } delete node->wildcard_child; @@ -106,7 +104,7 @@ void PrintTree(const node::permission::FSPermission::RadixTree::Node* node, node::DebugCategory::PERMISSION_MODEL, "%s%s\n", indent, node->prefix); } - if (node->children.size() > 0) { + if (!node->children.empty()) { size_t count = 0; size_t total = node->children.size(); @@ -120,10 +118,10 @@ void PrintTree(const node::permission::FSPermission::RadixTree::Node* node, } } - for (const auto& pair : node->children) { + for (const auto& [label, child] : node->children) { count++; bool child_is_last = (count == total); - PrintTree(pair.second, depth + 1, next_branch_prefix, child_is_last); + PrintTree(child, depth + 1, next_branch_prefix, child_is_last); } } } @@ -278,8 +276,8 @@ FSPermission::RadixTree::~RadixTree() { } void FSPermission::RadixTree::Clear() { - for (auto& c : root_node_->children) { - FreeRecursivelyNode(c.second); + for (auto& [label, child] : root_node_->children) { + FreeRecursivelyNode(child); } root_node_->children.clear(); delete root_node_->wildcard_child; @@ -294,15 +292,14 @@ bool FSPermission::RadixTree::Lookup(std::string_view s, return when_empty_return; } size_t parent_node_prefix_len = current_node->prefix.length(); - const std::string path(s); - auto path_len = path.length(); + auto path_len = s.length(); while (true) { if (parent_node_prefix_len == path_len && current_node->IsEndNode()) { return true; } - auto node = current_node->NextNode(path, parent_node_prefix_len); + auto node = current_node->NextNode(s, parent_node_prefix_len); if (node == nullptr) { return false; } diff --git a/src/permission/fs_permission.h b/src/permission/fs_permission.h index 8b15b86426b9..6af29ce7bd1b 100644 --- a/src/permission/fs_permission.h +++ b/src/permission/fs_permission.h @@ -5,7 +5,7 @@ #include "v8.h" -#include +#include #include "permission/permission_base.h" #include "util.h" @@ -28,16 +28,30 @@ class FSPermission final : public PermissionBase { struct RadixTree { struct Node { std::string prefix; - std::unordered_map children; - Node* wildcard_child; - bool is_leaf; + std::vector> children; + Node* wildcard_child = nullptr; + bool is_leaf = false; - explicit Node(const std::string& pre) - : prefix(pre), wildcard_child(nullptr), is_leaf(false) {} + explicit Node(std::string_view pre) + : prefix(pre) {} - Node() : wildcard_child(nullptr), is_leaf(false) {} + Node() = default; - Node* CreateChild(const std::string& path_prefix) { + Node* FindChild(char label) const { + for (const auto& [c, node] : children) { + if (c == label) return node; + } + return nullptr; + } + + void SetChild(char label, Node* node) { + for (auto& [c, n] : children) { + if (c == label) { n = node; return; } + } + children.emplace_back(label, node); + } + + Node* CreateChild(std::string_view path_prefix) { if (path_prefix.empty() && !is_leaf) { is_leaf = true; return this; @@ -46,10 +60,11 @@ class FSPermission final : public PermissionBase { CHECK(!path_prefix.empty()); char label = path_prefix[0]; - Node* child = children[label]; + Node* child = FindChild(label); if (child == nullptr) { - children[label] = new Node(path_prefix); - return children[label]; + child = new Node(path_prefix); + children.emplace_back(label, child); + return child; } bool child_was_end_node = child->IsEndNode(); @@ -58,13 +73,13 @@ class FSPermission final : public PermissionBase { size_t prefix_len = path_prefix.length(); for (; i < child->prefix.length(); ++i) { if (i >= prefix_len || path_prefix[i] != child->prefix[i]) { - std::string parent_prefix = child->prefix.substr(0, i); - std::string child_prefix = child->prefix.substr(i); + std::string parent_prefix(child->prefix.substr(0, i)); + std::string child_prefix(child->prefix.substr(i)); child->prefix = child_prefix; Node* split_child = new Node(parent_prefix); - split_child->children[child_prefix[0]] = child; - children[parent_prefix[0]] = split_child; + split_child->children.emplace_back(child_prefix[0], child); + SetChild(parent_prefix[0], split_child); return split_child->CreateChild(path_prefix.substr(i)); } @@ -83,24 +98,23 @@ class FSPermission final : public PermissionBase { return wildcard_child; } - Node* NextNode(const std::string& path, size_t idx) const { + Node* NextNode(std::string_view path, size_t idx) const { if (idx >= path.length()) { return nullptr; } // wildcard node takes precedence if (children.size() > 1) { - auto it = children.find('*'); - if (it != children.end()) { - return it->second; + Node* wc = FindChild('*'); + if (wc != nullptr) { + return wc; } } - auto it = children.find(path[idx]); - if (it == children.end()) { + Node* child = FindChild(path[idx]); + if (child == nullptr) { return nullptr; } - auto child = it->second; // match prefix size_t prefix_len = child->prefix.length(); for (size_t i = 0; i < path.length(); ++i) { diff --git a/src/permission/permission.cc b/src/permission/permission.cc index fd4b1ab34907..cee5172a9ba2 100644 --- a/src/permission/permission.cc +++ b/src/permission/permission.cc @@ -3,12 +3,14 @@ #include "env-inl.h" #include "memory_tracker-inl.h" #include "node.h" +#include "node_debug.h" #include "node_diagnostics_channel.h" #include "node_errors.h" #include "node_external_reference.h" #include "node_file.h" #include "permission/permission_base.h" +#include "v8-fast-api-calls.h" #include "v8-template.h" #include "v8.h" @@ -18,13 +20,16 @@ namespace node { +using v8::CFunction; using v8::Context; using v8::DictionaryTemplate; +using v8::FastApiCallbackOptions; using v8::FunctionCallbackInfo; using v8::IntegrityLevel; using v8::Local; using v8::MaybeLocal; using v8::Object; +using v8::String; using v8::Undefined; using v8::Value; @@ -121,6 +126,47 @@ static void Has(const FunctionCallbackInfo& args) { return args.GetReturnValue().Set(env->permission()->is_granted(env, scope)); } +static bool FastHas(Local receiver, + Local scope_arg, + Local resource_arg, + // NOLINTNEXTLINE(runtime/references) This is V8 api. + FastApiCallbackOptions& options) { + TRACK_V8_FAST_API_CALL("permission.has"); + auto isolate = options.isolate; + v8::HandleScope handle_scope(isolate); + auto context = isolate->GetCurrentContext(); + + Environment* env = Environment::GetCurrent(context); + + Local str; + if (!scope_arg->ToString(context).ToLocal(&str)) { + return false; + } + Utf8Value utf8_scope(isolate, str); + PermissionScope scope = + Permission::StringToPermission(utf8_scope.ToStringView()); + if (scope == PermissionScope::kPermissionsRoot) { + return false; + } + + if (resource_arg->IsUndefined()) { + return env->permission()->is_granted(env, scope); + } + + Local res_str; + if (!resource_arg->ToString(context).ToLocal(&res_str)) { + return false; + } + Utf8Value utf8_res(isolate, res_str); + if (utf8_res.length() == 0) { + return false; + } + + return env->permission()->is_granted(env, scope, utf8_res.ToStringView()); +} + +static CFunction fast_has_(CFunction::Make(FastHas)); + } // namespace #define V(Name, label, _, __) \ @@ -349,7 +395,7 @@ void Initialize(Local target, Local unused, Local context, void* priv) { - SetMethodNoSideEffect(context, target, "has", Has); + SetFastMethodNoSideEffect(context, target, "has", Has, &fast_has_); SetMethod(context, target, "drop", Drop); target->SetIntegrityLevel(context, IntegrityLevel::kFrozen).FromJust(); @@ -357,6 +403,7 @@ void Initialize(Local target, void RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(Has); + registry->Register(fast_has_); registry->Register(Drop); } From 5954b13f887c349aad80e628a8bf9f5e76cc4f37 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sat, 8 Aug 2026 19:06:43 -0700 Subject: [PATCH 134/344] src: simplify includes in permissions Signed-off-by: James M Snell PR-URL: https://github.com/nodejs/node/pull/65158 Reviewed-By: Xuguang Mei Reviewed-By: Stephen Belanger --- src/permission/child_process_permission.cc | 1 - src/permission/child_process_permission.h | 1 - src/permission/ffi_permission.cc | 1 - src/permission/ffi_permission.h | 1 - src/permission/fs_permission.cc | 7 ++----- src/permission/fs_permission.h | 2 -- src/permission/net_permission.cc | 1 - src/permission/openssl_store_permission.cc | 1 - src/permission/openssl_store_permission.h | 1 - src/permission/permission.cc | 12 +++++++++--- src/permission/permission.h | 11 ----------- src/permission/wasi_permission.cc | 1 - src/permission/wasi_permission.h | 1 - src/permission/worker_permission.cc | 1 - src/permission/worker_permission.h | 1 - 15 files changed, 11 insertions(+), 32 deletions(-) diff --git a/src/permission/child_process_permission.cc b/src/permission/child_process_permission.cc index 25c9713e0570..2f1c1796db15 100644 --- a/src/permission/child_process_permission.cc +++ b/src/permission/child_process_permission.cc @@ -1,7 +1,6 @@ #include "child_process_permission.h" #include -#include namespace node { diff --git a/src/permission/child_process_permission.h b/src/permission/child_process_permission.h index 59ab74bb5116..1454c8eef51b 100644 --- a/src/permission/child_process_permission.h +++ b/src/permission/child_process_permission.h @@ -3,7 +3,6 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS -#include #include "permission/permission_base.h" namespace node { diff --git a/src/permission/ffi_permission.cc b/src/permission/ffi_permission.cc index 674394144471..2989ae4179af 100644 --- a/src/permission/ffi_permission.cc +++ b/src/permission/ffi_permission.cc @@ -1,7 +1,6 @@ #include "permission/ffi_permission.h" #include -#include namespace node { diff --git a/src/permission/ffi_permission.h b/src/permission/ffi_permission.h index fcb8b403c725..9562fce9f54a 100644 --- a/src/permission/ffi_permission.h +++ b/src/permission/ffi_permission.h @@ -3,7 +3,6 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS -#include #include "permission/permission_base.h" namespace node { diff --git a/src/permission/fs_permission.cc b/src/permission/fs_permission.cc index c283fb40f091..b2f3cbb31a6e 100644 --- a/src/permission/fs_permission.cc +++ b/src/permission/fs_permission.cc @@ -1,15 +1,12 @@ #include "fs_permission.h" -#include "base_object-inl.h" #include "debug_utils-inl.h" #include "env.h" #include "path.h" -#include "v8.h" #include -#include -#include #include -#include +#include +#include #include #include #include diff --git a/src/permission/fs_permission.h b/src/permission/fs_permission.h index 6af29ce7bd1b..e26c7a44feaa 100644 --- a/src/permission/fs_permission.h +++ b/src/permission/fs_permission.h @@ -3,8 +3,6 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS -#include "v8.h" - #include #include "permission/permission_base.h" #include "util.h" diff --git a/src/permission/net_permission.cc b/src/permission/net_permission.cc index da42215ecfdc..10b635e82e6d 100644 --- a/src/permission/net_permission.cc +++ b/src/permission/net_permission.cc @@ -1,6 +1,5 @@ #include "net_permission.h" -#include #include namespace node { diff --git a/src/permission/openssl_store_permission.cc b/src/permission/openssl_store_permission.cc index e799b5373344..11d4ff82faf8 100644 --- a/src/permission/openssl_store_permission.cc +++ b/src/permission/openssl_store_permission.cc @@ -1,7 +1,6 @@ #include "permission/openssl_store_permission.h" #include -#include namespace node { diff --git a/src/permission/openssl_store_permission.h b/src/permission/openssl_store_permission.h index 8cac21f9f288..418e93e0d899 100644 --- a/src/permission/openssl_store_permission.h +++ b/src/permission/openssl_store_permission.h @@ -3,7 +3,6 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS -#include #include "permission/permission_base.h" namespace node { diff --git a/src/permission/permission.cc b/src/permission/permission.cc index cee5172a9ba2..e7e8ba6b2ea2 100644 --- a/src/permission/permission.cc +++ b/src/permission/permission.cc @@ -1,7 +1,5 @@ #include "permission.h" -#include "base_object-inl.h" #include "env-inl.h" -#include "memory_tracker-inl.h" #include "node.h" #include "node_debug.h" #include "node_diagnostics_channel.h" @@ -9,6 +7,15 @@ #include "node_external_reference.h" #include "node_file.h" +#include "permission/addon_permission.h" +#include "permission/child_process_permission.h" +#include "permission/ffi_permission.h" +#include "permission/fs_permission.h" +#include "permission/inspector_permission.h" +#include "permission/net_permission.h" +#include "permission/openssl_store_permission.h" +#include "permission/wasi_permission.h" +#include "permission/worker_permission.h" #include "permission/permission_base.h" #include "v8-fast-api-calls.h" #include "v8-template.h" @@ -16,7 +23,6 @@ #include #include -#include namespace node { diff --git a/src/permission/permission.h b/src/permission/permission.h index d93399d07642..6ed211f955b4 100644 --- a/src/permission/permission.h +++ b/src/permission/permission.h @@ -5,18 +5,7 @@ #include "debug_utils.h" #include "node_diagnostics_channel.h" -#include "node_options.h" -#include "permission/addon_permission.h" -#include "permission/child_process_permission.h" -#include "permission/ffi_permission.h" -#include "permission/fs_permission.h" -#include "permission/inspector_permission.h" -#include "permission/net_permission.h" -#include "permission/openssl_store_permission.h" #include "permission/permission_base.h" -#include "permission/wasi_permission.h" -#include "permission/worker_permission.h" -#include "v8.h" #include #include diff --git a/src/permission/wasi_permission.cc b/src/permission/wasi_permission.cc index cf3be848c31f..5891edc92aa3 100644 --- a/src/permission/wasi_permission.cc +++ b/src/permission/wasi_permission.cc @@ -1,7 +1,6 @@ #include "permission/wasi_permission.h" #include -#include namespace node { diff --git a/src/permission/wasi_permission.h b/src/permission/wasi_permission.h index 1d341c1a7334..fbca0e4e0879 100644 --- a/src/permission/wasi_permission.h +++ b/src/permission/wasi_permission.h @@ -3,7 +3,6 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS -#include #include "permission/permission_base.h" namespace node { diff --git a/src/permission/worker_permission.cc b/src/permission/worker_permission.cc index 395876d29cce..bdf0519d3bb0 100644 --- a/src/permission/worker_permission.cc +++ b/src/permission/worker_permission.cc @@ -1,7 +1,6 @@ #include "permission/worker_permission.h" #include -#include namespace node { diff --git a/src/permission/worker_permission.h b/src/permission/worker_permission.h index 4480b75646d2..ba4b45319ba9 100644 --- a/src/permission/worker_permission.h +++ b/src/permission/worker_permission.h @@ -3,7 +3,6 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS -#include #include "permission/permission_base.h" namespace node { From 981cfa537d2dbb54b312f79ad82428a261761a51 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sat, 8 Aug 2026 19:21:40 -0700 Subject: [PATCH 135/344] src: simplify permissions with BooleanPermissions Most of the PermissionBase subclasses used the identical simple pattern. Rather than define a bunch of individual identical permissions, use a single utility definition. Special cases like FsPermission are still possible but the simple case is kept... well, simple. Signed-off-by: James M Snell PR-URL: https://github.com/nodejs/node/pull/65158 Reviewed-By: Xuguang Mei Reviewed-By: Stephen Belanger --- node.gyp | 18 +------- src/permission/addon_permission.cc | 30 ------------ src/permission/addon_permission.h | 34 -------------- src/permission/boolean_permission.h | 54 ++++++++++++++++++++++ src/permission/child_process_permission.cc | 30 ------------ src/permission/child_process_permission.h | 33 ------------- src/permission/ffi_permission.cc | 29 ------------ src/permission/ffi_permission.h | 33 ------------- src/permission/fs_permission.h | 8 ++-- src/permission/inspector_permission.cc | 30 ------------ src/permission/inspector_permission.h | 34 -------------- src/permission/net_permission.cc | 28 ----------- src/permission/net_permission.h | 34 -------------- src/permission/openssl_store_permission.cc | 30 ------------ src/permission/openssl_store_permission.h | 33 ------------- src/permission/permission.cc | 45 +++--------------- src/permission/wasi_permission.cc | 30 ------------ src/permission/wasi_permission.h | 33 ------------- src/permission/worker_permission.cc | 30 ------------ src/permission/worker_permission.h | 33 ------------- 20 files changed, 68 insertions(+), 561 deletions(-) delete mode 100644 src/permission/addon_permission.cc delete mode 100644 src/permission/addon_permission.h create mode 100644 src/permission/boolean_permission.h delete mode 100644 src/permission/child_process_permission.cc delete mode 100644 src/permission/child_process_permission.h delete mode 100644 src/permission/ffi_permission.cc delete mode 100644 src/permission/ffi_permission.h delete mode 100644 src/permission/inspector_permission.cc delete mode 100644 src/permission/inspector_permission.h delete mode 100644 src/permission/net_permission.cc delete mode 100644 src/permission/net_permission.h delete mode 100644 src/permission/openssl_store_permission.cc delete mode 100644 src/permission/openssl_store_permission.h delete mode 100644 src/permission/wasi_permission.cc delete mode 100644 src/permission/wasi_permission.h delete mode 100644 src/permission/worker_permission.cc delete mode 100644 src/permission/worker_permission.h diff --git a/node.gyp b/node.gyp index 82913b344b29..66196b411062 100644 --- a/node.gyp +++ b/node.gyp @@ -177,16 +177,8 @@ 'src/node_worker.cc', 'src/node_zlib.cc', 'src/path.cc', - 'src/permission/child_process_permission.cc', - 'src/permission/openssl_store_permission.cc', - 'src/permission/ffi_permission.cc', 'src/permission/fs_permission.cc', - 'src/permission/inspector_permission.cc', 'src/permission/permission.cc', - 'src/permission/wasi_permission.cc', - 'src/permission/worker_permission.cc', - 'src/permission/net_permission.cc', - 'src/permission/addon_permission.cc', 'src/pipe_wrap.cc', 'src/process_wrap.cc', 'src/signal_wrap.cc', @@ -314,16 +306,10 @@ 'src/node_watchdog.h', 'src/node_worker.h', 'src/path.h', - 'src/permission/child_process_permission.h', - 'src/permission/openssl_store_permission.h', - 'src/permission/ffi_permission.h', + 'src/permission/boolean_permission.h', 'src/permission/fs_permission.h', - 'src/permission/inspector_permission.h', 'src/permission/permission.h', - 'src/permission/wasi_permission.h', - 'src/permission/worker_permission.h', - 'src/permission/net_permission.h', - 'src/permission/addon_permission.h', + 'src/permission/permission_base.h', 'src/pipe_wrap.h', 'src/req_wrap.h', 'src/req_wrap-inl.h', diff --git a/src/permission/addon_permission.cc b/src/permission/addon_permission.cc deleted file mode 100644 index 249b266c5919..000000000000 --- a/src/permission/addon_permission.cc +++ /dev/null @@ -1,30 +0,0 @@ -#include "addon_permission.h" - -#include - -namespace node { - -namespace permission { - -// Currently, Addon manage a single state -// Once denied, it's always denied -void AddonPermission::Apply(Environment* env, - std::span allow, - PermissionScope scope) { - deny_all_ = true; -} - -void AddonPermission::Drop(Environment* env, - PermissionScope scope, - std::string_view param) { - deny_all_ = true; -} - -bool AddonPermission::is_granted(Environment* env, - PermissionScope perm, - std::string_view param) const { - return deny_all_ == false; -} - -} // namespace permission -} // namespace node diff --git a/src/permission/addon_permission.h b/src/permission/addon_permission.h deleted file mode 100644 index 04e2ed6fed9a..000000000000 --- a/src/permission/addon_permission.h +++ /dev/null @@ -1,34 +0,0 @@ -#ifndef SRC_PERMISSION_ADDON_PERMISSION_H_ -#define SRC_PERMISSION_ADDON_PERMISSION_H_ - -#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS - -#include -#include "permission/permission_base.h" - -namespace node { - -namespace permission { - -class AddonPermission final : public PermissionBase { - public: - void Apply(Environment* env, - std::span allow, - PermissionScope scope) override; - void Drop(Environment* env, - PermissionScope scope, - std::string_view param) override; - bool is_granted(Environment* env, - PermissionScope perm, - std::string_view param) const override; - - private: - bool deny_all_; -}; - -} // namespace permission - -} // namespace node - -#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS -#endif // SRC_PERMISSION_ADDON_PERMISSION_H_ diff --git a/src/permission/boolean_permission.h b/src/permission/boolean_permission.h new file mode 100644 index 000000000000..d1a30972dcee --- /dev/null +++ b/src/permission/boolean_permission.h @@ -0,0 +1,54 @@ +#ifndef SRC_PERMISSION_BOOLEAN_PERMISSION_H_ +#define SRC_PERMISSION_BOOLEAN_PERMISSION_H_ + +#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS + +#include "permission/permission_base.h" + +namespace node { + +namespace permission { + +// A simple boolean permission that either denies or allows access. +// Used for permission scopes that don't need per-resource granularity. +template +class BooleanPermission final : public PermissionBase { + public: + void Apply(Environment* env, + std::span allow, + PermissionScope scope) override { + flag_ = true; + } + + void Drop(Environment* env, + PermissionScope scope, + std::string_view param) override { + flag_ = deny_only; + } + + bool is_granted(Environment* env, + PermissionScope perm, + std::string_view param) const override { + if constexpr (deny_only) { + return !flag_; + } else { + return flag_; + } + } + + private: + bool flag_ = false; +}; + +// Once denied, the permission cannot be re-granted. +using DenyOnlyPermission = BooleanPermission; + +// Apply grants access, Drop revokes. +using AllowRevokePermission = BooleanPermission; + +} // namespace permission + +} // namespace node + +#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS +#endif // SRC_PERMISSION_BOOLEAN_PERMISSION_H_ diff --git a/src/permission/child_process_permission.cc b/src/permission/child_process_permission.cc deleted file mode 100644 index 2f1c1796db15..000000000000 --- a/src/permission/child_process_permission.cc +++ /dev/null @@ -1,30 +0,0 @@ -#include "child_process_permission.h" - -#include - -namespace node { - -namespace permission { - -// Currently, ChildProcess manage a single state -// Once denied, it's always denied -void ChildProcessPermission::Apply(Environment* env, - std::span allow, - PermissionScope scope) { - deny_all_ = true; -} - -void ChildProcessPermission::Drop(Environment* env, - PermissionScope scope, - std::string_view param) { - deny_all_ = true; -} - -bool ChildProcessPermission::is_granted(Environment* env, - PermissionScope perm, - std::string_view param) const { - return deny_all_ == false; -} - -} // namespace permission -} // namespace node diff --git a/src/permission/child_process_permission.h b/src/permission/child_process_permission.h deleted file mode 100644 index 1454c8eef51b..000000000000 --- a/src/permission/child_process_permission.h +++ /dev/null @@ -1,33 +0,0 @@ -#ifndef SRC_PERMISSION_CHILD_PROCESS_PERMISSION_H_ -#define SRC_PERMISSION_CHILD_PROCESS_PERMISSION_H_ - -#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS - -#include "permission/permission_base.h" - -namespace node { - -namespace permission { - -class ChildProcessPermission final : public PermissionBase { - public: - void Apply(Environment* env, - std::span allow, - PermissionScope scope) override; - void Drop(Environment* env, - PermissionScope scope, - std::string_view param) override; - bool is_granted(Environment* env, - PermissionScope perm, - std::string_view param) const override; - - private: - bool deny_all_; -}; - -} // namespace permission - -} // namespace node - -#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS -#endif // SRC_PERMISSION_CHILD_PROCESS_PERMISSION_H_ diff --git a/src/permission/ffi_permission.cc b/src/permission/ffi_permission.cc deleted file mode 100644 index 2989ae4179af..000000000000 --- a/src/permission/ffi_permission.cc +++ /dev/null @@ -1,29 +0,0 @@ -#include "permission/ffi_permission.h" - -#include - -namespace node { - -namespace permission { - -// Currently, FFIPermission manages a single global deny state for FFI. -void FFIPermission::Apply(Environment* env, - std::span allow, - PermissionScope scope) { - deny_all_ = true; -} - -void FFIPermission::Drop(Environment* env, - PermissionScope scope, - std::string_view param) { - deny_all_ = true; -} - -bool FFIPermission::is_granted(Environment* env, - PermissionScope perm, - std::string_view param) const { - return perm != PermissionScope::kFFI || !deny_all_; -} - -} // namespace permission -} // namespace node diff --git a/src/permission/ffi_permission.h b/src/permission/ffi_permission.h deleted file mode 100644 index 9562fce9f54a..000000000000 --- a/src/permission/ffi_permission.h +++ /dev/null @@ -1,33 +0,0 @@ -#ifndef SRC_PERMISSION_FFI_PERMISSION_H_ -#define SRC_PERMISSION_FFI_PERMISSION_H_ - -#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS - -#include "permission/permission_base.h" - -namespace node { - -namespace permission { - -class FFIPermission final : public PermissionBase { - public: - void Apply(Environment* env, - std::span allow, - PermissionScope scope) override; - void Drop(Environment* env, - PermissionScope scope, - std::string_view param) override; - bool is_granted(Environment* env, - PermissionScope perm, - std::string_view param) const override; - - private: - bool deny_all_ = false; -}; - -} // namespace permission - -} // namespace node - -#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS -#endif // SRC_PERMISSION_FFI_PERMISSION_H_ diff --git a/src/permission/fs_permission.h b/src/permission/fs_permission.h index e26c7a44feaa..3fdb0542f681 100644 --- a/src/permission/fs_permission.h +++ b/src/permission/fs_permission.h @@ -30,8 +30,7 @@ class FSPermission final : public PermissionBase { Node* wildcard_child = nullptr; bool is_leaf = false; - explicit Node(std::string_view pre) - : prefix(pre) {} + explicit Node(std::string_view pre) : prefix(pre) {} Node() = default; @@ -44,7 +43,10 @@ class FSPermission final : public PermissionBase { void SetChild(char label, Node* node) { for (auto& [c, n] : children) { - if (c == label) { n = node; return; } + if (c == label) { + n = node; + return; + } } children.emplace_back(label, node); } diff --git a/src/permission/inspector_permission.cc b/src/permission/inspector_permission.cc deleted file mode 100644 index d884ec4d8208..000000000000 --- a/src/permission/inspector_permission.cc +++ /dev/null @@ -1,30 +0,0 @@ -#include "inspector_permission.h" - -#include - -namespace node { - -namespace permission { - -// Currently, Inspector manage a single state -// Once denied, it's always denied -void InspectorPermission::Apply(Environment* env, - std::span allow, - PermissionScope scope) { - deny_all_ = true; -} - -void InspectorPermission::Drop(Environment* env, - PermissionScope scope, - std::string_view param) { - deny_all_ = true; -} - -bool InspectorPermission::is_granted(Environment* env, - PermissionScope perm, - std::string_view param) const { - return deny_all_ == false; -} - -} // namespace permission -} // namespace node diff --git a/src/permission/inspector_permission.h b/src/permission/inspector_permission.h deleted file mode 100644 index 2490149caa91..000000000000 --- a/src/permission/inspector_permission.h +++ /dev/null @@ -1,34 +0,0 @@ -#ifndef SRC_PERMISSION_INSPECTOR_PERMISSION_H_ -#define SRC_PERMISSION_INSPECTOR_PERMISSION_H_ - -#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS - -#include -#include "permission/permission_base.h" - -namespace node { - -namespace permission { - -class InspectorPermission final : public PermissionBase { - public: - void Apply(Environment* env, - std::span allow, - PermissionScope scope) override; - void Drop(Environment* env, - PermissionScope scope, - std::string_view param) override; - bool is_granted(Environment* env, - PermissionScope perm, - std::string_view param) const override; - - private: - bool deny_all_; -}; - -} // namespace permission - -} // namespace node - -#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS -#endif // SRC_PERMISSION_INSPECTOR_PERMISSION_H_ diff --git a/src/permission/net_permission.cc b/src/permission/net_permission.cc deleted file mode 100644 index 10b635e82e6d..000000000000 --- a/src/permission/net_permission.cc +++ /dev/null @@ -1,28 +0,0 @@ -#include "net_permission.h" - -#include - -namespace node { - -namespace permission { - -void NetPermission::Apply(Environment* env, - std::span allow, - PermissionScope scope) { - allow_net_ = true; -} - -void NetPermission::Drop(Environment* env, - PermissionScope scope, - std::string_view param) { - allow_net_ = false; -} - -bool NetPermission::is_granted(Environment* env, - PermissionScope perm, - std::string_view param) const { - return allow_net_; -} - -} // namespace permission -} // namespace node diff --git a/src/permission/net_permission.h b/src/permission/net_permission.h deleted file mode 100644 index 23b643a50222..000000000000 --- a/src/permission/net_permission.h +++ /dev/null @@ -1,34 +0,0 @@ -#ifndef SRC_PERMISSION_NET_PERMISSION_H_ -#define SRC_PERMISSION_NET_PERMISSION_H_ - -#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS - -#include -#include "permission/permission_base.h" - -namespace node { - -namespace permission { - -class NetPermission final : public PermissionBase { - public: - void Apply(Environment* env, - std::span allow, - PermissionScope scope) override; - void Drop(Environment* env, - PermissionScope scope, - std::string_view param) override; - bool is_granted(Environment* env, - PermissionScope perm, - std::string_view param) const override; - - private: - bool allow_net_ = false; -}; - -} // namespace permission - -} // namespace node - -#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS -#endif // SRC_PERMISSION_NET_PERMISSION_H_ diff --git a/src/permission/openssl_store_permission.cc b/src/permission/openssl_store_permission.cc deleted file mode 100644 index 11d4ff82faf8..000000000000 --- a/src/permission/openssl_store_permission.cc +++ /dev/null @@ -1,30 +0,0 @@ -#include "permission/openssl_store_permission.h" - -#include - -namespace node { - -namespace permission { - -// OpenSSLStorePermission manages a single global deny state for the use of -// OpenSSL STORE loaders. -void OpenSSLStorePermission::Apply(Environment* env, - std::span allow, - PermissionScope scope) { - deny_all_ = true; -} - -void OpenSSLStorePermission::Drop(Environment* env, - PermissionScope scope, - std::string_view param) { - deny_all_ = true; -} - -bool OpenSSLStorePermission::is_granted(Environment* env, - PermissionScope perm, - std::string_view param) const { - return perm != PermissionScope::kOpenSSLStore || !deny_all_; -} - -} // namespace permission -} // namespace node diff --git a/src/permission/openssl_store_permission.h b/src/permission/openssl_store_permission.h deleted file mode 100644 index 418e93e0d899..000000000000 --- a/src/permission/openssl_store_permission.h +++ /dev/null @@ -1,33 +0,0 @@ -#ifndef SRC_PERMISSION_OPENSSL_STORE_PERMISSION_H_ -#define SRC_PERMISSION_OPENSSL_STORE_PERMISSION_H_ - -#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS - -#include "permission/permission_base.h" - -namespace node { - -namespace permission { - -class OpenSSLStorePermission final : public PermissionBase { - public: - void Apply(Environment* env, - std::span allow, - PermissionScope scope) override; - void Drop(Environment* env, - PermissionScope scope, - std::string_view param) override; - bool is_granted(Environment* env, - PermissionScope perm, - std::string_view param) const override; - - private: - bool deny_all_ = false; -}; - -} // namespace permission - -} // namespace node - -#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS -#endif // SRC_PERMISSION_OPENSSL_STORE_PERMISSION_H_ diff --git a/src/permission/permission.cc b/src/permission/permission.cc index e7e8ba6b2ea2..9bf1ee21e0af 100644 --- a/src/permission/permission.cc +++ b/src/permission/permission.cc @@ -7,15 +7,8 @@ #include "node_external_reference.h" #include "node_file.h" -#include "permission/addon_permission.h" -#include "permission/child_process_permission.h" -#include "permission/ffi_permission.h" +#include "permission/boolean_permission.h" #include "permission/fs_permission.h" -#include "permission/inspector_permission.h" -#include "permission/net_permission.h" -#include "permission/openssl_store_permission.h" -#include "permission/wasi_permission.h" -#include "permission/worker_permission.h" #include "permission/permission_base.h" #include "v8-fast-api-calls.h" #include "v8-template.h" @@ -194,49 +187,25 @@ PermissionScope Permission::StringToPermission(std::string_view perm) { Permission::Permission() : enabled_(false), warning_only_(false) { auto fs = std::make_shared(); - auto child_p = std::make_shared(); - auto worker_t = std::make_shared(); - auto inspector = std::make_shared(); - auto wasi = std::make_shared(); - auto net = std::make_shared(); - auto addon = std::make_shared(); - auto ffi = std::make_shared(); - auto openssl_store = std::make_shared(); #define V(Name, _, __, ___) \ nodes_[static_cast(PermissionScope::k##Name)] = fs; FILESYSTEM_PERMISSIONS(V) #undef V #define V(Name, _, __, ___) \ - nodes_[static_cast(PermissionScope::k##Name)] = child_p; + nodes_[static_cast(PermissionScope::k##Name)] = \ + std::make_shared(); CHILD_PROCESS_PERMISSIONS(V) -#undef V -#define V(Name, _, __, ___) \ - nodes_[static_cast(PermissionScope::k##Name)] = worker_t; WORKER_THREADS_PERMISSIONS(V) -#undef V -#define V(Name, _, __, ___) \ - nodes_[static_cast(PermissionScope::k##Name)] = inspector; INSPECTOR_PERMISSIONS(V) -#undef V -#define V(Name, _, __, ___) \ - nodes_[static_cast(PermissionScope::k##Name)] = wasi; WASI_PERMISSIONS(V) -#undef V -#define V(Name, _, __, ___) \ - nodes_[static_cast(PermissionScope::k##Name)] = net; - NET_PERMISSIONS(V) -#undef V -#define V(Name, _, __, ___) \ - nodes_[static_cast(PermissionScope::k##Name)] = addon; ADDON_PERMISSIONS(V) -#undef V -#define V(Name, _, __, ___) \ - nodes_[static_cast(PermissionScope::k##Name)] = ffi; FFI_PERMISSIONS(V) + OPENSSL_STORE_PERMISSIONS(V) #undef V #define V(Name, _, __, ___) \ - nodes_[static_cast(PermissionScope::k##Name)] = openssl_store; - OPENSSL_STORE_PERMISSIONS(V) + nodes_[static_cast(PermissionScope::k##Name)] = \ + std::make_shared(); + NET_PERMISSIONS(V) #undef V } diff --git a/src/permission/wasi_permission.cc b/src/permission/wasi_permission.cc deleted file mode 100644 index 5891edc92aa3..000000000000 --- a/src/permission/wasi_permission.cc +++ /dev/null @@ -1,30 +0,0 @@ -#include "permission/wasi_permission.h" - -#include - -namespace node { - -namespace permission { - -// Currently, WASIPermission manage a single state -// Once denied, it's always denied -void WASIPermission::Apply(Environment* env, - std::span allow, - PermissionScope scope) { - deny_all_ = true; -} - -void WASIPermission::Drop(Environment* env, - PermissionScope scope, - std::string_view param) { - deny_all_ = true; -} - -bool WASIPermission::is_granted(Environment* env, - PermissionScope perm, - std::string_view param) const { - return deny_all_ == false; -} - -} // namespace permission -} // namespace node diff --git a/src/permission/wasi_permission.h b/src/permission/wasi_permission.h deleted file mode 100644 index fbca0e4e0879..000000000000 --- a/src/permission/wasi_permission.h +++ /dev/null @@ -1,33 +0,0 @@ -#ifndef SRC_PERMISSION_WASI_PERMISSION_H_ -#define SRC_PERMISSION_WASI_PERMISSION_H_ - -#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS - -#include "permission/permission_base.h" - -namespace node { - -namespace permission { - -class WASIPermission final : public PermissionBase { - public: - void Apply(Environment* env, - std::span allow, - PermissionScope scope) override; - void Drop(Environment* env, - PermissionScope scope, - std::string_view param) override; - bool is_granted(Environment* env, - PermissionScope perm, - std::string_view param) const override; - - private: - bool deny_all_; -}; - -} // namespace permission - -} // namespace node - -#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS -#endif // SRC_PERMISSION_WASI_PERMISSION_H_ diff --git a/src/permission/worker_permission.cc b/src/permission/worker_permission.cc deleted file mode 100644 index bdf0519d3bb0..000000000000 --- a/src/permission/worker_permission.cc +++ /dev/null @@ -1,30 +0,0 @@ -#include "permission/worker_permission.h" - -#include - -namespace node { - -namespace permission { - -// Currently, PolicyDenyWorker manage a single state -// Once denied, it's always denied -void WorkerPermission::Apply(Environment* env, - std::span allow, - PermissionScope scope) { - deny_all_ = true; -} - -void WorkerPermission::Drop(Environment* env, - PermissionScope scope, - std::string_view param) { - deny_all_ = true; -} - -bool WorkerPermission::is_granted(Environment* env, - PermissionScope perm, - std::string_view param) const { - return deny_all_ == false; -} - -} // namespace permission -} // namespace node diff --git a/src/permission/worker_permission.h b/src/permission/worker_permission.h deleted file mode 100644 index ba4b45319ba9..000000000000 --- a/src/permission/worker_permission.h +++ /dev/null @@ -1,33 +0,0 @@ -#ifndef SRC_PERMISSION_WORKER_PERMISSION_H_ -#define SRC_PERMISSION_WORKER_PERMISSION_H_ - -#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS - -#include "permission/permission_base.h" - -namespace node { - -namespace permission { - -class WorkerPermission final : public PermissionBase { - public: - void Apply(Environment* env, - std::span allow, - PermissionScope scope) override; - void Drop(Environment* env, - PermissionScope scope, - std::string_view param) override; - bool is_granted(Environment* env, - PermissionScope perm, - std::string_view param) const override; - - private: - bool deny_all_; -}; - -} // namespace permission - -} // namespace node - -#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS -#endif // SRC_PERMISSION_WORKER_PERMISSION_H_ From 3a47f1f2820999e1e643a72ecf73d1fe551027ce Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sat, 8 Aug 2026 19:25:37 -0700 Subject: [PATCH 136/344] src: apply minor namespace format tweak in permissions Signed-off-by: James M Snell PR-URL: https://github.com/nodejs/node/pull/65158 Reviewed-By: Xuguang Mei Reviewed-By: Stephen Belanger --- src/permission/boolean_permission.h | 8 ++------ src/permission/fs_permission.h | 17 ++++++++--------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/permission/boolean_permission.h b/src/permission/boolean_permission.h index d1a30972dcee..f2fdc8a7f90f 100644 --- a/src/permission/boolean_permission.h +++ b/src/permission/boolean_permission.h @@ -5,9 +5,7 @@ #include "permission/permission_base.h" -namespace node { - -namespace permission { +namespace node::permission { // A simple boolean permission that either denies or allows access. // Used for permission scopes that don't need per-resource granularity. @@ -46,9 +44,7 @@ using DenyOnlyPermission = BooleanPermission; // Apply grants access, Drop revokes. using AllowRevokePermission = BooleanPermission; -} // namespace permission - -} // namespace node +} // namespace node::permission #endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS #endif // SRC_PERMISSION_BOOLEAN_PERMISSION_H_ diff --git a/src/permission/fs_permission.h b/src/permission/fs_permission.h index 3fdb0542f681..f2e1720ed9bd 100644 --- a/src/permission/fs_permission.h +++ b/src/permission/fs_permission.h @@ -7,9 +7,7 @@ #include "permission/permission_base.h" #include "util.h" -namespace node { - -namespace permission { +namespace node::permission { class FSPermission final : public PermissionBase { public: @@ -125,9 +123,12 @@ class FSPermission final : public PermissionBase { // Handle optional trailing // path = /home/subdirectory // child = subdirectory/* - if (idx >= path.length() && - child->prefix[i] == node::kPathSeparator) { - continue; + if (idx >= path.length()) { + if (child->prefix[i] == node::kPathSeparator) { + continue; + } + // Path is exhausted but prefix expects more characters + return nullptr; } if (path[idx++] != child->prefix[i]) { @@ -185,9 +186,7 @@ class FSPermission final : public PermissionBase { bool allow_all_out_ = false; }; -} // namespace permission - -} // namespace node +} // namespace node::permission #endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS #endif // SRC_PERMISSION_FS_PERMISSION_H_ From bf86fab458911a06a0f27c3ebb570798bd2990ac Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sat, 8 Aug 2026 22:19:37 -0700 Subject: [PATCH 137/344] test: add permission fast api test Signed-off-by: James M Snell PR-URL: https://github.com/nodejs/node/pull/65158 Reviewed-By: Xuguang Mei Reviewed-By: Stephen Belanger --- src/permission/permission.cc | 38 ++++++++++++++++--- src/util.cc | 25 ++++++++++++ src/util.h | 6 +++ test/parallel/test-permission-has-fast-api.js | 38 +++++++++++++++++++ 4 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 test/parallel/test-permission-has-fast-api.js diff --git a/src/permission/permission.cc b/src/permission/permission.cc index 9bf1ee21e0af..29449c0ec028 100644 --- a/src/permission/permission.cc +++ b/src/permission/permission.cc @@ -127,7 +127,6 @@ static void Has(const FunctionCallbackInfo& args) { static bool FastHas(Local receiver, Local scope_arg, - Local resource_arg, // NOLINTNEXTLINE(runtime/references) This is V8 api. FastApiCallbackOptions& options) { TRACK_V8_FAST_API_CALL("permission.has"); @@ -148,8 +147,31 @@ static bool FastHas(Local receiver, return false; } - if (resource_arg->IsUndefined()) { - return env->permission()->is_granted(env, scope); + return env->permission()->is_granted(env, scope); +} + +static bool FastHasResource( + Local receiver, + Local scope_arg, + Local resource_arg, + // NOLINTNEXTLINE(runtime/references) This is V8 api. + FastApiCallbackOptions& options) { + TRACK_V8_FAST_API_CALL("permission.has"); + auto isolate = options.isolate; + v8::HandleScope handle_scope(isolate); + auto context = isolate->GetCurrentContext(); + + Environment* env = Environment::GetCurrent(context); + + Local str; + if (!scope_arg->ToString(context).ToLocal(&str)) { + return false; + } + Utf8Value utf8_scope(isolate, str); + PermissionScope scope = + Permission::StringToPermission(utf8_scope.ToStringView()); + if (scope == PermissionScope::kPermissionsRoot) { + return false; } Local res_str; @@ -164,7 +186,8 @@ static bool FastHas(Local receiver, return env->permission()->is_granted(env, scope, utf8_res.ToStringView()); } -static CFunction fast_has_(CFunction::Make(FastHas)); +static CFunction fast_has_methods_[] = {CFunction::Make(FastHas), + CFunction::Make(FastHasResource)}; } // namespace @@ -370,7 +393,8 @@ void Initialize(Local target, Local unused, Local context, void* priv) { - SetFastMethodNoSideEffect(context, target, "has", Has, &fast_has_); + SetFastMethodNoSideEffect( + context, target, "has", Has, {fast_has_methods_, 2}); SetMethod(context, target, "drop", Drop); target->SetIntegrityLevel(context, IntegrityLevel::kFrozen).FromJust(); @@ -378,7 +402,9 @@ void Initialize(Local target, void RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(Has); - registry->Register(fast_has_); + for (const CFunction& method : fast_has_methods_) { + registry->Register(method); + } registry->Register(Drop); } diff --git a/src/util.cc b/src/util.cc index 317b8db0daac..ce45c1ad4ede 100644 --- a/src/util.cc +++ b/src/util.cc @@ -468,6 +468,31 @@ void SetFastMethodNoSideEffect( that->Set(name_string, t); } +void SetFastMethodNoSideEffect( + Local context, + Local that, + const std::string_view name, + v8::FunctionCallback slow_callback, + const v8::MemorySpan& methods) { + Isolate* isolate = Isolate::GetCurrent(); + Local function = FunctionTemplate::NewWithCFunctionOverloads( + isolate, + slow_callback, + Local(), + Local(), + 0, + v8::ConstructorBehavior::kThrow, + v8::SideEffectType::kHasNoSideEffect, + methods) + ->GetFunction(context) + .ToLocalChecked(); + const v8::NewStringType type = v8::NewStringType::kInternalized; + Local name_string = + v8::String::NewFromUtf8(isolate, name.data(), type, name.size()) + .ToLocalChecked(); + that->Set(context, name_string, function).Check(); +} + void SetMethodNoSideEffect(Local context, Local that, const std::string_view name, diff --git a/src/util.h b/src/util.h index 5e9bf2b1cdab..0461a5ebb19f 100644 --- a/src/util.h +++ b/src/util.h @@ -912,6 +912,12 @@ void SetFastMethodNoSideEffect( const std::string_view name, v8::FunctionCallback slow_callback, const v8::MemorySpan& methods); +void SetFastMethodNoSideEffect( + v8::Local context, + v8::Local that, + const std::string_view name, + v8::FunctionCallback slow_callback, + const v8::MemorySpan& methods); void SetProtoMethod(v8::Isolate* isolate, v8::Local that, const std::string_view name, diff --git a/test/parallel/test-permission-has-fast-api.js b/test/parallel/test-permission-has-fast-api.js new file mode 100644 index 000000000000..526ee4f20944 --- /dev/null +++ b/test/parallel/test-permission-has-fast-api.js @@ -0,0 +1,38 @@ +// Flags: --permission --allow-fs-read=* --allow-fs-write=* --allow-natives-syntax --expose-internals --no-warnings +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { internalBinding } = require('internal/test/binding'); +const path = require('path'); + +// Test that process.permission.has() uses the V8 fast API path. + +// Test with scope only (no resource argument). +function testHasScope() { + assert.strictEqual(process.permission.has('fs.read', __filename), true); + assert.strictEqual(process.permission.has('fs.write', __dirname), true); + assert.strictEqual( + process.permission.has('fs.read', path.resolve('/nonexistent')), + true + ); + assert.strictEqual(process.permission.has('fs.read'), true); + assert.strictEqual(process.permission.has('fs.write'), true); + assert.strictEqual(process.permission.has('child'), false); + assert.strictEqual(process.permission.has('worker'), false); + assert.strictEqual(process.permission.has('invalid-key'), false); +} + +// Warm up and optimize for the fast API path. +eval('%PrepareFunctionForOptimization(testHasScope)'); +testHasScope(); +testHasScope(); + +eval('%OptimizeFunctionOnNextCall(testHasScope)'); +testHasScope(); + +if (common.isDebug) { + const { getV8FastApiCallCount } = internalBinding('debug'); + // After optimization: testHasScope = 4, testHasResource = 3, testHasInvalid = 1 + assert.strictEqual(getV8FastApiCallCount('permission.has'), 8); +} From 5b5509d0d362a34112ec9529fadf31b4aa1a90d0 Mon Sep 17 00:00:00 2001 From: Rafael Gonzaga Date: Wed, 12 Aug 2026 15:23:46 -0300 Subject: [PATCH 138/344] doc: create ai-guidelines and include to CONTRIBUTING MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Beth Griggs Co-authored-by: Aditi <62544124+Aditi-1400@users.noreply.github.com> Co-authored-by: Joyee Cheung Co-authored-by: Tobias Nießen Co-authored-by: Antoine du Hamel Co-authored-by: Mike McCready <66998419+MikeMcC399@users.noreply.github.com> Co-authored-by: Efe Co-authored-by: James M Snell Co-authored-by: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Signed-off-by: RafaelGSS PR-URL: https://github.com/nodejs/node/pull/62105 Reviewed-By: Ulises Gascón Reviewed-By: Matteo Collina Reviewed-By: Luigi Pinca Reviewed-By: Aditi Singh Reviewed-By: Gürgün Dayıoğlu Reviewed-By: Moshe Atlow Reviewed-By: Robert Nagy Reviewed-By: Daeyeon Jeong Reviewed-By: James M Snell Reviewed-By: Joyee Cheung Reviewed-By: Paolo Insogna Reviewed-By: Trivikram Kamat Reviewed-By: Geoffrey Booth Reviewed-By: Chengzhong Wu Reviewed-By: Filip Skokan Reviewed-By: Jacob Smith Reviewed-By: Claudio Wunder Reviewed-By: Mike McCready <66998419+MikeMcC399@users.noreply.github.com> --- CONTRIBUTING.md | 10 ++++ doc/contributing/ai-guidelines.md | 98 +++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 doc/contributing/ai-guidelines.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b47b9868461b..90a8a1d50f4f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,6 +25,7 @@ works. * [Issues](#issues) * [Pull Requests](#pull-requests) * [Automation and bots](#automation-and-bots) +* [AI Use Policy and Guidelines](#ai-use-policy-and-guidelines) * [Developer's Certificate of Origin 1.1](#developers-certificate-of-origin-11) ## [Code of Conduct](./doc/contributing/code-of-conduct.md) @@ -66,6 +67,15 @@ by an automation that was not authorized by Node.js collaborators are subject to immediate moderation enforcement on the automation and owner without notice. +## [AI Use Policy and Guidelines](./doc/contributing/ai-guidelines.md) + +Node.js requires contributors to understand and take full responsibility for +every change they propose. Pull requests containing AI-generated code the +contributor has not personally understood, tested, and verified will likely be closed +without review. + +See [details on our AI use policy and guidelines](./doc/contributing/ai-guidelines.md). + ## Developer's Certificate of Origin 1.1 ```text diff --git a/doc/contributing/ai-guidelines.md b/doc/contributing/ai-guidelines.md new file mode 100644 index 000000000000..baebe5dc6c80 --- /dev/null +++ b/doc/contributing/ai-guidelines.md @@ -0,0 +1,98 @@ +# AI use policy and guidelines + +* [Core principle](#core-principle) +* [When AI is used in contributions](#when-ai-is-used-in-contributions) +* [When AI is used in communications](#when-ai-is-used-in-communications) + +This document aligns with the [OpenJS Foundation AI Coding Assistants Policy][]. + +## Core principle + +Tools should never replace human judgment, regardless of whether they are +powered by AI. + +Node.js requires contributors to understand and take full responsibility for +every change they propose. The answer to "Why is X an improvement?" can +never be "I'm not sure. The AI did it." + +If AI tools assisted in generating a contribution, acknowledge that honestly. +Regardless of how much code is generated by AI, disclosure does not serve +as a disclaimer of responsibility. + +Be aware that the mention of for-profit trademarks or commercial brands in +commit messages, which are part of the code base, can be abused for +profit-driven marketing. If the disclosure involves for-profit trademarks or +commercial brands, it's recommended to either anonymize the branding (e.g. say +`a frontier reasoning model`, `a closed-source coding agent` instead of +``), or only mention the for-profit brand/trademark in the PR +description, but not in the commit message, unless the message would not have +made sense without mentioning the specific brand/trademark. These +recommendations only apply to for-profit tools/models, not any non-profit ones. + +Pull requests that contain AI-generated code the contributor has not +personally understood, tested, and verified waste collaborator time and +will be subject to closure without additional review. Contributors who +repeatedly submit such changes, show no understanding of the project or +its processes, or are dishonest about the use of automated assistance +may be blocked from further contributions. + +Pull requests must not be opened by automated tooling, unless specifically +approved in advance by the project. To request approval, either open an issue in +[nodejs/admin](https://github.com/nodejs/admin/issues), or if the automation can +be done in the form of a GitHub workflow, submit a pull request to add the +workflow and use the usual pull request review process to seek consensus. + +## When AI is used in contributions + +Contributors may use AI tools to assist with contributions, but such tools +never replace human judgment. + +When using AI as a coding assistant: + +* **Understand the codebase first.** Do not skip familiarizing yourself with + the relevant subsystem. Always verify analysis generated by tools against + the actual source code with human judgement. + +* **Own every line you submit.** You are responsible for all code in your + pull request, regardless of how it was created. The submitted changes + must satisfy the project's [Developer's Certificate of Origin][] and licensing + requirements. Be prepared to explain any change in detail during review. + +* **Keep the commits logical.** The [commit message guidelines][] + and [commit squashing guidelines](./pull-requests.md#commit-squashing) + must be followed regardless of what tool is used in the pull request. + +* **Test thoroughly.** Existing tests should not be removed or modified + without human verification. It is crucial to verify, with human judgement, + the correctness of new tests against the intended behavior of the feature + being tested, independently of how the implementation happens to behave. + +* **Do not disappear.** If you open a PR, follow it through. Respond to + feedback and iterate until the work lands or is explicitly closed. If you + can no longer pursue it, close the PR. Stalled PRs block progress. + +* **Do not use AI to claim "good first issue" tasks.** These issues exist to + help new contributors learn the codebase and processes hands-on. + +* **Keep the comments useful.** Verify with human judgement that the + comments are necessary and accurate. Remove comments that simply + restate what the code does. Add comments only where the logic is non-obvious. + +## When AI is used in communications + +Node.js values concise, precise communication that respects collaborator and +contributor time. + +* **Do not paste messages generated entirely by AI** in pull requests, issues, + or the project's communication channels. Such communication may be removed in + accordance to [the Node.js moderation policy][]. +* **Verify claims about the code with human judgement before using them in + communications**. Results from AI tools should only be treated as hypothesis. + Link to actual code, documentation and specifications as source of truth. +* Grammar and spell-check tools are acceptable when they improve clarity and + conciseness. + +[Developer's Certificate of Origin]: ../../CONTRIBUTING.md#developers-certificate-of-origin-11 +[OpenJS Foundation AI Coding Assistants Policy]: https://ai-coding-assistants-policy.openjsf.org/ +[commit message guidelines]: ./pull-requests.md#commit-message-guidelines +[the Node.js moderation policy]: https://github.com/nodejs/admin/blob/main/Moderation-Policy.md From ebd8c6ba523bbfd29ed4da8970f0e7e7f0b3424b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9?= Date: Wed, 12 Aug 2026 22:13:05 +0100 Subject: [PATCH 139/344] deps: float ICU-23262 patch for icu78 Signed-off-by: Renegade334 PR-URL: https://github.com/nodejs/node/pull/64678 Fixes: https://github.com/nodejs/node/issues/63041 Refs: https://unicode-org.atlassian.net/browse/ICU-23262 Reviewed-By: Claudio Wunder Reviewed-By: James M Snell --- tools/icu/patches/78/source/i18n/dtfmtsym.cpp | 2653 +++++++++++++++++ 1 file changed, 2653 insertions(+) create mode 100644 tools/icu/patches/78/source/i18n/dtfmtsym.cpp diff --git a/tools/icu/patches/78/source/i18n/dtfmtsym.cpp b/tools/icu/patches/78/source/i18n/dtfmtsym.cpp new file mode 100644 index 000000000000..6c2a417eccde --- /dev/null +++ b/tools/icu/patches/78/source/i18n/dtfmtsym.cpp @@ -0,0 +1,2653 @@ +// © 2016 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html +/* +******************************************************************************* +* Copyright (C) 1997-2016, International Business Machines Corporation and * +* others. All Rights Reserved. * +******************************************************************************* +* +* File DTFMTSYM.CPP +* +* Modification History: +* +* Date Name Description +* 02/19/97 aliu Converted from java. +* 07/21/98 stephen Added getZoneIndex +* Changed weekdays/short weekdays to be one-based +* 06/14/99 stephen Removed SimpleDateFormat::fgTimeZoneDataSuffix +* 11/16/99 weiv Added 'Y' and 'e' to fgPatternChars +* 03/27/00 weiv Keeping resource bundle around! +* 06/30/05 emmons Added eraNames, narrow month/day, standalone context +* 10/12/05 emmons Added setters for eraNames, month/day by width/context +******************************************************************************* +*/ + +#include + +#include "unicode/utypes.h" + +#if !UCONFIG_NO_FORMATTING +#include "unicode/ustring.h" +#include "unicode/localpointer.h" +#include "unicode/dtfmtsym.h" +#include "unicode/errorcode.h" +#include "unicode/smpdtfmt.h" +#include "unicode/msgfmt.h" +#include "unicode/numsys.h" +#include "unicode/tznames.h" +#include "cpputils.h" +#include "umutex.h" +#include "cmemory.h" +#include "cstring.h" +#include "charstr.h" +#include "erarules.h" +#include "dt_impl.h" +#include "locbased.h" +#include "gregoimp.h" +#include "hash.h" +#include "uassert.h" +#include "uresimp.h" +#include "ureslocs.h" +#include "uvector.h" +#include "shareddateformatsymbols.h" +#include "unicode/calendar.h" +#include "unifiedcache.h" + +// ***************************************************************************** +// class DateFormatSymbols +// ***************************************************************************** + +/** + * These are static arrays we use only in the case where we have no + * resource data. + */ + +#if UDAT_HAS_PATTERN_CHAR_FOR_TIME_SEPARATOR +#define PATTERN_CHARS_LEN 38 +#else +#define PATTERN_CHARS_LEN 37 +#endif + +/** + * Unlocalized date-time pattern characters. For example: 'y', 'd', etc. All + * locales use the same these unlocalized pattern characters. + */ +static const char16_t gPatternChars[] = { + // if UDAT_HAS_PATTERN_CHAR_FOR_TIME_SEPARATOR: + // GyMdkHmsSEDFwWahKzYeugAZvcLQqVUOXxrbB: + // else: + // GyMdkHmsSEDFwWahKzYeugAZvcLQqVUOXxrbB + + 0x47, 0x79, 0x4D, 0x64, 0x6B, 0x48, 0x6D, 0x73, 0x53, 0x45, + 0x44, 0x46, 0x77, 0x57, 0x61, 0x68, 0x4B, 0x7A, 0x59, 0x65, + 0x75, 0x67, 0x41, 0x5A, 0x76, 0x63, 0x4c, 0x51, 0x71, 0x56, + 0x55, 0x4F, 0x58, 0x78, 0x72, 0x62, 0x42, +#if UDAT_HAS_PATTERN_CHAR_FOR_TIME_SEPARATOR + 0x3a, +#endif + 0 +}; + +/** + * Map of each ASCII character to its corresponding index in the table above if + * it is a pattern character and -1 otherwise. + */ +static const int8_t gLookupPatternChars[] = { + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + // + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + // ! " # $ % & ' ( ) * + , - . / + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, +#if UDAT_HAS_PATTERN_CHAR_FOR_TIME_SEPARATOR + // 0 1 2 3 4 5 6 7 8 9 : ; < = > ? + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 37, -1, -1, -1, -1, -1, +#else + // 0 1 2 3 4 5 6 7 8 9 : ; < = > ? + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, +#endif + // @ A B C D E F G H I J K L M N O + -1, 22, 36, -1, 10, 9, 11, 0, 5, -1, -1, 16, 26, 2, -1, 31, + // P Q R S T U V W X Y Z [ \ ] ^ _ + -1, 27, -1, 8, -1, 30, 29, 13, 32, 18, 23, -1, -1, -1, -1, -1, + // ` a b c d e f g h i j k l m n o + -1, 14, 35, 25, 3, 19, -1, 21, 15, -1, -1, 4, -1, 6, -1, -1, + // p q r s t u v w x y z { | } ~ + -1, 28, 34, 7, -1, 20, 24, 12, 33, 1, 17, -1, -1, -1, -1, -1 +}; + +//------------------------------------------------------ +// Strings of last resort. These are only used if we have no resource +// files. They aren't designed for actual use, just for backup. + +// These are the month names and abbreviations of last resort. +static const char16_t gLastResortMonthNames[13][3] = +{ + {0x0030, 0x0031, 0x0000}, /* "01" */ + {0x0030, 0x0032, 0x0000}, /* "02" */ + {0x0030, 0x0033, 0x0000}, /* "03" */ + {0x0030, 0x0034, 0x0000}, /* "04" */ + {0x0030, 0x0035, 0x0000}, /* "05" */ + {0x0030, 0x0036, 0x0000}, /* "06" */ + {0x0030, 0x0037, 0x0000}, /* "07" */ + {0x0030, 0x0038, 0x0000}, /* "08" */ + {0x0030, 0x0039, 0x0000}, /* "09" */ + {0x0031, 0x0030, 0x0000}, /* "10" */ + {0x0031, 0x0031, 0x0000}, /* "11" */ + {0x0031, 0x0032, 0x0000}, /* "12" */ + {0x0031, 0x0033, 0x0000} /* "13" */ +}; + +// These are the weekday names and abbreviations of last resort. +static const char16_t gLastResortDayNames[8][2] = +{ + {0x0030, 0x0000}, /* "0" */ + {0x0031, 0x0000}, /* "1" */ + {0x0032, 0x0000}, /* "2" */ + {0x0033, 0x0000}, /* "3" */ + {0x0034, 0x0000}, /* "4" */ + {0x0035, 0x0000}, /* "5" */ + {0x0036, 0x0000}, /* "6" */ + {0x0037, 0x0000} /* "7" */ +}; + +// These are the quarter names and abbreviations of last resort. +static const char16_t gLastResortQuarters[4][2] = +{ + {0x0031, 0x0000}, /* "1" */ + {0x0032, 0x0000}, /* "2" */ + {0x0033, 0x0000}, /* "3" */ + {0x0034, 0x0000}, /* "4" */ +}; + +// These are the am/pm and BC/AD markers of last resort. +static const char16_t gLastResortAmPmMarkers[2][3] = +{ + {0x0041, 0x004D, 0x0000}, /* "AM" */ + {0x0050, 0x004D, 0x0000} /* "PM" */ +}; + +static const char16_t gLastResortEras[2][3] = +{ + {0x0042, 0x0043, 0x0000}, /* "BC" */ + {0x0041, 0x0044, 0x0000} /* "AD" */ +}; + +/* Sizes for the last resort string arrays */ +typedef enum LastResortSize { + kMonthNum = 13, + kMonthLen = 3, + + kDayNum = 8, + kDayLen = 2, + + kAmPmNum = 2, + kAmPmLen = 3, + + kQuarterNum = 4, + kQuarterLen = 2, + + kEraNum = 2, + kEraLen = 3, + + kZoneNum = 5, + kZoneLen = 4, + + kGmtHourNum = 4, + kGmtHourLen = 10 +} LastResortSize; + +U_NAMESPACE_BEGIN + +SharedDateFormatSymbols::~SharedDateFormatSymbols() { +} + +template<> U_I18N_API +const SharedDateFormatSymbols * + LocaleCacheKey::createObject( + const void * /*unusedContext*/, UErrorCode &status) const { + char type[256]; + Calendar::getCalendarTypeFromLocale(fLoc, type, UPRV_LENGTHOF(type), status); + if (U_FAILURE(status)) { + return nullptr; + } + SharedDateFormatSymbols *shared + = new SharedDateFormatSymbols(fLoc, type, status); + if (shared == nullptr) { + status = U_MEMORY_ALLOCATION_ERROR; + return nullptr; + } + if (U_FAILURE(status)) { + delete shared; + return nullptr; + } + shared->addRef(); + return shared; +} + +UOBJECT_DEFINE_RTTI_IMPLEMENTATION(DateFormatSymbols) + +#define kSUPPLEMENTAL "supplementalData" + +/** + * These are the tags we expect to see in normal resource bundle files associated + * with a locale and calendar + */ +static const char gCalendarTag[]="calendar"; +static const char gGregorianTag[]="gregorian"; +static const char gErasTag[]="eras"; +static const char gCyclicNameSetsTag[]="cyclicNameSets"; +static const char gNameSetYearsTag[]="years"; +static const char gNameSetZodiacsTag[]="zodiacs"; +static const char gMonthNamesTag[]="monthNames"; +static const char gMonthPatternsTag[]="monthPatterns"; +static const char gDayNamesTag[]="dayNames"; +static const char gNamesWideTag[]="wide"; +static const char gNamesAbbrTag[]="abbreviated"; +static const char gNamesShortTag[]="short"; +static const char gNamesNarrowTag[]="narrow"; +static const char gNamesAllTag[]="all"; +static const char gNamesFormatTag[]="format"; +static const char gNamesStandaloneTag[]="stand-alone"; +static const char gNamesNumericTag[]="numeric"; +static const char gAmPmMarkersTag[]="AmPmMarkers"; +static const char gAmPmMarkersAbbrTag[]="AmPmMarkersAbbr"; +static const char gAmPmMarkersNarrowTag[]="AmPmMarkersNarrow"; +static const char gQuartersTag[]="quarters"; +static const char gNumberElementsTag[]="NumberElements"; +static const char gSymbolsTag[]="symbols"; +static const char gTimeSeparatorTag[]="timeSeparator"; +static const char gDayPeriodTag[]="dayPeriod"; + +// static const char gZoneStringsTag[]="zoneStrings"; + +// static const char gLocalPatternCharsTag[]="localPatternChars"; + +static const char gContextTransformsTag[]="contextTransforms"; + +/** + * Jitterbug 2974: MSVC has a bug whereby new X[0] behaves badly. + * Work around this. + */ +static inline UnicodeString* newUnicodeStringArray(size_t count) { + return new UnicodeString[count ? count : 1]; +} + +//------------------------------------------------------ + +DateFormatSymbols * U_EXPORT2 +DateFormatSymbols::createForLocale( + const Locale& locale, UErrorCode &status) { + const SharedDateFormatSymbols *shared = nullptr; + UnifiedCache::getByLocale(locale, shared, status); + if (U_FAILURE(status)) { + return nullptr; + } + DateFormatSymbols *result = new DateFormatSymbols(shared->get()); + shared->removeRef(); + if (result == nullptr) { + status = U_MEMORY_ALLOCATION_ERROR; + return nullptr; + } + return result; +} + +DateFormatSymbols::DateFormatSymbols(const Locale& locale, + UErrorCode& status) + : UObject() +{ + initializeData(locale, nullptr, status); +} + +DateFormatSymbols::DateFormatSymbols(UErrorCode& status) + : UObject() +{ + initializeData(Locale::getDefault(), nullptr, status, true); +} + + +DateFormatSymbols::DateFormatSymbols(const Locale& locale, + const char *type, + UErrorCode& status) + : UObject() +{ + initializeData(locale, type, status); +} + +DateFormatSymbols::DateFormatSymbols(const char *type, UErrorCode& status) + : UObject() +{ + initializeData(Locale::getDefault(), type, status, true); +} + +DateFormatSymbols::DateFormatSymbols(const DateFormatSymbols& other) + : UObject(other) +{ + copyData(other); +} + +void +DateFormatSymbols::assignArray(UnicodeString*& dstArray, + int32_t& dstCount, + const UnicodeString* srcArray, + int32_t srcCount) +{ + // assignArray() is only called by copyData() and initializeData(), which in turn + // implements the copy constructor and the assignment operator. + // All strings in a DateFormatSymbols object are created in one of the following + // three ways that all allow to safely use UnicodeString::fastCopyFrom(): + // - readonly-aliases from resource bundles + // - readonly-aliases or allocated strings from constants + // - safely cloned strings (with owned buffers) from setXYZ() functions + // + // Note that this is true for as long as DateFormatSymbols can be constructed + // only from a locale bundle or set via the cloning API, + // *and* for as long as all the strings are in *private* fields, preventing + // a subclass from creating these strings in an "unsafe" way (with respect to fastCopyFrom()). + if(srcArray == nullptr) { + // Do not attempt to copy bogus input (which will crash). + // Note that this assignArray method already had the potential to return a null dstArray; + // see handling below for "if(dstArray != nullptr)". + dstCount = 0; + dstArray = nullptr; + return; + } + dstCount = srcCount; + dstArray = newUnicodeStringArray(srcCount); + if(dstArray != nullptr) { + int32_t i; + for(i=0; i(uprv_malloc(fZoneStringsRowCount * sizeof(UnicodeString*))); + if (fZoneStrings != nullptr) { + for (row=0; row= 0; i--) { + delete[] fZoneStrings[i]; + } + uprv_free(fZoneStrings); + fZoneStrings = nullptr; + } +} + +/** + * Copy all of the other's data to this. + */ +void +DateFormatSymbols::copyData(const DateFormatSymbols& other) { + validLocale = other.validLocale; + actualLocale = other.actualLocale; + assignArray(fEras, fErasCount, other.fEras, other.fErasCount); + assignArray(fEraNames, fEraNamesCount, other.fEraNames, other.fEraNamesCount); + assignArray(fNarrowEras, fNarrowErasCount, other.fNarrowEras, other.fNarrowErasCount); + assignArray(fMonths, fMonthsCount, other.fMonths, other.fMonthsCount); + assignArray(fShortMonths, fShortMonthsCount, other.fShortMonths, other.fShortMonthsCount); + assignArray(fNarrowMonths, fNarrowMonthsCount, other.fNarrowMonths, other.fNarrowMonthsCount); + assignArray(fStandaloneMonths, fStandaloneMonthsCount, other.fStandaloneMonths, other.fStandaloneMonthsCount); + assignArray(fStandaloneShortMonths, fStandaloneShortMonthsCount, other.fStandaloneShortMonths, other.fStandaloneShortMonthsCount); + assignArray(fStandaloneNarrowMonths, fStandaloneNarrowMonthsCount, other.fStandaloneNarrowMonths, other.fStandaloneNarrowMonthsCount); + assignArray(fWeekdays, fWeekdaysCount, other.fWeekdays, other.fWeekdaysCount); + assignArray(fShortWeekdays, fShortWeekdaysCount, other.fShortWeekdays, other.fShortWeekdaysCount); + assignArray(fShorterWeekdays, fShorterWeekdaysCount, other.fShorterWeekdays, other.fShorterWeekdaysCount); + assignArray(fNarrowWeekdays, fNarrowWeekdaysCount, other.fNarrowWeekdays, other.fNarrowWeekdaysCount); + assignArray(fStandaloneWeekdays, fStandaloneWeekdaysCount, other.fStandaloneWeekdays, other.fStandaloneWeekdaysCount); + assignArray(fStandaloneShortWeekdays, fStandaloneShortWeekdaysCount, other.fStandaloneShortWeekdays, other.fStandaloneShortWeekdaysCount); + assignArray(fStandaloneShorterWeekdays, fStandaloneShorterWeekdaysCount, other.fStandaloneShorterWeekdays, other.fStandaloneShorterWeekdaysCount); + assignArray(fStandaloneNarrowWeekdays, fStandaloneNarrowWeekdaysCount, other.fStandaloneNarrowWeekdays, other.fStandaloneNarrowWeekdaysCount); + assignArray(fAmPms, fAmPmsCount, other.fAmPms, other.fAmPmsCount); + assignArray(fWideAmPms, fWideAmPmsCount, other.fWideAmPms, other.fWideAmPmsCount ); + assignArray(fNarrowAmPms, fNarrowAmPmsCount, other.fNarrowAmPms, other.fNarrowAmPmsCount ); + fTimeSeparator.fastCopyFrom(other.fTimeSeparator); // fastCopyFrom() - see assignArray comments + assignArray(fQuarters, fQuartersCount, other.fQuarters, other.fQuartersCount); + assignArray(fShortQuarters, fShortQuartersCount, other.fShortQuarters, other.fShortQuartersCount); + assignArray(fNarrowQuarters, fNarrowQuartersCount, other.fNarrowQuarters, other.fNarrowQuartersCount); + assignArray(fStandaloneQuarters, fStandaloneQuartersCount, other.fStandaloneQuarters, other.fStandaloneQuartersCount); + assignArray(fStandaloneShortQuarters, fStandaloneShortQuartersCount, other.fStandaloneShortQuarters, other.fStandaloneShortQuartersCount); + assignArray(fStandaloneNarrowQuarters, fStandaloneNarrowQuartersCount, other.fStandaloneNarrowQuarters, other.fStandaloneNarrowQuartersCount); + assignArray(fWideDayPeriods, fWideDayPeriodsCount, + other.fWideDayPeriods, other.fWideDayPeriodsCount); + assignArray(fNarrowDayPeriods, fNarrowDayPeriodsCount, + other.fNarrowDayPeriods, other.fNarrowDayPeriodsCount); + assignArray(fAbbreviatedDayPeriods, fAbbreviatedDayPeriodsCount, + other.fAbbreviatedDayPeriods, other.fAbbreviatedDayPeriodsCount); + assignArray(fStandaloneWideDayPeriods, fStandaloneWideDayPeriodsCount, + other.fStandaloneWideDayPeriods, other.fStandaloneWideDayPeriodsCount); + assignArray(fStandaloneNarrowDayPeriods, fStandaloneNarrowDayPeriodsCount, + other.fStandaloneNarrowDayPeriods, other.fStandaloneNarrowDayPeriodsCount); + assignArray(fStandaloneAbbreviatedDayPeriods, fStandaloneAbbreviatedDayPeriodsCount, + other.fStandaloneAbbreviatedDayPeriods, other.fStandaloneAbbreviatedDayPeriodsCount); + if (other.fLeapMonthPatterns != nullptr) { + assignArray(fLeapMonthPatterns, fLeapMonthPatternsCount, other.fLeapMonthPatterns, other.fLeapMonthPatternsCount); + } else { + fLeapMonthPatterns = nullptr; + fLeapMonthPatternsCount = 0; + } + if (other.fShortYearNames != nullptr) { + assignArray(fShortYearNames, fShortYearNamesCount, other.fShortYearNames, other.fShortYearNamesCount); + } else { + fShortYearNames = nullptr; + fShortYearNamesCount = 0; + } + if (other.fShortZodiacNames != nullptr) { + assignArray(fShortZodiacNames, fShortZodiacNamesCount, other.fShortZodiacNames, other.fShortZodiacNamesCount); + } else { + fShortZodiacNames = nullptr; + fShortZodiacNamesCount = 0; + } + + if (other.fZoneStrings != nullptr) { + fZoneStringsColCount = other.fZoneStringsColCount; + fZoneStringsRowCount = other.fZoneStringsRowCount; + createZoneStrings((const UnicodeString**)other.fZoneStrings); + + } else { + fZoneStrings = nullptr; + fZoneStringsColCount = 0; + fZoneStringsRowCount = 0; + } + fZSFLocale = other.fZSFLocale; + // Other zone strings data is created on demand + fLocaleZoneStrings = nullptr; + + // fastCopyFrom() - see assignArray comments + fLocalPatternChars.fastCopyFrom(other.fLocalPatternChars); + + uprv_memcpy(fCapitalization, other.fCapitalization, sizeof(fCapitalization)); +} + +/** + * Assignment operator. + */ +DateFormatSymbols& DateFormatSymbols::operator=(const DateFormatSymbols& other) +{ + if (this == &other) { return *this; } // self-assignment: no-op + dispose(); + copyData(other); + + return *this; +} + +DateFormatSymbols::~DateFormatSymbols() +{ + dispose(); +} + +void DateFormatSymbols::dispose() +{ + delete[] fEras; + delete[] fEraNames; + delete[] fNarrowEras; + delete[] fMonths; + delete[] fShortMonths; + delete[] fNarrowMonths; + delete[] fStandaloneMonths; + delete[] fStandaloneShortMonths; + delete[] fStandaloneNarrowMonths; + delete[] fWeekdays; + delete[] fShortWeekdays; + delete[] fShorterWeekdays; + delete[] fNarrowWeekdays; + delete[] fStandaloneWeekdays; + delete[] fStandaloneShortWeekdays; + delete[] fStandaloneShorterWeekdays; + delete[] fStandaloneNarrowWeekdays; + delete[] fAmPms; + delete[] fWideAmPms; + delete[] fNarrowAmPms; + delete[] fQuarters; + delete[] fShortQuarters; + delete[] fNarrowQuarters; + delete[] fStandaloneQuarters; + delete[] fStandaloneShortQuarters; + delete[] fStandaloneNarrowQuarters; + delete[] fLeapMonthPatterns; + delete[] fShortYearNames; + delete[] fShortZodiacNames; + delete[] fAbbreviatedDayPeriods; + delete[] fWideDayPeriods; + delete[] fNarrowDayPeriods; + delete[] fStandaloneAbbreviatedDayPeriods; + delete[] fStandaloneWideDayPeriods; + delete[] fStandaloneNarrowDayPeriods; + + actualLocale = Locale::getRoot(); + validLocale = Locale::getRoot(); + disposeZoneStrings(); +} + +void DateFormatSymbols::disposeZoneStrings() +{ + if (fZoneStrings) { + for (int32_t row = 0; row < fZoneStringsRowCount; ++row) { + delete[] fZoneStrings[row]; + } + uprv_free(fZoneStrings); + } + if (fLocaleZoneStrings) { + for (int32_t row = 0; row < fZoneStringsRowCount; ++row) { + delete[] fLocaleZoneStrings[row]; + } + uprv_free(fLocaleZoneStrings); + } + + fZoneStrings = nullptr; + fLocaleZoneStrings = nullptr; + fZoneStringsRowCount = 0; + fZoneStringsColCount = 0; +} + +UBool +DateFormatSymbols::arrayCompare(const UnicodeString* array1, + const UnicodeString* array2, + int32_t count) +{ + if (array1 == array2) return true; + while (count>0) + { + --count; + if (array1[count] != array2[count]) return false; + } + return true; +} + +bool +DateFormatSymbols::operator==(const DateFormatSymbols& other) const +{ + // First do cheap comparisons + if (this == &other) { + return true; + } + if (fErasCount == other.fErasCount && + fEraNamesCount == other.fEraNamesCount && + fNarrowErasCount == other.fNarrowErasCount && + fMonthsCount == other.fMonthsCount && + fShortMonthsCount == other.fShortMonthsCount && + fNarrowMonthsCount == other.fNarrowMonthsCount && + fStandaloneMonthsCount == other.fStandaloneMonthsCount && + fStandaloneShortMonthsCount == other.fStandaloneShortMonthsCount && + fStandaloneNarrowMonthsCount == other.fStandaloneNarrowMonthsCount && + fWeekdaysCount == other.fWeekdaysCount && + fShortWeekdaysCount == other.fShortWeekdaysCount && + fShorterWeekdaysCount == other.fShorterWeekdaysCount && + fNarrowWeekdaysCount == other.fNarrowWeekdaysCount && + fStandaloneWeekdaysCount == other.fStandaloneWeekdaysCount && + fStandaloneShortWeekdaysCount == other.fStandaloneShortWeekdaysCount && + fStandaloneShorterWeekdaysCount == other.fStandaloneShorterWeekdaysCount && + fStandaloneNarrowWeekdaysCount == other.fStandaloneNarrowWeekdaysCount && + fAmPmsCount == other.fAmPmsCount && + fWideAmPmsCount == other.fWideAmPmsCount && + fNarrowAmPmsCount == other.fNarrowAmPmsCount && + fQuartersCount == other.fQuartersCount && + fShortQuartersCount == other.fShortQuartersCount && + fNarrowQuartersCount == other.fNarrowQuartersCount && + fStandaloneQuartersCount == other.fStandaloneQuartersCount && + fStandaloneShortQuartersCount == other.fStandaloneShortQuartersCount && + fStandaloneNarrowQuartersCount == other.fStandaloneNarrowQuartersCount && + fLeapMonthPatternsCount == other.fLeapMonthPatternsCount && + fShortYearNamesCount == other.fShortYearNamesCount && + fShortZodiacNamesCount == other.fShortZodiacNamesCount && + fAbbreviatedDayPeriodsCount == other.fAbbreviatedDayPeriodsCount && + fWideDayPeriodsCount == other.fWideDayPeriodsCount && + fNarrowDayPeriodsCount == other.fNarrowDayPeriodsCount && + fStandaloneAbbreviatedDayPeriodsCount == other.fStandaloneAbbreviatedDayPeriodsCount && + fStandaloneWideDayPeriodsCount == other.fStandaloneWideDayPeriodsCount && + fStandaloneNarrowDayPeriodsCount == other.fStandaloneNarrowDayPeriodsCount && + (uprv_memcmp(fCapitalization, other.fCapitalization, sizeof(fCapitalization))==0)) + { + // Now compare the arrays themselves + if (arrayCompare(fEras, other.fEras, fErasCount) && + arrayCompare(fEraNames, other.fEraNames, fEraNamesCount) && + arrayCompare(fNarrowEras, other.fNarrowEras, fNarrowErasCount) && + arrayCompare(fMonths, other.fMonths, fMonthsCount) && + arrayCompare(fShortMonths, other.fShortMonths, fShortMonthsCount) && + arrayCompare(fNarrowMonths, other.fNarrowMonths, fNarrowMonthsCount) && + arrayCompare(fStandaloneMonths, other.fStandaloneMonths, fStandaloneMonthsCount) && + arrayCompare(fStandaloneShortMonths, other.fStandaloneShortMonths, fStandaloneShortMonthsCount) && + arrayCompare(fStandaloneNarrowMonths, other.fStandaloneNarrowMonths, fStandaloneNarrowMonthsCount) && + arrayCompare(fWeekdays, other.fWeekdays, fWeekdaysCount) && + arrayCompare(fShortWeekdays, other.fShortWeekdays, fShortWeekdaysCount) && + arrayCompare(fShorterWeekdays, other.fShorterWeekdays, fShorterWeekdaysCount) && + arrayCompare(fNarrowWeekdays, other.fNarrowWeekdays, fNarrowWeekdaysCount) && + arrayCompare(fStandaloneWeekdays, other.fStandaloneWeekdays, fStandaloneWeekdaysCount) && + arrayCompare(fStandaloneShortWeekdays, other.fStandaloneShortWeekdays, fStandaloneShortWeekdaysCount) && + arrayCompare(fStandaloneShorterWeekdays, other.fStandaloneShorterWeekdays, fStandaloneShorterWeekdaysCount) && + arrayCompare(fStandaloneNarrowWeekdays, other.fStandaloneNarrowWeekdays, fStandaloneNarrowWeekdaysCount) && + arrayCompare(fAmPms, other.fAmPms, fAmPmsCount) && + arrayCompare(fWideAmPms, other.fWideAmPms, fWideAmPmsCount) && + arrayCompare(fNarrowAmPms, other.fNarrowAmPms, fNarrowAmPmsCount) && + fTimeSeparator == other.fTimeSeparator && + arrayCompare(fQuarters, other.fQuarters, fQuartersCount) && + arrayCompare(fShortQuarters, other.fShortQuarters, fShortQuartersCount) && + arrayCompare(fNarrowQuarters, other.fNarrowQuarters, fNarrowQuartersCount) && + arrayCompare(fStandaloneQuarters, other.fStandaloneQuarters, fStandaloneQuartersCount) && + arrayCompare(fStandaloneShortQuarters, other.fStandaloneShortQuarters, fStandaloneShortQuartersCount) && + arrayCompare(fStandaloneNarrowQuarters, other.fStandaloneNarrowQuarters, fStandaloneNarrowQuartersCount) && + arrayCompare(fLeapMonthPatterns, other.fLeapMonthPatterns, fLeapMonthPatternsCount) && + arrayCompare(fShortYearNames, other.fShortYearNames, fShortYearNamesCount) && + arrayCompare(fShortZodiacNames, other.fShortZodiacNames, fShortZodiacNamesCount) && + arrayCompare(fAbbreviatedDayPeriods, other.fAbbreviatedDayPeriods, fAbbreviatedDayPeriodsCount) && + arrayCompare(fWideDayPeriods, other.fWideDayPeriods, fWideDayPeriodsCount) && + arrayCompare(fNarrowDayPeriods, other.fNarrowDayPeriods, fNarrowDayPeriodsCount) && + arrayCompare(fStandaloneAbbreviatedDayPeriods, other.fStandaloneAbbreviatedDayPeriods, + fStandaloneAbbreviatedDayPeriodsCount) && + arrayCompare(fStandaloneWideDayPeriods, other.fStandaloneWideDayPeriods, + fStandaloneWideDayPeriodsCount) && + arrayCompare(fStandaloneNarrowDayPeriods, other.fStandaloneNarrowDayPeriods, + fStandaloneWideDayPeriodsCount)) + { + // Compare the contents of fZoneStrings + if (fZoneStrings == nullptr && other.fZoneStrings == nullptr) { + if (fZSFLocale == other.fZSFLocale) { + return true; + } + } else if (fZoneStrings != nullptr && other.fZoneStrings != nullptr) { + if (fZoneStringsRowCount == other.fZoneStringsRowCount + && fZoneStringsColCount == other.fZoneStringsColCount) { + bool cmpres = true; + for (int32_t i = 0; (i < fZoneStringsRowCount) && cmpres; i++) { + cmpres = arrayCompare(fZoneStrings[i], other.fZoneStrings[i], fZoneStringsColCount); + } + return cmpres; + } + } + return false; + } + } + return false; +} + +//------------------------------------------------------ + +const UnicodeString* +DateFormatSymbols::getEras(int32_t &count) const +{ + count = fErasCount; + return fEras; +} + +const UnicodeString* +DateFormatSymbols::getEraNames(int32_t &count) const +{ + count = fEraNamesCount; + return fEraNames; +} + +const UnicodeString* +DateFormatSymbols::getNarrowEras(int32_t &count) const +{ + count = fNarrowErasCount; + return fNarrowEras; +} + +const UnicodeString* +DateFormatSymbols::getMonths(int32_t &count) const +{ + count = fMonthsCount; + return fMonths; +} + +const UnicodeString* +DateFormatSymbols::getShortMonths(int32_t &count) const +{ + count = fShortMonthsCount; + return fShortMonths; +} + +const UnicodeString* +DateFormatSymbols::getMonths(int32_t &count, DtContextType context, DtWidthType width ) const +{ + UnicodeString *returnValue = nullptr; + + switch (context) { + case FORMAT : + switch(width) { + case WIDE : + count = fMonthsCount; + returnValue = fMonths; + break; + case ABBREVIATED : + case SHORT : // no month data for this, defaults to ABBREVIATED + count = fShortMonthsCount; + returnValue = fShortMonths; + break; + case NARROW : + count = fNarrowMonthsCount; + returnValue = fNarrowMonths; + break; + case DT_WIDTH_COUNT : + break; + } + break; + case STANDALONE : + switch(width) { + case WIDE : + count = fStandaloneMonthsCount; + returnValue = fStandaloneMonths; + break; + case ABBREVIATED : + case SHORT : // no month data for this, defaults to ABBREVIATED + count = fStandaloneShortMonthsCount; + returnValue = fStandaloneShortMonths; + break; + case NARROW : + count = fStandaloneNarrowMonthsCount; + returnValue = fStandaloneNarrowMonths; + break; + case DT_WIDTH_COUNT : + break; + } + break; + case DT_CONTEXT_COUNT : + break; + } + return returnValue; +} + +const UnicodeString* +DateFormatSymbols::getWeekdays(int32_t &count) const +{ + count = fWeekdaysCount; + return fWeekdays; +} + +const UnicodeString* +DateFormatSymbols::getShortWeekdays(int32_t &count) const +{ + count = fShortWeekdaysCount; + return fShortWeekdays; +} + +const UnicodeString* +DateFormatSymbols::getWeekdays(int32_t &count, DtContextType context, DtWidthType width) const +{ + UnicodeString *returnValue = nullptr; + switch (context) { + case FORMAT : + switch(width) { + case WIDE : + count = fWeekdaysCount; + returnValue = fWeekdays; + break; + case ABBREVIATED : + count = fShortWeekdaysCount; + returnValue = fShortWeekdays; + break; + case SHORT : + count = fShorterWeekdaysCount; + returnValue = fShorterWeekdays; + break; + case NARROW : + count = fNarrowWeekdaysCount; + returnValue = fNarrowWeekdays; + break; + case DT_WIDTH_COUNT : + break; + } + break; + case STANDALONE : + switch(width) { + case WIDE : + count = fStandaloneWeekdaysCount; + returnValue = fStandaloneWeekdays; + break; + case ABBREVIATED : + count = fStandaloneShortWeekdaysCount; + returnValue = fStandaloneShortWeekdays; + break; + case SHORT : + count = fStandaloneShorterWeekdaysCount; + returnValue = fStandaloneShorterWeekdays; + break; + case NARROW : + count = fStandaloneNarrowWeekdaysCount; + returnValue = fStandaloneNarrowWeekdays; + break; + case DT_WIDTH_COUNT : + break; + } + break; + case DT_CONTEXT_COUNT : + break; + } + return returnValue; +} + +const UnicodeString* +DateFormatSymbols::getQuarters(int32_t &count, DtContextType context, DtWidthType width ) const +{ + UnicodeString *returnValue = nullptr; + + switch (context) { + case FORMAT : + switch(width) { + case WIDE : + count = fQuartersCount; + returnValue = fQuarters; + break; + case ABBREVIATED : + case SHORT : // no quarter data for this, defaults to ABBREVIATED + count = fShortQuartersCount; + returnValue = fShortQuarters; + break; + case NARROW : + count = fNarrowQuartersCount; + returnValue = fNarrowQuarters; + break; + case DT_WIDTH_COUNT : + break; + } + break; + case STANDALONE : + switch(width) { + case WIDE : + count = fStandaloneQuartersCount; + returnValue = fStandaloneQuarters; + break; + case ABBREVIATED : + case SHORT : // no quarter data for this, defaults to ABBREVIATED + count = fStandaloneShortQuartersCount; + returnValue = fStandaloneShortQuarters; + break; + case NARROW : + count = fStandaloneNarrowQuartersCount; + returnValue = fStandaloneNarrowQuarters; + break; + case DT_WIDTH_COUNT : + break; + } + break; + case DT_CONTEXT_COUNT : + break; + } + return returnValue; +} + +UnicodeString& +DateFormatSymbols::getTimeSeparatorString(UnicodeString& result) const +{ + // fastCopyFrom() - see assignArray comments + return result.fastCopyFrom(fTimeSeparator); +} + +const UnicodeString* +DateFormatSymbols::getAmPmStrings(int32_t &count) const +{ + return getAmPmStrings(count, FORMAT, ABBREVIATED); +} + +const UnicodeString* +DateFormatSymbols::getAmPmStrings(int32_t &count, DtContextType /*ignored*/, DtWidthType width) const +{ + UnicodeString* const* srcArray; + int32_t const* srcCount; + switch (width) { + case WIDE: + srcArray = &fWideAmPms; + srcCount = &fWideAmPmsCount; + break; + case NARROW: + srcArray = &fNarrowAmPms; + srcCount = &fNarrowAmPmsCount; + break; + case ABBREVIATED: + default: + srcArray = &fAmPms; + srcCount = &fAmPmsCount; + break; + } + + count = *srcCount; + return *srcArray; +} + +const UnicodeString* +DateFormatSymbols::getLeapMonthPatterns(int32_t &count) const +{ + count = fLeapMonthPatternsCount; + return fLeapMonthPatterns; +} + +const UnicodeString* +DateFormatSymbols::getYearNames(int32_t& count, + DtContextType /*ignored*/, DtWidthType /*ignored*/) const +{ + count = fShortYearNamesCount; + return fShortYearNames; +} + +void +DateFormatSymbols::setYearNames(const UnicodeString* yearNames, int32_t count, + DtContextType context, DtWidthType width) +{ + if (context == FORMAT && width == ABBREVIATED) { + delete[] fShortYearNames; + fShortYearNames = newUnicodeStringArray(count); + uprv_arrayCopy(yearNames, fShortYearNames, count); + fShortYearNamesCount = count; + } +} + +const UnicodeString* +DateFormatSymbols::getZodiacNames(int32_t& count, + DtContextType /*ignored*/, DtWidthType /*ignored*/) const +{ + count = fShortZodiacNamesCount; + return fShortZodiacNames; +} + +void +DateFormatSymbols::setZodiacNames(const UnicodeString* zodiacNames, int32_t count, + DtContextType context, DtWidthType width) +{ + if (context == FORMAT && width == ABBREVIATED) { + delete[] fShortZodiacNames; + fShortZodiacNames = newUnicodeStringArray(count); + uprv_arrayCopy(zodiacNames, fShortZodiacNames, count); + fShortZodiacNamesCount = count; + } +} + +//------------------------------------------------------ + +void +DateFormatSymbols::setEras(const UnicodeString* erasArray, int32_t count) +{ + // delete the old list if we own it + delete[] fEras; + + // we always own the new list, which we create here (we duplicate rather + // than adopting the list passed in) + fEras = newUnicodeStringArray(count); + uprv_arrayCopy(erasArray,fEras, count); + fErasCount = count; +} + +void +DateFormatSymbols::setEraNames(const UnicodeString* eraNamesArray, int32_t count) +{ + // delete the old list if we own it + delete[] fEraNames; + + // we always own the new list, which we create here (we duplicate rather + // than adopting the list passed in) + fEraNames = newUnicodeStringArray(count); + uprv_arrayCopy(eraNamesArray,fEraNames, count); + fEraNamesCount = count; +} + +void +DateFormatSymbols::setNarrowEras(const UnicodeString* narrowErasArray, int32_t count) +{ + // delete the old list if we own it + delete[] fNarrowEras; + + // we always own the new list, which we create here (we duplicate rather + // than adopting the list passed in) + fNarrowEras = newUnicodeStringArray(count); + uprv_arrayCopy(narrowErasArray,fNarrowEras, count); + fNarrowErasCount = count; +} + +void +DateFormatSymbols::setMonths(const UnicodeString* monthsArray, int32_t count) +{ + // delete the old list if we own it + delete[] fMonths; + + // we always own the new list, which we create here (we duplicate rather + // than adopting the list passed in) + fMonths = newUnicodeStringArray(count); + uprv_arrayCopy( monthsArray,fMonths,count); + fMonthsCount = count; +} + +void +DateFormatSymbols::setShortMonths(const UnicodeString* shortMonthsArray, int32_t count) +{ + // delete the old list if we own it + delete[] fShortMonths; + + // we always own the new list, which we create here (we duplicate rather + // than adopting the list passed in) + fShortMonths = newUnicodeStringArray(count); + uprv_arrayCopy(shortMonthsArray,fShortMonths, count); + fShortMonthsCount = count; +} + +void +DateFormatSymbols::setMonths(const UnicodeString* monthsArray, int32_t count, DtContextType context, DtWidthType width) +{ + // delete the old list if we own it + // we always own the new list, which we create here (we duplicate rather + // than adopting the list passed in) + + switch (context) { + case FORMAT : + switch (width) { + case WIDE : + delete[] fMonths; + fMonths = newUnicodeStringArray(count); + uprv_arrayCopy( monthsArray,fMonths,count); + fMonthsCount = count; + break; + case ABBREVIATED : + delete[] fShortMonths; + fShortMonths = newUnicodeStringArray(count); + uprv_arrayCopy( monthsArray,fShortMonths,count); + fShortMonthsCount = count; + break; + case NARROW : + delete[] fNarrowMonths; + fNarrowMonths = newUnicodeStringArray(count); + uprv_arrayCopy( monthsArray,fNarrowMonths,count); + fNarrowMonthsCount = count; + break; + default : + break; + } + break; + case STANDALONE : + switch (width) { + case WIDE : + delete[] fStandaloneMonths; + fStandaloneMonths = newUnicodeStringArray(count); + uprv_arrayCopy( monthsArray,fStandaloneMonths,count); + fStandaloneMonthsCount = count; + break; + case ABBREVIATED : + delete[] fStandaloneShortMonths; + fStandaloneShortMonths = newUnicodeStringArray(count); + uprv_arrayCopy( monthsArray,fStandaloneShortMonths,count); + fStandaloneShortMonthsCount = count; + break; + case NARROW : + delete[] fStandaloneNarrowMonths; + fStandaloneNarrowMonths = newUnicodeStringArray(count); + uprv_arrayCopy( monthsArray,fStandaloneNarrowMonths,count); + fStandaloneNarrowMonthsCount = count; + break; + default : + break; + } + break; + case DT_CONTEXT_COUNT : + break; + } +} + +void DateFormatSymbols::setWeekdays(const UnicodeString* weekdaysArray, int32_t count) +{ + // delete the old list if we own it + delete[] fWeekdays; + + // we always own the new list, which we create here (we duplicate rather + // than adopting the list passed in) + fWeekdays = newUnicodeStringArray(count); + uprv_arrayCopy(weekdaysArray,fWeekdays,count); + fWeekdaysCount = count; +} + +void +DateFormatSymbols::setShortWeekdays(const UnicodeString* shortWeekdaysArray, int32_t count) +{ + // delete the old list if we own it + delete[] fShortWeekdays; + + // we always own the new list, which we create here (we duplicate rather + // than adopting the list passed in) + fShortWeekdays = newUnicodeStringArray(count); + uprv_arrayCopy(shortWeekdaysArray, fShortWeekdays, count); + fShortWeekdaysCount = count; +} + +void +DateFormatSymbols::setWeekdays(const UnicodeString* weekdaysArray, int32_t count, DtContextType context, DtWidthType width) +{ + // delete the old list if we own it + // we always own the new list, which we create here (we duplicate rather + // than adopting the list passed in) + + switch (context) { + case FORMAT : + switch (width) { + case WIDE : + delete[] fWeekdays; + fWeekdays = newUnicodeStringArray(count); + uprv_arrayCopy(weekdaysArray, fWeekdays, count); + fWeekdaysCount = count; + break; + case ABBREVIATED : + delete[] fShortWeekdays; + fShortWeekdays = newUnicodeStringArray(count); + uprv_arrayCopy(weekdaysArray, fShortWeekdays, count); + fShortWeekdaysCount = count; + break; + case SHORT : + delete[] fShorterWeekdays; + fShorterWeekdays = newUnicodeStringArray(count); + uprv_arrayCopy(weekdaysArray, fShorterWeekdays, count); + fShorterWeekdaysCount = count; + break; + case NARROW : + delete[] fNarrowWeekdays; + fNarrowWeekdays = newUnicodeStringArray(count); + uprv_arrayCopy(weekdaysArray, fNarrowWeekdays, count); + fNarrowWeekdaysCount = count; + break; + case DT_WIDTH_COUNT : + break; + } + break; + case STANDALONE : + switch (width) { + case WIDE : + delete[] fStandaloneWeekdays; + fStandaloneWeekdays = newUnicodeStringArray(count); + uprv_arrayCopy(weekdaysArray, fStandaloneWeekdays, count); + fStandaloneWeekdaysCount = count; + break; + case ABBREVIATED : + delete[] fStandaloneShortWeekdays; + fStandaloneShortWeekdays = newUnicodeStringArray(count); + uprv_arrayCopy(weekdaysArray, fStandaloneShortWeekdays, count); + fStandaloneShortWeekdaysCount = count; + break; + case SHORT : + delete[] fStandaloneShorterWeekdays; + fStandaloneShorterWeekdays = newUnicodeStringArray(count); + uprv_arrayCopy(weekdaysArray, fStandaloneShorterWeekdays, count); + fStandaloneShorterWeekdaysCount = count; + break; + case NARROW : + delete[] fStandaloneNarrowWeekdays; + fStandaloneNarrowWeekdays = newUnicodeStringArray(count); + uprv_arrayCopy(weekdaysArray, fStandaloneNarrowWeekdays, count); + fStandaloneNarrowWeekdaysCount = count; + break; + case DT_WIDTH_COUNT : + break; + } + break; + case DT_CONTEXT_COUNT : + break; + } +} + +void +DateFormatSymbols::setQuarters(const UnicodeString* quartersArray, int32_t count, DtContextType context, DtWidthType width) +{ + // delete the old list if we own it + // we always own the new list, which we create here (we duplicate rather + // than adopting the list passed in) + + switch (context) { + case FORMAT : + switch (width) { + case WIDE : + delete[] fQuarters; + fQuarters = newUnicodeStringArray(count); + uprv_arrayCopy( quartersArray,fQuarters,count); + fQuartersCount = count; + break; + case ABBREVIATED : + delete[] fShortQuarters; + fShortQuarters = newUnicodeStringArray(count); + uprv_arrayCopy( quartersArray,fShortQuarters,count); + fShortQuartersCount = count; + break; + case NARROW : + delete[] fNarrowQuarters; + fNarrowQuarters = newUnicodeStringArray(count); + uprv_arrayCopy( quartersArray,fNarrowQuarters,count); + fNarrowQuartersCount = count; + break; + default : + break; + } + break; + case STANDALONE : + switch (width) { + case WIDE : + delete[] fStandaloneQuarters; + fStandaloneQuarters = newUnicodeStringArray(count); + uprv_arrayCopy( quartersArray,fStandaloneQuarters,count); + fStandaloneQuartersCount = count; + break; + case ABBREVIATED : + delete[] fStandaloneShortQuarters; + fStandaloneShortQuarters = newUnicodeStringArray(count); + uprv_arrayCopy( quartersArray,fStandaloneShortQuarters,count); + fStandaloneShortQuartersCount = count; + break; + case NARROW : + delete[] fStandaloneNarrowQuarters; + fStandaloneNarrowQuarters = newUnicodeStringArray(count); + uprv_arrayCopy( quartersArray,fStandaloneNarrowQuarters,count); + fStandaloneNarrowQuartersCount = count; + break; + default : + break; + } + break; + case DT_CONTEXT_COUNT : + break; + } +} + +void +DateFormatSymbols::setAmPmStrings(const UnicodeString* amPmsArray, int32_t count) +{ + setAmPmStrings(amPmsArray, count, FORMAT, ABBREVIATED); +} + +void +DateFormatSymbols::setAmPmStrings(const UnicodeString* amPmsArray, int32_t count, DtContextType /*ignored*/, DtWidthType width) +{ + UnicodeString** targetArray; + int32_t* targetCount; + switch (width) { + case WIDE: + targetArray = &fWideAmPms; + targetCount = &fWideAmPmsCount; + break; + case NARROW: + targetArray = &fNarrowAmPms; + targetCount = &fNarrowAmPmsCount; + break; + case ABBREVIATED: + default: + targetArray = &fAmPms; + targetCount = &fAmPmsCount; + break; + } + + // delete the old list if we own it + delete[] *targetArray; + + // we always own the new list, which we create here (we duplicate rather + // than adopting the list passed in) + *targetArray = newUnicodeStringArray(count); + uprv_arrayCopy(amPmsArray,*targetArray,count); + *targetCount = count; +} + +void +DateFormatSymbols::setTimeSeparatorString(const UnicodeString& newTimeSeparator) +{ + fTimeSeparator = newTimeSeparator; +} + +const UnicodeString** +DateFormatSymbols::getZoneStrings(int32_t& rowCount, int32_t& columnCount) const +{ + const UnicodeString **result = nullptr; + static UMutex LOCK; + + umtx_lock(&LOCK); + if (fZoneStrings == nullptr) { + if (fLocaleZoneStrings == nullptr) { + const_cast(this)->initZoneStringsArray(); + } + result = (const UnicodeString**)fLocaleZoneStrings; + } else { + result = (const UnicodeString**)fZoneStrings; + } + rowCount = fZoneStringsRowCount; + columnCount = fZoneStringsColCount; + umtx_unlock(&LOCK); + + return result; +} + +// For now, we include all zones +#define ZONE_SET UCAL_ZONE_TYPE_ANY + +// This code must be called within a synchronized block +void +DateFormatSymbols::initZoneStringsArray() { + if (fZoneStrings != nullptr || fLocaleZoneStrings != nullptr) { + return; + } + + UErrorCode status = U_ZERO_ERROR; + + StringEnumeration *tzids = nullptr; + UnicodeString ** zarray = nullptr; + TimeZoneNames *tzNames = nullptr; + int32_t rows = 0; + + static const UTimeZoneNameType TYPES[] = { + UTZNM_LONG_STANDARD, UTZNM_SHORT_STANDARD, + UTZNM_LONG_DAYLIGHT, UTZNM_SHORT_DAYLIGHT + }; + static const int32_t NUM_TYPES = 4; + + do { // dummy do-while + + tzids = TimeZone::createTimeZoneIDEnumeration(ZONE_SET, nullptr, nullptr, status); + rows = tzids->count(status); + if (U_FAILURE(status)) { + break; + } + + // Allocate array + int32_t size = rows * sizeof(UnicodeString*); + zarray = static_cast(uprv_malloc(size)); + if (zarray == nullptr) { + status = U_MEMORY_ALLOCATION_ERROR; + break; + } + uprv_memset(zarray, 0, size); + + tzNames = TimeZoneNames::createInstance(fZSFLocale, status); + tzNames->loadAllDisplayNames(status); + if (U_FAILURE(status)) { break; } + + const UnicodeString *tzid; + int32_t i = 0; + UDate now = Calendar::getNow(); + UnicodeString tzDispName; + + while ((tzid = tzids->snext(status)) != nullptr) { + if (U_FAILURE(status)) { + break; + } + + zarray[i] = new UnicodeString[5]; + if (zarray[i] == nullptr) { + status = U_MEMORY_ALLOCATION_ERROR; + break; + } + + zarray[i][0].setTo(*tzid); + tzNames->getDisplayNames(*tzid, TYPES, NUM_TYPES, now, zarray[i]+1, status); + i++; + } + + } while (false); + + if (U_FAILURE(status)) { + if (zarray) { + for (int32_t i = 0; i < rows; i++) { + if (zarray[i]) { + delete[] zarray[i]; + } + } + uprv_free(zarray); + zarray = nullptr; + } + } + + delete tzNames; + delete tzids; + + fLocaleZoneStrings = zarray; + fZoneStringsRowCount = rows; + fZoneStringsColCount = 1 + NUM_TYPES; +} + +void +DateFormatSymbols::setZoneStrings(const UnicodeString* const *strings, int32_t rowCount, int32_t columnCount) +{ + // since deleting a 2-d array is a pain in the butt, we offload that task to + // a separate function + disposeZoneStrings(); + // we always own the new list, which we create here (we duplicate rather + // than adopting the list passed in) + fZoneStringsRowCount = rowCount; + fZoneStringsColCount = columnCount; + createZoneStrings(const_cast(strings)); +} + +//------------------------------------------------------ + +const char16_t * U_EXPORT2 +DateFormatSymbols::getPatternUChars() +{ + return gPatternChars; +} + +UDateFormatField U_EXPORT2 +DateFormatSymbols::getPatternCharIndex(char16_t c) { + if (c >= UPRV_LENGTHOF(gLookupPatternChars)) { + return UDAT_FIELD_COUNT; + } + const auto idx = gLookupPatternChars[c]; + return idx == -1 ? UDAT_FIELD_COUNT : static_cast(idx); +} + +static const uint64_t kNumericFieldsAlways = + (static_cast(1) << UDAT_YEAR_FIELD) | // y + (static_cast(1) << UDAT_DATE_FIELD) | // d + (static_cast(1) << UDAT_HOUR_OF_DAY1_FIELD) | // k + (static_cast(1) << UDAT_HOUR_OF_DAY0_FIELD) | // H + (static_cast(1) << UDAT_MINUTE_FIELD) | // m + (static_cast(1) << UDAT_SECOND_FIELD) | // s + (static_cast(1) << UDAT_FRACTIONAL_SECOND_FIELD) | // S + (static_cast(1) << UDAT_DAY_OF_YEAR_FIELD) | // D + (static_cast(1) << UDAT_DAY_OF_WEEK_IN_MONTH_FIELD) | // F + (static_cast(1) << UDAT_WEEK_OF_YEAR_FIELD) | // w + (static_cast(1) << UDAT_WEEK_OF_MONTH_FIELD) | // W + (static_cast(1) << UDAT_HOUR1_FIELD) | // h + (static_cast(1) << UDAT_HOUR0_FIELD) | // K + (static_cast(1) << UDAT_YEAR_WOY_FIELD) | // Y + (static_cast(1) << UDAT_EXTENDED_YEAR_FIELD) | // u + (static_cast(1) << UDAT_JULIAN_DAY_FIELD) | // g + (static_cast(1) << UDAT_MILLISECONDS_IN_DAY_FIELD) | // A + (static_cast(1) << UDAT_RELATED_YEAR_FIELD); // r + +static const uint64_t kNumericFieldsForCount12 = + (static_cast(1) << UDAT_MONTH_FIELD) | // M or MM + (static_cast(1) << UDAT_DOW_LOCAL_FIELD) | // e or ee + (static_cast(1) << UDAT_STANDALONE_DAY_FIELD) | // c or cc + (static_cast(1) << UDAT_STANDALONE_MONTH_FIELD) | // L or LL + (static_cast(1) << UDAT_QUARTER_FIELD) | // Q or QQ + (static_cast(1) << UDAT_STANDALONE_QUARTER_FIELD); // q or qq + +UBool U_EXPORT2 +DateFormatSymbols::isNumericField(UDateFormatField f, int32_t count) { + if (f == UDAT_FIELD_COUNT) { + return false; + } + uint64_t flag = static_cast(1) << f; + return ((kNumericFieldsAlways & flag) != 0 || ((kNumericFieldsForCount12 & flag) != 0 && count < 3)); +} + +UBool U_EXPORT2 +DateFormatSymbols::isNumericPatternChar(char16_t c, int32_t count) { + return isNumericField(getPatternCharIndex(c), count); +} + +//------------------------------------------------------ + +UnicodeString& +DateFormatSymbols::getLocalPatternChars(UnicodeString& result) const +{ + // fastCopyFrom() - see assignArray comments + return result.fastCopyFrom(fLocalPatternChars); +} + +//------------------------------------------------------ + +void +DateFormatSymbols::setLocalPatternChars(const UnicodeString& newLocalPatternChars) +{ + fLocalPatternChars = newLocalPatternChars; +} + +//------------------------------------------------------ + +namespace { + +// Constants declarations +const char16_t kCalendarAliasPrefixUChar[] = { + SOLIDUS, CAP_L, CAP_O, CAP_C, CAP_A, CAP_L, CAP_E, SOLIDUS, + LOW_C, LOW_A, LOW_L, LOW_E, LOW_N, LOW_D, LOW_A, LOW_R, SOLIDUS +}; +const char16_t kGregorianTagUChar[] = { + LOW_G, LOW_R, LOW_E, LOW_G, LOW_O, LOW_R, LOW_I, LOW_A, LOW_N +}; +const char16_t kVariantTagUChar[] = { + PERCENT, LOW_V, LOW_A, LOW_R, LOW_I, LOW_A, LOW_N, LOW_T +}; +const char16_t kLeapTagUChar[] = { + LOW_L, LOW_E, LOW_A, LOW_P +}; +const char16_t kCyclicNameSetsTagUChar[] = { + LOW_C, LOW_Y, LOW_C, LOW_L, LOW_I, LOW_C, CAP_N, LOW_A, LOW_M, LOW_E, CAP_S, LOW_E, LOW_T, LOW_S +}; +const char16_t kYearsTagUChar[] = { + SOLIDUS, LOW_Y, LOW_E, LOW_A, LOW_R, LOW_S +}; +const char16_t kZodiacsUChar[] = { + SOLIDUS, LOW_Z, LOW_O, LOW_D, LOW_I, LOW_A, LOW_C, LOW_S +}; +const char16_t kDayPartsTagUChar[] = { + SOLIDUS, LOW_D, LOW_A, LOW_Y, CAP_P, LOW_A, LOW_R, LOW_T, LOW_S +}; +const char16_t kFormatTagUChar[] = { + SOLIDUS, LOW_F, LOW_O, LOW_R, LOW_M, LOW_A, LOW_T +}; +const char16_t kAbbrTagUChar[] = { + SOLIDUS, LOW_A, LOW_B, LOW_B, LOW_R, LOW_E, LOW_V, LOW_I, LOW_A, LOW_T, LOW_E, LOW_D +}; + +// ResourceSink to enumerate all calendar resources +struct CalendarDataSink : public ResourceSink { + + // Enum which specifies the type of alias received, or no alias + enum AliasType { + SAME_CALENDAR, + DIFFERENT_CALENDAR, + GREGORIAN, + NONE + }; + + // Data structures to store resources from the current resource bundle + Hashtable arrays; + Hashtable arraySizes; + Hashtable maps; + /** + * Whenever there are aliases, the same object will be added twice to 'map'. + * To avoid double deletion, 'maps' won't take ownership of the objects. Instead, + * 'mapRefs' will own them and will delete them when CalendarDataSink is deleted. + */ + MemoryPool mapRefs; + + // Paths and the aliases they point to + UVector aliasPathPairs; + + // Current and next calendar resource table which should be loaded + UnicodeString currentCalendarType; + UnicodeString nextCalendarType; + + // Resources to visit when enumerating fallback calendars + LocalPointer resourcesToVisit; + + // Alias' relative path populated whenever an alias is read + UnicodeString aliasRelativePath; + + // Initializes CalendarDataSink with default values + CalendarDataSink(UErrorCode& status) + : arrays(false, status), arraySizes(false, status), maps(false, status), + mapRefs(), + aliasPathPairs(uprv_deleteUObject, uhash_compareUnicodeString, status), + currentCalendarType(), nextCalendarType(), + resourcesToVisit(nullptr), aliasRelativePath() { + if (U_FAILURE(status)) { return; } + } + virtual ~CalendarDataSink(); + + // Configure the CalendarSink to visit all the resources + void visitAllResources() { + resourcesToVisit.adoptInstead(nullptr); + } + + // Actions to be done before enumerating + void preEnumerate(const UnicodeString &calendarType) { + currentCalendarType = calendarType; + nextCalendarType.setToBogus(); + aliasPathPairs.removeAllElements(); + } + + virtual void put(const char *key, ResourceValue &value, UBool, UErrorCode &errorCode) override { + if (U_FAILURE(errorCode)) { return; } + U_ASSERT(!currentCalendarType.isEmpty()); + + // Stores the resources to visit on the next calendar. + LocalPointer resourcesToVisitNext(nullptr); + ResourceTable calendarData = value.getTable(errorCode); + if (U_FAILURE(errorCode)) { return; } + + // Enumerate all resources for this calendar + for (int i = 0; calendarData.getKeyAndValue(i, key, value); i++) { + UnicodeString keyUString(key, -1, US_INV); + + // == Handle aliases == + AliasType aliasType = processAliasFromValue(keyUString, value, errorCode); + if (U_FAILURE(errorCode)) { return; } + if (aliasType == GREGORIAN) { + // Ignore aliases to the gregorian calendar, all of its resources will be loaded anyway. + continue; + + } else if (aliasType == DIFFERENT_CALENDAR) { + // Whenever an alias to the next calendar (except gregorian) is encountered, register the + // calendar type it's pointing to + if (resourcesToVisitNext.isNull()) { + resourcesToVisitNext + .adoptInsteadAndCheckErrorCode(new UVector(uprv_deleteUObject, uhash_compareUnicodeString, errorCode), + errorCode); + if (U_FAILURE(errorCode)) { return; } + } + LocalPointer aliasRelativePathCopy(aliasRelativePath.clone(), errorCode); + resourcesToVisitNext->adoptElement(aliasRelativePathCopy.orphan(), errorCode); + if (U_FAILURE(errorCode)) { return; } + continue; + + } else if (aliasType == SAME_CALENDAR) { + // Register same-calendar alias + if (arrays.get(aliasRelativePath) == nullptr && maps.get(aliasRelativePath) == nullptr) { + LocalPointer aliasRelativePathCopy(aliasRelativePath.clone(), errorCode); + aliasPathPairs.adoptElement(aliasRelativePathCopy.orphan(), errorCode); + if (U_FAILURE(errorCode)) { return; } + LocalPointer keyUStringCopy(keyUString.clone(), errorCode); + aliasPathPairs.adoptElement(keyUStringCopy.orphan(), errorCode); + if (U_FAILURE(errorCode)) { return; } + } + continue; + } + + // Only visit the resources that were referenced by an alias on the previous calendar + // (AmPmMarkersAbbr is an exception). + if (!resourcesToVisit.isNull() && !resourcesToVisit->isEmpty() && !resourcesToVisit->contains(&keyUString) + && uprv_strcmp(key, gAmPmMarkersAbbrTag) != 0) { continue; } + + // == Handle data == + if (uprv_strcmp(key, gAmPmMarkersTag) == 0 + || uprv_strcmp(key, gAmPmMarkersAbbrTag) == 0 + || uprv_strcmp(key, gAmPmMarkersNarrowTag) == 0) { + if (arrays.get(keyUString) == nullptr) { + ResourceArray resourceArray = value.getArray(errorCode); + int32_t arraySize = resourceArray.getSize(); + LocalArray stringArray(new UnicodeString[arraySize], errorCode); + value.getStringArray(stringArray.getAlias(), arraySize, errorCode); + arrays.put(keyUString, stringArray.orphan(), errorCode); + arraySizes.puti(keyUString, arraySize, errorCode); + if (U_FAILURE(errorCode)) { return; } + } + } else if (uprv_strcmp(key, gErasTag) == 0 + || uprv_strcmp(key, gDayNamesTag) == 0 + || uprv_strcmp(key, gMonthNamesTag) == 0 + || uprv_strcmp(key, gQuartersTag) == 0 + || uprv_strcmp(key, gDayPeriodTag) == 0 + || uprv_strcmp(key, gMonthPatternsTag) == 0 + || uprv_strcmp(key, gCyclicNameSetsTag) == 0) { + processResource(keyUString, key, value, errorCode); + } + } + + // Apply same-calendar aliases + UBool modified; + do { + modified = false; + for (int32_t i = 0; i < aliasPathPairs.size();) { + UBool mod = false; + UnicodeString* alias = static_cast(aliasPathPairs[i]); + UnicodeString *aliasArray; + Hashtable *aliasMap; + if ((aliasArray = static_cast(arrays.get(*alias))) != nullptr) { + UnicodeString* path = static_cast(aliasPathPairs[i + 1]); + if (arrays.get(*path) == nullptr) { + // Clone the array + int32_t aliasArraySize = arraySizes.geti(*alias); + LocalArray aliasArrayCopy(new UnicodeString[aliasArraySize], errorCode); + if (U_FAILURE(errorCode)) { return; } + uprv_arrayCopy(aliasArray, aliasArrayCopy.getAlias(), aliasArraySize); + // Put the array on the 'arrays' map + arrays.put(*path, aliasArrayCopy.orphan(), errorCode); + arraySizes.puti(*path, aliasArraySize, errorCode); + } + if (U_FAILURE(errorCode)) { return; } + mod = true; + } else if ((aliasMap = static_cast(maps.get(*alias))) != nullptr) { + UnicodeString* path = static_cast(aliasPathPairs[i + 1]); + if (maps.get(*path) == nullptr) { + maps.put(*path, aliasMap, errorCode); + } + if (U_FAILURE(errorCode)) { return; } + mod = true; + } + if (mod) { + aliasPathPairs.removeElementAt(i + 1); + aliasPathPairs.removeElementAt(i); + modified = true; + } else { + i += 2; + } + } + } while (modified && !aliasPathPairs.isEmpty()); + + // Set the resources to visit on the next calendar + if (!resourcesToVisitNext.isNull()) { + resourcesToVisit = std::move(resourcesToVisitNext); + } + } + + // Process the nested resource bundle tables + void processResource(UnicodeString &path, const char *key, ResourceValue &value, UErrorCode &errorCode) { + if (U_FAILURE(errorCode)) return; + + ResourceTable table = value.getTable(errorCode); + if (U_FAILURE(errorCode)) return; + Hashtable* stringMap = nullptr; + + // Iterate over all the elements of the table and add them to the map + for (int i = 0; table.getKeyAndValue(i, key, value); i++) { + UnicodeString keyUString(key, -1, US_INV); + + // Ignore '%variant' keys + if (keyUString.endsWith(kVariantTagUChar, UPRV_LENGTHOF(kVariantTagUChar))) { + continue; + } + + // == Handle String elements == + if (value.getType() == URES_STRING) { + // We are on a leaf, store the map elements into the stringMap + if (i == 0) { + // mapRefs will keep ownership of 'stringMap': + stringMap = mapRefs.create(false, errorCode); + if (stringMap == nullptr) { + errorCode = U_MEMORY_ALLOCATION_ERROR; + return; + } + maps.put(path, stringMap, errorCode); + if (U_FAILURE(errorCode)) { return; } + stringMap->setValueDeleter(uprv_deleteUObject); + } + U_ASSERT(stringMap != nullptr); + int32_t valueStringSize; + const char16_t *valueString = value.getString(valueStringSize, errorCode); + if (U_FAILURE(errorCode)) { return; } + LocalPointer valueUString(new UnicodeString(true, valueString, valueStringSize), errorCode); + stringMap->put(keyUString, valueUString.orphan(), errorCode); + if (U_FAILURE(errorCode)) { return; } + continue; + } + U_ASSERT(stringMap == nullptr); + + // Store the current path's length and append the current key to the path. + int32_t pathLength = path.length(); + path.append(SOLIDUS).append(keyUString); + + // In cyclicNameSets ignore everything but years/format/abbreviated + // and zodiacs/format/abbreviated + if (path.startsWith(kCyclicNameSetsTagUChar, UPRV_LENGTHOF(kCyclicNameSetsTagUChar))) { + UBool skip = true; + int32_t startIndex = UPRV_LENGTHOF(kCyclicNameSetsTagUChar); + int32_t length = 0; + if (startIndex == path.length() + || path.compare(startIndex, (length = UPRV_LENGTHOF(kZodiacsUChar)), kZodiacsUChar, 0, UPRV_LENGTHOF(kZodiacsUChar)) == 0 + || path.compare(startIndex, (length = UPRV_LENGTHOF(kYearsTagUChar)), kYearsTagUChar, 0, UPRV_LENGTHOF(kYearsTagUChar)) == 0 + || path.compare(startIndex, (length = UPRV_LENGTHOF(kDayPartsTagUChar)), kDayPartsTagUChar, 0, UPRV_LENGTHOF(kDayPartsTagUChar)) == 0) { + startIndex += length; + length = 0; + if (startIndex == path.length() + || path.compare(startIndex, (length = UPRV_LENGTHOF(kFormatTagUChar)), kFormatTagUChar, 0, UPRV_LENGTHOF(kFormatTagUChar)) == 0) { + startIndex += length; + length = 0; + if (startIndex == path.length() + || path.compare(startIndex, (length = UPRV_LENGTHOF(kAbbrTagUChar)), kAbbrTagUChar, 0, UPRV_LENGTHOF(kAbbrTagUChar)) == 0) { + skip = false; + } + } + } + if (skip) { + // Drop the latest key on the path and continue + path.retainBetween(0, pathLength); + continue; + } + } + + // == Handle aliases == + if (arrays.get(path) != nullptr || maps.get(path) != nullptr) { + // Drop the latest key on the path and continue + path.retainBetween(0, pathLength); + continue; + } + + AliasType aliasType = processAliasFromValue(path, value, errorCode); + if (U_FAILURE(errorCode)) { return; } + if (aliasType == SAME_CALENDAR) { + // Store the alias path and the current path on aliasPathPairs + LocalPointer aliasRelativePathCopy(aliasRelativePath.clone(), errorCode); + aliasPathPairs.adoptElement(aliasRelativePathCopy.orphan(), errorCode); + if (U_FAILURE(errorCode)) { return; } + LocalPointer pathCopy(path.clone(), errorCode); + aliasPathPairs.adoptElement(pathCopy.orphan(), errorCode); + if (U_FAILURE(errorCode)) { return; } + + // Drop the latest key on the path and continue + path.retainBetween(0, pathLength); + continue; + } + U_ASSERT(aliasType == NONE); + + // == Handle data == + if (value.getType() == URES_ARRAY) { + // We are on a leaf, store the array + ResourceArray rDataArray = value.getArray(errorCode); + int32_t dataArraySize = rDataArray.getSize(); + LocalArray dataArray(new UnicodeString[dataArraySize], errorCode); + value.getStringArray(dataArray.getAlias(), dataArraySize, errorCode); + arrays.put(path, dataArray.orphan(), errorCode); + arraySizes.puti(path, dataArraySize, errorCode); + if (U_FAILURE(errorCode)) { return; } + } else if (value.getType() == URES_TABLE) { + // We are not on a leaf, recursively process the subtable. + processResource(path, key, value, errorCode); + if (U_FAILURE(errorCode)) { return; } + } + + // Drop the latest key on the path + path.retainBetween(0, pathLength); + } + } + + // Populates an AliasIdentifier with the alias information contained on the UResource.Value. + AliasType processAliasFromValue(UnicodeString ¤tRelativePath, ResourceValue &value, + UErrorCode &errorCode) { + if (U_FAILURE(errorCode)) { return NONE; } + + if (value.getType() == URES_ALIAS) { + int32_t aliasPathSize; + const char16_t* aliasPathUChar = value.getAliasString(aliasPathSize, errorCode); + if (U_FAILURE(errorCode)) { return NONE; } + UnicodeString aliasPath(aliasPathUChar, aliasPathSize); + const int32_t aliasPrefixLength = UPRV_LENGTHOF(kCalendarAliasPrefixUChar); + if (aliasPath.startsWith(kCalendarAliasPrefixUChar, aliasPrefixLength) + && aliasPath.length() > aliasPrefixLength) { + int32_t typeLimit = aliasPath.indexOf(SOLIDUS, aliasPrefixLength); + if (typeLimit > aliasPrefixLength) { + const UnicodeString aliasCalendarType = + aliasPath.tempSubStringBetween(aliasPrefixLength, typeLimit); + aliasRelativePath.setTo(aliasPath, typeLimit + 1, aliasPath.length()); + + if (currentCalendarType == aliasCalendarType + && currentRelativePath != aliasRelativePath) { + // If we have an alias to the same calendar, the path to the resource must be different + return SAME_CALENDAR; + + } else if (currentCalendarType != aliasCalendarType + && currentRelativePath == aliasRelativePath) { + // If we have an alias to a different calendar, the path to the resource must be the same + if (aliasCalendarType.compare(kGregorianTagUChar, UPRV_LENGTHOF(kGregorianTagUChar)) == 0) { + return GREGORIAN; + } else if (nextCalendarType.isBogus()) { + nextCalendarType = aliasCalendarType; + return DIFFERENT_CALENDAR; + } else if (nextCalendarType == aliasCalendarType) { + return DIFFERENT_CALENDAR; + } + } + } + } + errorCode = U_INTERNAL_PROGRAM_ERROR; + return NONE; + } + return NONE; + } + + // Deleter function to be used by 'arrays' + static void U_CALLCONV deleteUnicodeStringArray(void *uArray) { + delete[] static_cast(uArray); + } +}; +// Virtual destructors have to be defined out of line +CalendarDataSink::~CalendarDataSink() { + arrays.setValueDeleter(deleteUnicodeStringArray); +} +} + +//------------------------------------------------------ + +static void +initField(UnicodeString **field, int32_t& length, const char16_t *data, LastResortSize numStr, LastResortSize strLen, UErrorCode &status) { + if (U_SUCCESS(status)) { + length = numStr; + *field = newUnicodeStringArray(static_cast(numStr)); + if (*field) { + for(int32_t i = 0; isetTo(true, data + (i * (static_cast(strLen))), -1); + } + } + else { + length = 0; + status = U_MEMORY_ALLOCATION_ERROR; + } + } +} + +static void +initField(UnicodeString **field, int32_t& length, CalendarDataSink &sink, CharString &key, UErrorCode &status) { + if (U_SUCCESS(status)) { + UnicodeString keyUString(key.data(), -1, US_INV); + UnicodeString* array = static_cast(sink.arrays.get(keyUString)); + + if (array != nullptr) { + length = sink.arraySizes.geti(keyUString); + *field = array; + // DateFormatSymbols takes ownership of the array: + sink.arrays.remove(keyUString); + } else { + length = 0; + status = U_MISSING_RESOURCE_ERROR; + } + } +} + +static void +initField(UnicodeString **field, int32_t& length, CalendarDataSink &sink, CharString &key, int32_t arrayOffset, UErrorCode &status) { + if (U_SUCCESS(status)) { + UnicodeString keyUString(key.data(), -1, US_INV); + UnicodeString* array = static_cast(sink.arrays.get(keyUString)); + + if (array != nullptr) { + int32_t arrayLength = sink.arraySizes.geti(keyUString); + length = arrayLength + arrayOffset; + *field = new UnicodeString[length]; + if (*field == nullptr) { + status = U_MEMORY_ALLOCATION_ERROR; + return; + } + uprv_arrayCopy(array, 0, *field, arrayOffset, arrayLength); + } else { + length = 0; + status = U_MISSING_RESOURCE_ERROR; + } + } +} + +static void +initEras(UnicodeString **field, int32_t& length, CalendarDataSink &sink, CharString &key, const UResourceBundle *ctebPtr, const char* eraWidth, int32_t maxEra, UErrorCode &status) { + if (U_SUCCESS(status)) { + length = 0; + UnicodeString keyUString(key.data(), -1, US_INV); + Hashtable *eraNamesTable = static_cast(sink.maps.get(keyUString)); + + if (eraNamesTable != nullptr) { + UErrorCode resStatus = U_ZERO_ERROR; + LocalUResourceBundlePointer ctewb(ures_getByKeyWithFallback(ctebPtr, eraWidth, nullptr, &resStatus)); + const UResourceBundle *ctewbPtr = (U_SUCCESS(resStatus))? ctewb.getAlias() : nullptr; + *field = new UnicodeString[maxEra + 1]; + if (*field == nullptr) { + status = U_MEMORY_ALLOCATION_ERROR; + return; + } + length = maxEra + 1; + for (int32_t eraCode = 0; eraCode <= maxEra; eraCode++) { + char eraCodeStr[12]; // T_CString_integerToString is documented to generate at most 12 bytes including nul terminator + int32_t eraCodeStrLen = T_CString_integerToString(eraCodeStr, eraCode, 10); + UnicodeString eraCodeKey = UnicodeString(eraCodeStr, eraCodeStrLen, US_INV); + UnicodeString *eraName = static_cast(eraNamesTable->get(eraCodeKey)); + (*field)[eraCode].remove(); + if (eraName != nullptr) { + // Get eraName from map (created by CalendarSink) + (*field)[eraCode].fastCopyFrom(*eraName); + } else if (ctewbPtr != nullptr) { + // Try filling in missing items from parent locale(s) + resStatus = U_ZERO_ERROR; + LocalUResourceBundlePointer ctewkb(ures_getByKeyWithFallback(ctewbPtr, eraCodeStr, nullptr, &resStatus)); + if (U_SUCCESS(resStatus)) { + int32_t eraNameLen; + const UChar* eraNamePtr = ures_getString(ctewkb.getAlias(), &eraNameLen, &resStatus); + if (U_SUCCESS(resStatus)) { + (*field)[eraCode].setTo(false, eraNamePtr, eraNameLen); + } + } + } + } + return; + } + status = U_MISSING_RESOURCE_ERROR; + } +} + +static void +initLeapMonthPattern(UnicodeString *field, int32_t index, CalendarDataSink &sink, CharString &path, UErrorCode &status) { + field[index].remove(); + if (U_SUCCESS(status)) { + UnicodeString pathUString(path.data(), -1, US_INV); + Hashtable *leapMonthTable = static_cast(sink.maps.get(pathUString)); + if (leapMonthTable != nullptr) { + UnicodeString leapLabel(false, kLeapTagUChar, UPRV_LENGTHOF(kLeapTagUChar)); + UnicodeString *leapMonthPattern = static_cast(leapMonthTable->get(leapLabel)); + if (leapMonthPattern != nullptr) { + field[index].fastCopyFrom(*leapMonthPattern); + } else { + field[index].setToBogus(); + } + return; + } + status = U_MISSING_RESOURCE_ERROR; + } +} + +static CharString +&buildResourcePath(CharString &path, const char* segment1, UErrorCode &errorCode) { + return path.clear().append(segment1, -1, errorCode); +} + +static CharString +&buildResourcePath(CharString &path, const char* segment1, const char* segment2, + UErrorCode &errorCode) { + return buildResourcePath(path, segment1, errorCode).append('/', errorCode) + .append(segment2, -1, errorCode); +} + +static CharString +&buildResourcePath(CharString &path, const char* segment1, const char* segment2, + const char* segment3, UErrorCode &errorCode) { + return buildResourcePath(path, segment1, segment2, errorCode).append('/', errorCode) + .append(segment3, -1, errorCode); +} + +static CharString +&buildResourcePath(CharString &path, const char* segment1, const char* segment2, + const char* segment3, const char* segment4, UErrorCode &errorCode) { + return buildResourcePath(path, segment1, segment2, segment3, errorCode).append('/', errorCode) + .append(segment4, -1, errorCode); +} + +typedef struct { + const char * usageTypeName; + DateFormatSymbols::ECapitalizationContextUsageType usageTypeEnumValue; +} ContextUsageTypeNameToEnumValue; + +static const ContextUsageTypeNameToEnumValue contextUsageTypeMap[] = { + // Entries must be sorted by usageTypeName; entry with nullptr name terminates list. + { "day-format-except-narrow", DateFormatSymbols::kCapContextUsageDayFormat }, + { "day-narrow", DateFormatSymbols::kCapContextUsageDayNarrow }, + { "day-standalone-except-narrow", DateFormatSymbols::kCapContextUsageDayStandalone }, + { "era-abbr", DateFormatSymbols::kCapContextUsageEraAbbrev }, + { "era-name", DateFormatSymbols::kCapContextUsageEraWide }, + { "era-narrow", DateFormatSymbols::kCapContextUsageEraNarrow }, + { "metazone-long", DateFormatSymbols::kCapContextUsageMetazoneLong }, + { "metazone-short", DateFormatSymbols::kCapContextUsageMetazoneShort }, + { "month-format-except-narrow", DateFormatSymbols::kCapContextUsageMonthFormat }, + { "month-narrow", DateFormatSymbols::kCapContextUsageMonthNarrow }, + { "month-standalone-except-narrow", DateFormatSymbols::kCapContextUsageMonthStandalone }, + { "zone-long", DateFormatSymbols::kCapContextUsageZoneLong }, + { "zone-short", DateFormatSymbols::kCapContextUsageZoneShort }, + { nullptr, static_cast(0) }, +}; + +// Resource keys to look up localized strings for day periods. +// The first one must be midnight and the second must be noon, so that their indices coincide +// with the am/pm field. Formatting and parsing code for day periods relies on this coincidence. +static const char *dayPeriodKeys[] = {"midnight", "noon", + "morning1", "afternoon1", "evening1", "night1", + "morning2", "afternoon2", "evening2", "night2"}; + +UnicodeString* loadDayPeriodStrings(CalendarDataSink &sink, CharString &path, + int32_t &stringCount, UErrorCode &status) { + if (U_FAILURE(status)) { return nullptr; } + + UnicodeString pathUString(path.data(), -1, US_INV); + Hashtable* map = static_cast(sink.maps.get(pathUString)); + + stringCount = UPRV_LENGTHOF(dayPeriodKeys); + UnicodeString *strings = new UnicodeString[stringCount]; + if (strings == nullptr) { + status = U_MEMORY_ALLOCATION_ERROR; + return nullptr; + } + + if (map != nullptr) { + for (int32_t i = 0; i < stringCount; ++i) { + UnicodeString dayPeriodKey(dayPeriodKeys[i], -1, US_INV); + UnicodeString *dayPeriod = static_cast(map->get(dayPeriodKey)); + if (dayPeriod != nullptr) { + strings[i].fastCopyFrom(*dayPeriod); + } else { + strings[i].setToBogus(); + } + } + } else { + for (int32_t i = 0; i < stringCount; i++) { + strings[i].setToBogus(); + } + } + return strings; +} + + +void +DateFormatSymbols::initializeData(const Locale& locale, const char *type, UErrorCode& status, UBool useLastResortData) +{ + int32_t len = 0; + /* In case something goes wrong, initialize all of the data to nullptr. */ + fEras = nullptr; + fErasCount = 0; + fEraNames = nullptr; + fEraNamesCount = 0; + fNarrowEras = nullptr; + fNarrowErasCount = 0; + fMonths = nullptr; + fMonthsCount=0; + fShortMonths = nullptr; + fShortMonthsCount=0; + fNarrowMonths = nullptr; + fNarrowMonthsCount=0; + fStandaloneMonths = nullptr; + fStandaloneMonthsCount=0; + fStandaloneShortMonths = nullptr; + fStandaloneShortMonthsCount=0; + fStandaloneNarrowMonths = nullptr; + fStandaloneNarrowMonthsCount=0; + fWeekdays = nullptr; + fWeekdaysCount=0; + fShortWeekdays = nullptr; + fShortWeekdaysCount=0; + fShorterWeekdays = nullptr; + fShorterWeekdaysCount=0; + fNarrowWeekdays = nullptr; + fNarrowWeekdaysCount=0; + fStandaloneWeekdays = nullptr; + fStandaloneWeekdaysCount=0; + fStandaloneShortWeekdays = nullptr; + fStandaloneShortWeekdaysCount=0; + fStandaloneShorterWeekdays = nullptr; + fStandaloneShorterWeekdaysCount=0; + fStandaloneNarrowWeekdays = nullptr; + fStandaloneNarrowWeekdaysCount=0; + fAmPms = nullptr; + fAmPmsCount=0; + fWideAmPms = nullptr; + fWideAmPmsCount=0; + fNarrowAmPms = nullptr; + fNarrowAmPmsCount=0; + fTimeSeparator.setToBogus(); + fQuarters = nullptr; + fQuartersCount = 0; + fShortQuarters = nullptr; + fShortQuartersCount = 0; + fNarrowQuarters = nullptr; + fNarrowQuartersCount = 0; + fStandaloneQuarters = nullptr; + fStandaloneQuartersCount = 0; + fStandaloneShortQuarters = nullptr; + fStandaloneShortQuartersCount = 0; + fStandaloneNarrowQuarters = nullptr; + fStandaloneNarrowQuartersCount = 0; + fLeapMonthPatterns = nullptr; + fLeapMonthPatternsCount = 0; + fShortYearNames = nullptr; + fShortYearNamesCount = 0; + fShortZodiacNames = nullptr; + fShortZodiacNamesCount = 0; + fZoneStringsRowCount = 0; + fZoneStringsColCount = 0; + fZoneStrings = nullptr; + fLocaleZoneStrings = nullptr; + fAbbreviatedDayPeriods = nullptr; + fAbbreviatedDayPeriodsCount = 0; + fWideDayPeriods = nullptr; + fWideDayPeriodsCount = 0; + fNarrowDayPeriods = nullptr; + fNarrowDayPeriodsCount = 0; + fStandaloneAbbreviatedDayPeriods = nullptr; + fStandaloneAbbreviatedDayPeriodsCount = 0; + fStandaloneWideDayPeriods = nullptr; + fStandaloneWideDayPeriodsCount = 0; + fStandaloneNarrowDayPeriods = nullptr; + fStandaloneNarrowDayPeriodsCount = 0; + uprv_memset(fCapitalization, 0, sizeof(fCapitalization)); + + // We need to preserve the requested locale for + // lazy ZoneStringFormat instantiation. ZoneStringFormat + // is region sensitive, thus, bundle locale bundle's locale + // is not sufficient. + fZSFLocale = locale; + + if (U_FAILURE(status)) return; + + // Create a CalendarDataSink to process this data and the resource bundles + CalendarDataSink calendarSink(status); + LocalUResourceBundlePointer rb(ures_open(nullptr, locale.getBaseName(), &status)); + LocalUResourceBundlePointer cb(ures_getByKey(rb.getAlias(), gCalendarTag, nullptr, &status)); + + if (U_FAILURE(status)) return; + + // Iterate over the resource bundle data following the fallbacks through different calendar types + UnicodeString calendarType((type != nullptr && *type != '\0')? type : gGregorianTag, -1, US_INV); + while (!calendarType.isBogus()) { + CharString calendarTypeBuffer; + calendarTypeBuffer.appendInvariantChars(calendarType, status); + if (U_FAILURE(status)) { return; } + const char *calendarTypeCArray = calendarTypeBuffer.data(); + + // Enumerate this calendar type. If the calendar is not found fallback to gregorian + UErrorCode oldStatus = status; + LocalUResourceBundlePointer ctb(ures_getByKeyWithFallback(cb.getAlias(), calendarTypeCArray, nullptr, &status)); + if (status == U_MISSING_RESOURCE_ERROR) { + if (uprv_strcmp(calendarTypeCArray, gGregorianTag) != 0) { + calendarType.setTo(false, kGregorianTagUChar, UPRV_LENGTHOF(kGregorianTagUChar)); + calendarSink.visitAllResources(); + status = oldStatus; + continue; + } + return; + } + + calendarSink.preEnumerate(calendarType); + ures_getAllItemsWithFallback(ctb.getAlias(), "", calendarSink, status); + if (U_FAILURE(status)) break; + + // Stop loading when gregorian was loaded + if (uprv_strcmp(calendarTypeCArray, gGregorianTag) == 0) { + break; + } + + // Get the next calendar type to process from the sink + calendarType = calendarSink.nextCalendarType; + + // Gregorian is always the last fallback + if (calendarType.isBogus()) { + calendarType.setTo(false, kGregorianTagUChar, UPRV_LENGTHOF(kGregorianTagUChar)); + calendarSink.visitAllResources(); + } + } + + // CharString object to build paths + CharString path; + + // Load Leap Month Patterns + UErrorCode tempStatus = status; + fLeapMonthPatterns = newUnicodeStringArray(kMonthPatternsCount); + if (fLeapMonthPatterns) { + initLeapMonthPattern(fLeapMonthPatterns, kLeapMonthPatternFormatWide, calendarSink, + buildResourcePath(path, gMonthPatternsTag, gNamesFormatTag, gNamesWideTag, tempStatus), tempStatus); + initLeapMonthPattern(fLeapMonthPatterns, kLeapMonthPatternFormatAbbrev, calendarSink, + buildResourcePath(path, gMonthPatternsTag, gNamesFormatTag, gNamesAbbrTag, tempStatus), tempStatus); + initLeapMonthPattern(fLeapMonthPatterns, kLeapMonthPatternFormatNarrow, calendarSink, + buildResourcePath(path, gMonthPatternsTag, gNamesFormatTag, gNamesNarrowTag, tempStatus), tempStatus); + initLeapMonthPattern(fLeapMonthPatterns, kLeapMonthPatternStandaloneWide, calendarSink, + buildResourcePath(path, gMonthPatternsTag, gNamesStandaloneTag, gNamesWideTag, tempStatus), tempStatus); + initLeapMonthPattern(fLeapMonthPatterns, kLeapMonthPatternStandaloneAbbrev, calendarSink, + buildResourcePath(path, gMonthPatternsTag, gNamesStandaloneTag, gNamesAbbrTag, tempStatus), tempStatus); + initLeapMonthPattern(fLeapMonthPatterns, kLeapMonthPatternStandaloneNarrow, calendarSink, + buildResourcePath(path, gMonthPatternsTag, gNamesStandaloneTag, gNamesNarrowTag, tempStatus), tempStatus); + initLeapMonthPattern(fLeapMonthPatterns, kLeapMonthPatternNumeric, calendarSink, + buildResourcePath(path, gMonthPatternsTag, gNamesNumericTag, gNamesAllTag, tempStatus), tempStatus); + if (U_SUCCESS(tempStatus)) { + // Hack to fix bad C inheritance for dangi monthPatterns (OK in J); this should be handled by aliases in root, but isn't. + // The ordering of the following statements is important. + if (fLeapMonthPatterns[kLeapMonthPatternFormatAbbrev].isEmpty()) { + fLeapMonthPatterns[kLeapMonthPatternFormatAbbrev].setTo(fLeapMonthPatterns[kLeapMonthPatternFormatWide]); + } + if (fLeapMonthPatterns[kLeapMonthPatternFormatNarrow].isEmpty()) { + fLeapMonthPatterns[kLeapMonthPatternFormatNarrow].setTo(fLeapMonthPatterns[kLeapMonthPatternStandaloneNarrow]); + } + if (fLeapMonthPatterns[kLeapMonthPatternStandaloneWide].isEmpty()) { + fLeapMonthPatterns[kLeapMonthPatternStandaloneWide].setTo(fLeapMonthPatterns[kLeapMonthPatternFormatWide]); + } + if (fLeapMonthPatterns[kLeapMonthPatternStandaloneAbbrev].isEmpty()) { + fLeapMonthPatterns[kLeapMonthPatternStandaloneAbbrev].setTo(fLeapMonthPatterns[kLeapMonthPatternFormatAbbrev]); + } + // end of hack + fLeapMonthPatternsCount = kMonthPatternsCount; + } else { + delete[] fLeapMonthPatterns; + fLeapMonthPatterns = nullptr; + } + } + + // Load cyclic names sets + tempStatus = status; + initField(&fShortYearNames, fShortYearNamesCount, calendarSink, + buildResourcePath(path, gCyclicNameSetsTag, gNameSetYearsTag, gNamesFormatTag, gNamesAbbrTag, tempStatus), tempStatus); + initField(&fShortZodiacNames, fShortZodiacNamesCount, calendarSink, + buildResourcePath(path, gCyclicNameSetsTag, gNameSetZodiacsTag, gNamesFormatTag, gNamesAbbrTag, tempStatus), tempStatus); + + // Load context transforms and capitalization + tempStatus = U_ZERO_ERROR; + LocalUResourceBundlePointer localeBundle(ures_open(nullptr, locale.getName(), &tempStatus)); + if (U_SUCCESS(tempStatus)) { + LocalUResourceBundlePointer contextTransforms(ures_getByKeyWithFallback(localeBundle.getAlias(), gContextTransformsTag, nullptr, &tempStatus)); + if (U_SUCCESS(tempStatus)) { + for (LocalUResourceBundlePointer contextTransformUsage; + contextTransformUsage.adoptInstead(ures_getNextResource(contextTransforms.getAlias(), nullptr, &tempStatus)), + contextTransformUsage.isValid();) { + const int32_t * intVector = ures_getIntVector(contextTransformUsage.getAlias(), &len, &status); + if (U_SUCCESS(tempStatus) && intVector != nullptr && len >= 2) { + const char* usageType = ures_getKey(contextTransformUsage.getAlias()); + if (usageType != nullptr) { + const ContextUsageTypeNameToEnumValue * typeMapPtr = contextUsageTypeMap; + int32_t compResult = 0; + // linear search; list is short and we cannot be sure that bsearch is available + while ( typeMapPtr->usageTypeName != nullptr && (compResult = uprv_strcmp(usageType, typeMapPtr->usageTypeName)) > 0 ) { + ++typeMapPtr; + } + if (typeMapPtr->usageTypeName != nullptr && compResult == 0) { + fCapitalization[typeMapPtr->usageTypeEnumValue][0] = static_cast(intVector[0]); + fCapitalization[typeMapPtr->usageTypeEnumValue][1] = static_cast(intVector[1]); + } + } + } + tempStatus = U_ZERO_ERROR; + } + } + + tempStatus = U_ZERO_ERROR; + const LocalPointer numberingSystem( + NumberingSystem::createInstance(locale, tempStatus), tempStatus); + if (U_SUCCESS(tempStatus)) { + // These functions all fail gracefully if passed nullptr pointers and + // do nothing unless U_SUCCESS(tempStatus), so it's only necessary + // to check for errors once after all calls are made. + const LocalUResourceBundlePointer numberElementsData(ures_getByKeyWithFallback( + localeBundle.getAlias(), gNumberElementsTag, nullptr, &tempStatus)); + const LocalUResourceBundlePointer nsNameData(ures_getByKeyWithFallback( + numberElementsData.getAlias(), numberingSystem->getName(), nullptr, &tempStatus)); + const LocalUResourceBundlePointer symbolsData(ures_getByKeyWithFallback( + nsNameData.getAlias(), gSymbolsTag, nullptr, &tempStatus)); + fTimeSeparator = ures_getUnicodeStringByKey( + symbolsData.getAlias(), gTimeSeparatorTag, &tempStatus); + if (U_FAILURE(tempStatus)) { + fTimeSeparator.setToBogus(); + } + } + + } + + if (fTimeSeparator.isBogus()) { + fTimeSeparator.setTo(DateFormatSymbols::DEFAULT_TIME_SEPARATOR); + } + + // Load day periods + fAbbreviatedDayPeriods = loadDayPeriodStrings(calendarSink, + buildResourcePath(path, gDayPeriodTag, gNamesFormatTag, gNamesAbbrTag, status), + fAbbreviatedDayPeriodsCount, status); + + fWideDayPeriods = loadDayPeriodStrings(calendarSink, + buildResourcePath(path, gDayPeriodTag, gNamesFormatTag, gNamesWideTag, status), + fWideDayPeriodsCount, status); + fNarrowDayPeriods = loadDayPeriodStrings(calendarSink, + buildResourcePath(path, gDayPeriodTag, gNamesFormatTag, gNamesNarrowTag, status), + fNarrowDayPeriodsCount, status); + + fStandaloneAbbreviatedDayPeriods = loadDayPeriodStrings(calendarSink, + buildResourcePath(path, gDayPeriodTag, gNamesStandaloneTag, gNamesAbbrTag, status), + fStandaloneAbbreviatedDayPeriodsCount, status); + + fStandaloneWideDayPeriods = loadDayPeriodStrings(calendarSink, + buildResourcePath(path, gDayPeriodTag, gNamesStandaloneTag, gNamesWideTag, status), + fStandaloneWideDayPeriodsCount, status); + fStandaloneNarrowDayPeriods = loadDayPeriodStrings(calendarSink, + buildResourcePath(path, gDayPeriodTag, gNamesStandaloneTag, gNamesNarrowTag, status), + fStandaloneNarrowDayPeriodsCount, status); + + // Fill in for missing/bogus items (dayPeriods are a map so single items might be missing) + if (U_SUCCESS(status)) { + for (int32_t dpidx = 0; dpidx < fAbbreviatedDayPeriodsCount; ++dpidx) { + if (dpidx < fWideDayPeriodsCount && fWideDayPeriods != nullptr && fWideDayPeriods[dpidx].isBogus()) { + fWideDayPeriods[dpidx].fastCopyFrom(fAbbreviatedDayPeriods[dpidx]); + } + if (dpidx < fNarrowDayPeriodsCount && fNarrowDayPeriods != nullptr && fNarrowDayPeriods[dpidx].isBogus()) { + fNarrowDayPeriods[dpidx].fastCopyFrom(fAbbreviatedDayPeriods[dpidx]); + } + if (dpidx < fStandaloneAbbreviatedDayPeriodsCount && fStandaloneAbbreviatedDayPeriods != nullptr && fStandaloneAbbreviatedDayPeriods[dpidx].isBogus()) { + fStandaloneAbbreviatedDayPeriods[dpidx].fastCopyFrom(fAbbreviatedDayPeriods[dpidx]); + } + if (dpidx < fStandaloneWideDayPeriodsCount && fStandaloneWideDayPeriods != nullptr && fStandaloneWideDayPeriods[dpidx].isBogus()) { + fStandaloneWideDayPeriods[dpidx].fastCopyFrom(fStandaloneAbbreviatedDayPeriods[dpidx]); + } + if (dpidx < fStandaloneNarrowDayPeriodsCount && fStandaloneNarrowDayPeriods != nullptr && fStandaloneNarrowDayPeriods[dpidx].isBogus()) { + fStandaloneNarrowDayPeriods[dpidx].fastCopyFrom(fStandaloneAbbreviatedDayPeriods[dpidx]); + } + } + } + + // if we make it to here, the resource data is cool, and we can get everything out + // of it that we need except for the time-zone and localized-pattern data, which + // are stored in a separate file + validLocale = Locale(ures_getLocaleByType(cb.getAlias(), ULOC_VALID_LOCALE, &status)); + actualLocale = Locale(ures_getLocaleByType(cb.getAlias(), ULOC_ACTUAL_LOCALE, &status)); + + // Era setup + if (type == nullptr) { + type = "gregorian"; + } + LocalPointer eraRules(EraRules::createInstance(type, false, status)); + int32_t maxEra = (U_SUCCESS(status))? eraRules->getMaxEraCode(): 0; + UErrorCode resStatus = U_ZERO_ERROR; + LocalUResourceBundlePointer ctpb(ures_getByKeyWithFallback(cb.getAlias(), type, nullptr, &resStatus)); + LocalUResourceBundlePointer cteb(ures_getByKeyWithFallback(ctpb.getAlias(), gErasTag, nullptr, &resStatus)); + const UResourceBundle *ctebPtr = (U_SUCCESS(resStatus))? cteb.getAlias() : nullptr; + // Load eras + initEras(&fEras, fErasCount, calendarSink, buildResourcePath(path, gErasTag, gNamesAbbrTag, status), + ctebPtr, gNamesAbbrTag, maxEra, status); + UErrorCode oldStatus = status; + initEras(&fEraNames, fEraNamesCount, calendarSink, buildResourcePath(path, gErasTag, gNamesWideTag, status), + ctebPtr, gNamesWideTag, maxEra, status); + if (status == U_MISSING_RESOURCE_ERROR) { // Workaround because eras/wide was omitted from CLDR 1.3 + status = U_ZERO_ERROR; + assignArray(fEraNames, fEraNamesCount, fEras, fErasCount); + } + // current ICU4J falls back to abbreviated if narrow eras are missing, so we will too + oldStatus = status; + initEras(&fNarrowEras, fNarrowErasCount, calendarSink, buildResourcePath(path, gErasTag, gNamesNarrowTag, status), + ctebPtr, gNamesNarrowTag, maxEra, status); + if (status == U_MISSING_RESOURCE_ERROR) { // Workaround because eras/wide was omitted from CLDR 1.3 + status = U_ZERO_ERROR; + assignArray(fNarrowEras, fNarrowErasCount, fEras, fErasCount); + } + + // Load month names + initField(&fMonths, fMonthsCount, calendarSink, + buildResourcePath(path, gMonthNamesTag, gNamesFormatTag, gNamesWideTag, status), status); + initField(&fShortMonths, fShortMonthsCount, calendarSink, + buildResourcePath(path, gMonthNamesTag, gNamesFormatTag, gNamesAbbrTag, status), status); + initField(&fStandaloneMonths, fStandaloneMonthsCount, calendarSink, + buildResourcePath(path, gMonthNamesTag, gNamesStandaloneTag, gNamesWideTag, status), status); + if (status == U_MISSING_RESOURCE_ERROR) { /* If standalone/wide not available, use format/wide */ + status = U_ZERO_ERROR; + assignArray(fStandaloneMonths, fStandaloneMonthsCount, fMonths, fMonthsCount); + } + initField(&fStandaloneShortMonths, fStandaloneShortMonthsCount, calendarSink, + buildResourcePath(path, gMonthNamesTag, gNamesStandaloneTag, gNamesAbbrTag, status), status); + if (status == U_MISSING_RESOURCE_ERROR) { /* If standalone/abbreviated not available, use format/abbreviated */ + status = U_ZERO_ERROR; + assignArray(fStandaloneShortMonths, fStandaloneShortMonthsCount, fShortMonths, fShortMonthsCount); + } + + UErrorCode narrowMonthsEC = status; + UErrorCode standaloneNarrowMonthsEC = status; + initField(&fNarrowMonths, fNarrowMonthsCount, calendarSink, + buildResourcePath(path, gMonthNamesTag, gNamesFormatTag, gNamesNarrowTag, narrowMonthsEC), narrowMonthsEC); + initField(&fStandaloneNarrowMonths, fStandaloneNarrowMonthsCount, calendarSink, + buildResourcePath(path, gMonthNamesTag, gNamesStandaloneTag, gNamesNarrowTag, narrowMonthsEC), standaloneNarrowMonthsEC); + if (narrowMonthsEC == U_MISSING_RESOURCE_ERROR && standaloneNarrowMonthsEC != U_MISSING_RESOURCE_ERROR) { + // If format/narrow not available, use standalone/narrow + assignArray(fNarrowMonths, fNarrowMonthsCount, fStandaloneNarrowMonths, fStandaloneNarrowMonthsCount); + } else if (narrowMonthsEC != U_MISSING_RESOURCE_ERROR && standaloneNarrowMonthsEC == U_MISSING_RESOURCE_ERROR) { + // If standalone/narrow not available, use format/narrow + assignArray(fStandaloneNarrowMonths, fStandaloneNarrowMonthsCount, fNarrowMonths, fNarrowMonthsCount); + } else if (narrowMonthsEC == U_MISSING_RESOURCE_ERROR && standaloneNarrowMonthsEC == U_MISSING_RESOURCE_ERROR) { + // If neither is available, use format/abbreviated + assignArray(fNarrowMonths, fNarrowMonthsCount, fShortMonths, fShortMonthsCount); + assignArray(fStandaloneNarrowMonths, fStandaloneNarrowMonthsCount, fShortMonths, fShortMonthsCount); + } + + // Load AM/PM markers. + ErrorCode ampmStatus; + initField(&fAmPms, fAmPmsCount, calendarSink, + buildResourcePath(path, gAmPmMarkersAbbrTag, ampmStatus), ampmStatus); + if (ampmStatus.isFailure()) { + // No-op: fall back to last-resort names, which are pre-populated + } + ampmStatus.reset(); + initField(&fNarrowAmPms, fNarrowAmPmsCount, calendarSink, + buildResourcePath(path, gAmPmMarkersNarrowTag, ampmStatus), ampmStatus); + if (ampmStatus.isFailure()) { + // Narrow falls back to Abbreviated + assignArray(fNarrowAmPms, fNarrowAmPmsCount, fAmPms, fAmPmsCount); + } + ampmStatus.reset(); + initField(&fWideAmPms, fWideAmPmsCount, calendarSink, + buildResourcePath(path, gAmPmMarkersTag, ampmStatus), ampmStatus); + if (ampmStatus.isFailure()) { + // Wide falls back to Abbreviated + assignArray(fWideAmPms, fWideAmPmsCount, fAmPms, fAmPmsCount); + } + ampmStatus.reset(); + + // Load quarters + initField(&fQuarters, fQuartersCount, calendarSink, + buildResourcePath(path, gQuartersTag, gNamesFormatTag, gNamesWideTag, status), status); + initField(&fShortQuarters, fShortQuartersCount, calendarSink, + buildResourcePath(path, gQuartersTag, gNamesFormatTag, gNamesAbbrTag, status), status); + if(status == U_MISSING_RESOURCE_ERROR) { + status = U_ZERO_ERROR; + assignArray(fShortQuarters, fShortQuartersCount, fQuarters, fQuartersCount); + } + + initField(&fStandaloneQuarters, fStandaloneQuartersCount, calendarSink, + buildResourcePath(path, gQuartersTag, gNamesStandaloneTag, gNamesWideTag, status), status); + if(status == U_MISSING_RESOURCE_ERROR) { + status = U_ZERO_ERROR; + assignArray(fStandaloneQuarters, fStandaloneQuartersCount, fQuarters, fQuartersCount); + } + initField(&fStandaloneShortQuarters, fStandaloneShortQuartersCount, calendarSink, + buildResourcePath(path, gQuartersTag, gNamesStandaloneTag, gNamesAbbrTag, status), status); + if(status == U_MISSING_RESOURCE_ERROR) { + status = U_ZERO_ERROR; + assignArray(fStandaloneShortQuarters, fStandaloneShortQuartersCount, fShortQuarters, fShortQuartersCount); + } + + // unlike the fields above, narrow format quarters fall back on narrow standalone quarters + initField(&fStandaloneNarrowQuarters, fStandaloneNarrowQuartersCount, calendarSink, + buildResourcePath(path, gQuartersTag, gNamesStandaloneTag, gNamesNarrowTag, status), status); + initField(&fNarrowQuarters, fNarrowQuartersCount, calendarSink, + buildResourcePath(path, gQuartersTag, gNamesFormatTag, gNamesNarrowTag, status), status); + if(status == U_MISSING_RESOURCE_ERROR) { + status = U_ZERO_ERROR; + assignArray(fNarrowQuarters, fNarrowQuartersCount, fStandaloneNarrowQuarters, fStandaloneNarrowQuartersCount); + } + + // ICU 3.8 or later version no longer uses localized date-time pattern characters by default (ticket#5597) + /* + // fastCopyFrom()/setTo() - see assignArray comments + resStr = ures_getStringByKey(fResourceBundle, gLocalPatternCharsTag, &len, &status); + fLocalPatternChars.setTo(true, resStr, len); + // If the locale data does not include new pattern chars, use the defaults + // TODO: Consider making this an error, since this may add conflicting characters. + if (len < PATTERN_CHARS_LEN) { + fLocalPatternChars.append(UnicodeString(true, &gPatternChars[len], PATTERN_CHARS_LEN-len)); + } + */ + fLocalPatternChars.setTo(true, gPatternChars, PATTERN_CHARS_LEN); + + // Format wide weekdays -> fWeekdays + // {sfb} fixed to handle 1-based weekdays + initField(&fWeekdays, fWeekdaysCount, calendarSink, + buildResourcePath(path, gDayNamesTag, gNamesFormatTag, gNamesWideTag, status), 1, status); + + // Format abbreviated weekdays -> fShortWeekdays + initField(&fShortWeekdays, fShortWeekdaysCount, calendarSink, + buildResourcePath(path, gDayNamesTag, gNamesFormatTag, gNamesAbbrTag, status), 1, status); + + // Format short weekdays -> fShorterWeekdays (fall back to abbreviated) + initField(&fShorterWeekdays, fShorterWeekdaysCount, calendarSink, + buildResourcePath(path, gDayNamesTag, gNamesFormatTag, gNamesShortTag, status), 1, status); + if (status == U_MISSING_RESOURCE_ERROR) { + status = U_ZERO_ERROR; + assignArray(fShorterWeekdays, fShorterWeekdaysCount, fShortWeekdays, fShortWeekdaysCount); + } + + // Stand-alone wide weekdays -> fStandaloneWeekdays + initField(&fStandaloneWeekdays, fStandaloneWeekdaysCount, calendarSink, + buildResourcePath(path, gDayNamesTag, gNamesStandaloneTag, gNamesWideTag, status), 1, status); + if (status == U_MISSING_RESOURCE_ERROR) { /* If standalone/wide is not available, use format/wide */ + status = U_ZERO_ERROR; + assignArray(fStandaloneWeekdays, fStandaloneWeekdaysCount, fWeekdays, fWeekdaysCount); + } + + // Stand-alone abbreviated weekdays -> fStandaloneShortWeekdays + initField(&fStandaloneShortWeekdays, fStandaloneShortWeekdaysCount, calendarSink, + buildResourcePath(path, gDayNamesTag, gNamesStandaloneTag, gNamesAbbrTag, status), 1, status); + if (status == U_MISSING_RESOURCE_ERROR) { /* If standalone/abbreviated is not available, use format/abbreviated */ + status = U_ZERO_ERROR; + assignArray(fStandaloneShortWeekdays, fStandaloneShortWeekdaysCount, fShortWeekdays, fShortWeekdaysCount); + } + + // Stand-alone short weekdays -> fStandaloneShorterWeekdays (fall back to format abbreviated) + initField(&fStandaloneShorterWeekdays, fStandaloneShorterWeekdaysCount, calendarSink, + buildResourcePath(path, gDayNamesTag, gNamesStandaloneTag, gNamesShortTag, status), 1, status); + if (status == U_MISSING_RESOURCE_ERROR) { /* If standalone/short is not available, use format/short */ + status = U_ZERO_ERROR; + assignArray(fStandaloneShorterWeekdays, fStandaloneShorterWeekdaysCount, fShorterWeekdays, fShorterWeekdaysCount); + } + + // Format narrow weekdays -> fNarrowWeekdays + UErrorCode narrowWeeksEC = status; + initField(&fNarrowWeekdays, fNarrowWeekdaysCount, calendarSink, + buildResourcePath(path, gDayNamesTag, gNamesFormatTag, gNamesNarrowTag, status), 1, narrowWeeksEC); + // Stand-alone narrow weekdays -> fStandaloneNarrowWeekdays + UErrorCode standaloneNarrowWeeksEC = status; + initField(&fStandaloneNarrowWeekdays, fStandaloneNarrowWeekdaysCount, calendarSink, + buildResourcePath(path, gDayNamesTag, gNamesStandaloneTag, gNamesNarrowTag, status), 1, standaloneNarrowWeeksEC); + + if (narrowWeeksEC == U_MISSING_RESOURCE_ERROR && standaloneNarrowWeeksEC != U_MISSING_RESOURCE_ERROR) { + // If format/narrow not available, use standalone/narrow + assignArray(fNarrowWeekdays, fNarrowWeekdaysCount, fStandaloneNarrowWeekdays, fStandaloneNarrowWeekdaysCount); + } else if (narrowWeeksEC != U_MISSING_RESOURCE_ERROR && standaloneNarrowWeeksEC == U_MISSING_RESOURCE_ERROR) { + // If standalone/narrow not available, use format/narrow + assignArray(fStandaloneNarrowWeekdays, fStandaloneNarrowWeekdaysCount, fNarrowWeekdays, fNarrowWeekdaysCount); + } else if (narrowWeeksEC == U_MISSING_RESOURCE_ERROR && standaloneNarrowWeeksEC == U_MISSING_RESOURCE_ERROR ) { + // If neither is available, use format/abbreviated + assignArray(fNarrowWeekdays, fNarrowWeekdaysCount, fShortWeekdays, fShortWeekdaysCount); + assignArray(fStandaloneNarrowWeekdays, fStandaloneNarrowWeekdaysCount, fShortWeekdays, fShortWeekdaysCount); + } + + // Last resort fallback in case previous data wasn't loaded + if (U_FAILURE(status)) + { + if (useLastResortData) + { + // Handle the case in which there is no resource data present. + // We don't have to generate usable patterns in this situation; + // we just need to produce something that will be semi-intelligible + // in most locales. + + status = U_USING_FALLBACK_WARNING; + //TODO(fabalbon): make sure we are storing las resort data for all fields in here. + initField(&fEras, fErasCount, reinterpret_cast(gLastResortEras), kEraNum, kEraLen, status); + initField(&fEraNames, fEraNamesCount, reinterpret_cast(gLastResortEras), kEraNum, kEraLen, status); + initField(&fNarrowEras, fNarrowErasCount, reinterpret_cast(gLastResortEras), kEraNum, kEraLen, status); + initField(&fMonths, fMonthsCount, reinterpret_cast(gLastResortMonthNames), kMonthNum, kMonthLen, status); + initField(&fShortMonths, fShortMonthsCount, reinterpret_cast(gLastResortMonthNames), kMonthNum, kMonthLen, status); + initField(&fNarrowMonths, fNarrowMonthsCount, reinterpret_cast(gLastResortMonthNames), kMonthNum, kMonthLen, status); + initField(&fStandaloneMonths, fStandaloneMonthsCount, reinterpret_cast(gLastResortMonthNames), kMonthNum, kMonthLen, status); + initField(&fStandaloneShortMonths, fStandaloneShortMonthsCount, reinterpret_cast(gLastResortMonthNames), kMonthNum, kMonthLen, status); + initField(&fStandaloneNarrowMonths, fStandaloneNarrowMonthsCount, reinterpret_cast(gLastResortMonthNames), kMonthNum, kMonthLen, status); + initField(&fWeekdays, fWeekdaysCount, reinterpret_cast(gLastResortDayNames), kDayNum, kDayLen, status); + initField(&fShortWeekdays, fShortWeekdaysCount, reinterpret_cast(gLastResortDayNames), kDayNum, kDayLen, status); + initField(&fShorterWeekdays, fShorterWeekdaysCount, reinterpret_cast(gLastResortDayNames), kDayNum, kDayLen, status); + initField(&fNarrowWeekdays, fNarrowWeekdaysCount, reinterpret_cast(gLastResortDayNames), kDayNum, kDayLen, status); + initField(&fStandaloneWeekdays, fStandaloneWeekdaysCount, reinterpret_cast(gLastResortDayNames), kDayNum, kDayLen, status); + initField(&fStandaloneShortWeekdays, fStandaloneShortWeekdaysCount, reinterpret_cast(gLastResortDayNames), kDayNum, kDayLen, status); + initField(&fStandaloneShorterWeekdays, fStandaloneShorterWeekdaysCount, reinterpret_cast(gLastResortDayNames), kDayNum, kDayLen, status); + initField(&fStandaloneNarrowWeekdays, fStandaloneNarrowWeekdaysCount, reinterpret_cast(gLastResortDayNames), kDayNum, kDayLen, status); + initField(&fAmPms, fAmPmsCount, reinterpret_cast(gLastResortAmPmMarkers), kAmPmNum, kAmPmLen, status); + initField(&fNarrowAmPms, fNarrowAmPmsCount, reinterpret_cast(gLastResortAmPmMarkers), kAmPmNum, kAmPmLen, status); + initField(&fQuarters, fQuartersCount, reinterpret_cast(gLastResortQuarters), kQuarterNum, kQuarterLen, status); + initField(&fShortQuarters, fShortQuartersCount, reinterpret_cast(gLastResortQuarters), kQuarterNum, kQuarterLen, status); + initField(&fNarrowQuarters, fNarrowQuartersCount, reinterpret_cast(gLastResortQuarters), kQuarterNum, kQuarterLen, status); + initField(&fStandaloneQuarters, fStandaloneQuartersCount, reinterpret_cast(gLastResortQuarters), kQuarterNum, kQuarterLen, status); + initField(&fStandaloneShortQuarters, fStandaloneShortQuartersCount, reinterpret_cast(gLastResortQuarters), kQuarterNum, kQuarterLen, status); + initField(&fStandaloneNarrowQuarters, fStandaloneNarrowQuartersCount, reinterpret_cast(gLastResortQuarters), kQuarterNum, kQuarterLen, status); + fLocalPatternChars.setTo(true, gPatternChars, PATTERN_CHARS_LEN); + } + } +} + +Locale +DateFormatSymbols::getLocale(ULocDataLocaleType type, UErrorCode& status) const { + return LocaleBased::getLocale(validLocale, actualLocale, type, status); +} + +U_NAMESPACE_END + +#endif /* #if !UCONFIG_NO_FORMATTING */ + +//eof From d7805e00dc7043f439e943ca5132244751fa3ba9 Mon Sep 17 00:00:00 2001 From: Paul Bouchon Date: Wed, 12 Aug 2026 17:47:11 -0400 Subject: [PATCH 140/344] module: fix --check on ambiguous ESM files A `.js` file with no `"type"` in the nearest package.json has no format of its own, and `defaultGetFormat()` reports it as null. `--check` passed that null straight to `wrapSafe()`, which parses as CommonJS. Module syntax makes that parse bail out early, so the file was reported as valid and `--check` exited 0 even though it is not valid JavaScript under either goal. At load time the goal for such a file is decided by looking for module syntax in the source. Decide it the same way here, so the file is parsed as a module and its real syntax error is reported. Files whose format is known are unaffected, as are ambiguous files without module syntax, which are still parsed as CommonJS. Fixes: https://github.com/nodejs/node/issues/65202 Signed-off-by: Paul Bouchon PR-URL: https://github.com/nodejs/node/pull/65203 Reviewed-By: Antoine du Hamel Reviewed-By: Aviv Keller --- lib/internal/main/check_syntax.js | 12 ++++++++++++ test/fixtures/syntax/bad_syntax_esm_ambiguous.js | 2 ++ test/sequential/test-cli-syntax-bad.js | 3 +++ 3 files changed, 17 insertions(+) create mode 100644 test/fixtures/syntax/bad_syntax_esm_ambiguous.js diff --git a/lib/internal/main/check_syntax.js b/lib/internal/main/check_syntax.js index 16e367c4e089..7bc7bbc1ede4 100644 --- a/lib/internal/main/check_syntax.js +++ b/lib/internal/main/check_syntax.js @@ -67,6 +67,18 @@ async function checkSyntax(source, filename) { format = await defaultGetFormat(new URL(url)); } + // A `.js` file with no `"type"` in the nearest package.json has no format of + // its own. At load time the goal is decided by looking for module syntax in + // the source, so decide it the same way here. Otherwise such a file is only + // ever parsed as CommonJS, where module syntax makes the parse bail out + // before any syntax error in the rest of the file is reported. + if (format === null || format === undefined) { + const { containsModuleSyntax } = internalBinding('contextify'); + if (containsModuleSyntax(source, filename)) { + format = 'module'; + } + } + if (format === 'module') { const { ModuleWrap } = internalBinding('module_wrap'); new ModuleWrap(filename, undefined, source, 0, 0); diff --git a/test/fixtures/syntax/bad_syntax_esm_ambiguous.js b/test/fixtures/syntax/bad_syntax_esm_ambiguous.js new file mode 100644 index 000000000000..511f42994f90 --- /dev/null +++ b/test/fixtures/syntax/bad_syntax_esm_ambiguous.js @@ -0,0 +1,2 @@ +import fs from 'node:fs'; +var = ; diff --git a/test/sequential/test-cli-syntax-bad.js b/test/sequential/test-cli-syntax-bad.js index e967ff36ac28..0cf9d020b30f 100644 --- a/test/sequential/test-cli-syntax-bad.js +++ b/test/sequential/test-cli-syntax-bad.js @@ -21,6 +21,9 @@ const syntaxErrorRE = /^SyntaxError: \b/m; 'syntax/bad_syntax', 'syntax/bad_syntax_shebang.js', 'syntax/bad_syntax_shebang', + // A `.js` file with no `"type"` in the nearest package.json, whose module + // syntax makes it load as ESM. Refs: https://github.com/nodejs/node/issues/65202 + 'syntax/bad_syntax_esm_ambiguous.js', ].forEach((file) => { const path = fixtures.path(file); From 374f7e214d523c2bea3693b8e04657ad3fa7c603 Mon Sep 17 00:00:00 2001 From: greenhead Date: Thu, 13 Aug 2026 16:29:17 +0900 Subject: [PATCH 141/344] stream: use validateBuffer for BYOB reader view The same check is already spelled validateBuffer(view, 'view') in ReadableStreamBYOBRequest.respondWithNewView(), and the thrown error is unchanged. Signed-off-by: greenhead PR-URL: https://github.com/nodejs/node/pull/65046 Reviewed-By: Daeyeon Jeong Reviewed-By: James M Snell --- lib/internal/webstreams/readablestream.js | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/lib/internal/webstreams/readablestream.js b/lib/internal/webstreams/readablestream.js index e1e80eb953c0..b05cb7c257eb 100644 --- a/lib/internal/webstreams/readablestream.js +++ b/lib/internal/webstreams/readablestream.js @@ -1060,17 +1060,7 @@ class ReadableStreamBYOBReader { async read(view, options = kEmptyObject) { if (!isReadableStreamBYOBReader(this)) throw new ERR_INVALID_THIS('ReadableStreamBYOBReader'); - if (!isArrayBufferView(view)) { - throw new ERR_INVALID_ARG_TYPE( - 'view', - [ - 'Buffer', - 'TypedArray', - 'DataView', - ], - view, - ); - } + validateBuffer(view, 'view'); validateObject(options, 'options', kValidateObjectAllowObjectsAndNull); const viewByteLength = ArrayBufferViewGetByteLength(view); From 6a142c03e90e051a85c93d40556449af84537e08 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Fri, 7 Aug 2026 20:14:51 +0200 Subject: [PATCH 142/344] crypto: read WebCrypto inputs through primordials BufferSource conversion hands over the caller's own object uncopied, so byteLength, byteOffset, buffer and length reads on it run user-replaceable prototype accessors. Internal lookup tables are indexed with computed keys, so a polluted %Object.prototype% key answers a miss. The %Set% constructor iterates its argument through the user-mutable %Array.prototype% iterator. The algorithm registry and the hash name tables are detached from %Object.prototype% after construction rather than declared `__proto__: null`, which V8 places in dictionary mode. Signed-off-by: Filip Skokan PR-URL: https://github.com/nodejs/node/pull/65115 Reviewed-By: Rafael Gonzaga Reviewed-By: Aviv Keller --- lib/internal/crypto/aes.js | 3 +- lib/internal/crypto/cfrg.js | 4 +- lib/internal/crypto/diffiehellman.js | 3 +- lib/internal/crypto/ec.js | 7 +- lib/internal/crypto/hash.js | 5 +- lib/internal/crypto/hashnames.js | 14 +- lib/internal/crypto/keys.js | 4 +- lib/internal/crypto/ml_dsa.js | 7 +- lib/internal/crypto/ml_kem.js | 7 +- lib/internal/crypto/rsa.js | 6 +- lib/internal/crypto/util.js | 90 +++- lib/internal/crypto/webcrypto.js | 13 +- lib/internal/crypto/webcrypto_util.js | 13 +- lib/internal/crypto/webidl.js | 42 +- .../test-webcrypto-prototype-pollution.mjs | 469 ++++++++++++++++++ 15 files changed, 618 insertions(+), 69 deletions(-) create mode 100644 test/parallel/test-webcrypto-prototype-pollution.mjs diff --git a/lib/internal/crypto/aes.js b/lib/internal/crypto/aes.js index 7ce1dabbf7c5..3280ddff8459 100644 --- a/lib/internal/crypto/aes.js +++ b/lib/internal/crypto/aes.js @@ -24,6 +24,7 @@ const { const { getUsagesMask, jobPromise, + getBufferSourceByteLength, } = require('internal/crypto/util'); const { @@ -218,7 +219,7 @@ function aesImportKey( if (format === 'raw' && name === 'AES-OCB') { return undefined; } - length = keyData.byteLength * 8; + length = getBufferSourceByteLength(keyData) * 8; validateKeyLength(length); handle = importSecretKey(keyData); break; diff --git a/lib/internal/crypto/cfrg.js b/lib/internal/crypto/cfrg.js index 994b8f510c49..9eea26aa36a0 100644 --- a/lib/internal/crypto/cfrg.js +++ b/lib/internal/crypto/cfrg.js @@ -1,7 +1,6 @@ 'use strict'; const { - SafeSet, StringPrototypeToLowerCase, TypedArrayPrototypeGetBuffer, } = primordials; @@ -27,6 +26,7 @@ const { const { getUsagesMask, jobPromise, + toUsagesSet, } = require('internal/crypto/util'); const { @@ -124,7 +124,7 @@ function cfrgImportKey( const { name } = algorithm; let handle; const allowedUsages = kUsages[name]; - const usagesSet = new SafeSet(usages); + const usagesSet = toUsagesSet(usages); switch (format) { case 'KeyObjectHandle': verifyAcceptableKeyUse( diff --git a/lib/internal/crypto/diffiehellman.js b/lib/internal/crypto/diffiehellman.js index 367ca9df1f13..c41b192ca8c8 100644 --- a/lib/internal/crypto/diffiehellman.js +++ b/lib/internal/crypto/diffiehellman.js @@ -1,6 +1,7 @@ 'use strict'; const { + ArrayBufferPrototypeGetByteLength, ArrayBufferPrototypeSlice, FunctionPrototypeCall, ObjectDefineProperty, @@ -414,7 +415,7 @@ function ecdhDeriveBits(algorithm, baseKey, length) { return jobPromiseThen(bits, (bits) => { const sliceLength = numBitsToBytes(length); - const { byteLength } = bits; + const byteLength = ArrayBufferPrototypeGetByteLength(bits); // If the length is larger than the derived secret, throw. if (byteLength < sliceLength) throw lazyDOMException('derived bit length is too small', 'OperationError'); diff --git a/lib/internal/crypto/ec.js b/lib/internal/crypto/ec.js index fc19e7cce6a5..cbd89dd11c7e 100644 --- a/lib/internal/crypto/ec.js +++ b/lib/internal/crypto/ec.js @@ -1,7 +1,6 @@ 'use strict'; const { - SafeSet, TypedArrayPrototypeGetBuffer, TypedArrayPrototypeGetByteLength, } = primordials; @@ -33,6 +32,7 @@ const { jobPromise, normalizeHashName, kNamedCurveAliases, + toUsagesSet, } = require('internal/crypto/util'); const { @@ -142,7 +142,7 @@ function ecImportKey( let handle; const allowedUsages = kUsages[name]; - const usagesSet = new SafeSet(usages); + const usagesSet = toUsagesSet(usages); switch (format) { case 'KeyObjectHandle': verifyAcceptableKeyUse( @@ -215,7 +215,8 @@ function ecImportKey( throw lazyDOMException('Invalid keyData', 'DataError'); } - if (kNamedCurveAliases[namedCurve] !== handle.keyDetail({}).namedCurve) + if (kNamedCurveAliases[namedCurve] !== + handle.keyDetail({ __proto__: null }).namedCurve) throw lazyDOMException('Named curve mismatch', 'DataError'); return new InternalCryptoKey( diff --git a/lib/internal/crypto/hash.js b/lib/internal/crypto/hash.js index 60d34aae902c..a32d74f65cb4 100644 --- a/lib/internal/crypto/hash.js +++ b/lib/internal/crypto/hash.js @@ -31,6 +31,7 @@ const { kHandle, getCachedHashId, getHashCache, + getOptionalByteLength, } = require('internal/crypto/util'); const { @@ -234,8 +235,8 @@ function asyncDigest(algorithm, data) { // Fall through case 'cSHAKE256': { const outputLength = algorithm.outputLength; - if (algorithm.functionName?.byteLength || - algorithm.customization?.byteLength) { + if (getOptionalByteLength(algorithm.functionName) || + getOptionalByteLength(algorithm.customization)) { if (CShakeJob === undefined) { throw lazyDOMException( 'Non-empty CShakeParams functionName or customization is not supported', diff --git a/lib/internal/crypto/hashnames.js b/lib/internal/crypto/hashnames.js index 7a625c47e2f4..c39d696a8011 100644 --- a/lib/internal/crypto/hashnames.js +++ b/lib/internal/crypto/hashnames.js @@ -2,6 +2,7 @@ const { ObjectKeys, + ObjectSetPrototypeOf, } = primordials; const kHashContextNode = 1; @@ -71,15 +72,22 @@ const kHashNames = { }, }; +// Both tables are indexed with computed keys, so a polluted %Object.prototype% +// key must not answer a miss. Detached here rather than declared +// `__proto__: null`: V8 puts that literal form in dictionary mode. +ObjectSetPrototypeOf(kHashNames, null); + { // Index the aliases const keys = ObjectKeys(kHashNames); for (let n = 0; n < keys.length; n++) { - const contexts = ObjectKeys(kHashNames[keys[n]]); + const entry = kHashNames[keys[n]]; + ObjectSetPrototypeOf(entry, null); + const contexts = ObjectKeys(entry); for (let i = 0; i < contexts.length; i++) { - const alias = kHashNames[keys[n]][contexts[i]]; + const alias = entry[contexts[i]]; if (kHashNames[alias] === undefined) - kHashNames[alias] = kHashNames[keys[n]]; + kHashNames[alias] = entry; } } } diff --git a/lib/internal/crypto/keys.js b/lib/internal/crypto/keys.js index 600a01b7b514..a7f3b4f47cfb 100644 --- a/lib/internal/crypto/keys.js +++ b/lib/internal/crypto/keys.js @@ -5,7 +5,6 @@ const { ObjectDefineProperties, ObjectPrototypeHasOwnProperty, ObjectSetPrototypeOf, - SafeSet, StringPrototypeIncludes, StringPrototypeStartsWith, SymbolToStringTag, @@ -68,6 +67,7 @@ const { getUsagesMask, getUsagesFromMask, hasUsage, + toUsagesSet, } = require('internal/crypto/util'); const { @@ -1332,7 +1332,7 @@ function importGenericSecretKey( extractable, keyUsages, ) { - const usagesSet = new SafeSet(keyUsages); + const usagesSet = toUsagesSet(keyUsages); const { name } = algorithm; if (extractable) throw lazyDOMException(`${name} keys are not extractable`, 'SyntaxError'); diff --git a/lib/internal/crypto/ml_dsa.js b/lib/internal/crypto/ml_dsa.js index 857961ff6ef3..0756198312c0 100644 --- a/lib/internal/crypto/ml_dsa.js +++ b/lib/internal/crypto/ml_dsa.js @@ -1,7 +1,6 @@ 'use strict'; const { - SafeSet, StringPrototypeToLowerCase, TypedArrayPrototypeGetBuffer, TypedArrayPrototypeGetByteLength, @@ -28,6 +27,8 @@ const { const { getUsagesMask, jobPromise, + toUsagesSet, + getBufferSourceByteLength, } = require('internal/crypto/util'); const { @@ -122,7 +123,7 @@ function mlDsaImportKey( const { name } = algorithm; let handle; - const usagesSet = new SafeSet(usages); + const usagesSet = toUsagesSet(usages); switch (format) { case 'KeyObjectHandle': verifyAcceptableKeyUse( @@ -147,7 +148,7 @@ function mlDsaImportKey( 'ML-DSA-65': 4060, 'ML-DSA-87': 4924, }; - if (keyData.byteLength === privOnlyLengths[name]) { + if (getBufferSourceByteLength(keyData) === privOnlyLengths[name]) { throw lazyDOMException( 'Importing an ML-DSA PKCS#8 key without a seed is not supported', 'NotSupportedError'); diff --git a/lib/internal/crypto/ml_kem.js b/lib/internal/crypto/ml_kem.js index f18dcd13db77..c917c88c0f29 100644 --- a/lib/internal/crypto/ml_kem.js +++ b/lib/internal/crypto/ml_kem.js @@ -1,7 +1,6 @@ 'use strict'; const { - SafeSet, StringPrototypeToLowerCase, TypedArrayPrototypeGetBuffer, TypedArrayPrototypeGetByteLength, @@ -27,6 +26,8 @@ const { const { getUsagesMask, jobPromise, + toUsagesSet, + getBufferSourceByteLength, } = require('internal/crypto/util'); const { @@ -123,7 +124,7 @@ function mlKemImportKey( const { name } = algorithm; let handle; - const usagesSet = new SafeSet(usages); + const usagesSet = toUsagesSet(usages); switch (format) { case 'KeyObjectHandle': verifyAcceptableKeyUse( @@ -148,7 +149,7 @@ function mlKemImportKey( 'ML-KEM-768': 2428, 'ML-KEM-1024': 3196, }; - if (keyData.byteLength === privOnlyLengths[name]) { + if (getBufferSourceByteLength(keyData) === privOnlyLengths[name]) { throw lazyDOMException( 'Importing an ML-KEM PKCS#8 key without a seed is not supported', 'NotSupportedError'); diff --git a/lib/internal/crypto/rsa.js b/lib/internal/crypto/rsa.js index a2757384f4f4..d153ab664bd8 100644 --- a/lib/internal/crypto/rsa.js +++ b/lib/internal/crypto/rsa.js @@ -2,7 +2,6 @@ const { MathCeil, - SafeSet, TypedArrayPrototypeGetBuffer, Uint8Array, } = primordials; @@ -35,6 +34,7 @@ const { jobPromise, normalizeHashName, validateMaxBufferLength, + toUsagesSet, } = require('internal/crypto/util'); const { @@ -174,7 +174,7 @@ function rsaImportKey( extractable, usages) { const allowedUsages = kUsages[algorithm.name]; - const usagesSet = new SafeSet(usages); + const usagesSet = toUsagesSet(usages); let handle; switch (format) { case 'KeyObjectHandle': @@ -234,7 +234,7 @@ function rsaImportKey( const { modulusLength, publicExponent, - } = handle.keyDetail({}); + } = handle.keyDetail({ __proto__: null }); return new InternalCryptoKey(handle, { name: algorithm.name, diff --git a/lib/internal/crypto/util.js b/lib/internal/crypto/util.js index ca02c6907dad..282ce9b35591 100644 --- a/lib/internal/crypto/util.js +++ b/lib/internal/crypto/util.js @@ -15,15 +15,18 @@ const { ObjectEntries, ObjectKeys, ObjectPrototypeHasOwnProperty, + ObjectSetPrototypeOf, PromisePrototypeThen, PromiseReject, PromiseWithResolvers, SafeMap, + SafeSet, StringPrototypeToUpperCase, Symbol, TypedArrayPrototypeGetBuffer, TypedArrayPrototypeGetByteLength, TypedArrayPrototypeGetByteOffset, + TypedArrayPrototypeGetLength, TypedArrayPrototypeSlice, Uint8Array, } = primordials; @@ -459,17 +462,23 @@ const experimentalAlgorithms = [ // Also builds a parallel Map per operation // for O(1) case-insensitive algorithm name lookup in normalizeAlgorithm. function createSupportedAlgorithms(algorithmDefs) { + // Detached below rather than declared `__proto__: null`: V8 puts that + // literal form in dictionary mode, slowing every registry lookup. const result = {}; const nameMap = {}; - for (const { 0: algorithmName, 1: operations } of ObjectEntries(algorithmDefs)) { + const algorithmEntries = ObjectEntries(algorithmDefs); + for (let i = 0; i < algorithmEntries.length; i++) { + const { 0: algorithmName, 1: operations } = algorithmEntries[i]; // Skip algorithms that are conditionally not supported if (ObjectPrototypeHasOwnProperty(conditionalAlgorithms, algorithmName) && !conditionalAlgorithms[algorithmName]) { continue; } - for (const { 0: operation, 1: dict } of ObjectEntries(operations)) { + const operationEntries = ObjectEntries(operations); + for (let j = 0; j < operationEntries.length; j++) { + const { 0: operation, 1: dict } = operationEntries[j]; result[operation] ||= {}; nameMap[operation] ||= new SafeMap(); nameMap[operation].set(StringPrototypeToUpperCase(algorithmName), algorithmName); @@ -490,6 +499,13 @@ function createSupportedAlgorithms(algorithmDefs) { } } + const operations = ObjectKeys(result); + for (let i = 0; i < operations.length; i++) { + ObjectSetPrototypeOf(result[operations[i]], null); + } + ObjectSetPrototypeOf(result, null); + ObjectSetPrototypeOf(nameMap, null); + return { algorithms: result, nameMap }; } @@ -538,12 +554,17 @@ const simpleAlgorithmDictionaries = { // Pre-compute ObjectKeys() for each dictionary entry at module init // to avoid allocating a new keys array on every normalizeAlgorithm call. -for (const { 0: name, 1: types } of ObjectEntries(simpleAlgorithmDictionaries)) { +const simpleAlgorithmDictionaryEntries = + ObjectEntries(simpleAlgorithmDictionaries); +for (let i = 0; i < simpleAlgorithmDictionaryEntries.length; i++) { + const { 0: name, 1: types } = simpleAlgorithmDictionaryEntries[i]; simpleAlgorithmDictionaries[name] = { keys: ObjectKeys(types), types }; } +// See createSupportedAlgorithms() for why this is detached here. +ObjectSetPrototypeOf(simpleAlgorithmDictionaries, null); function validateMaxBufferLength(data, name, max = kMaxBufferLength) { - if (data.byteLength > max) { + if (getBufferSourceByteLength(data) > max) { throw lazyDOMException( `${name} must be at most ${max} bytes`, 'OperationError'); @@ -648,20 +669,8 @@ function normalizeAlgorithm(algorithm, op) { const idlValue = normalizedAlgorithm[member]; // 3. if (idlType === 'BufferSource' && idlValue) { - const isView = ArrayBufferIsView(idlValue); - const idlValueBytes = isView ? - new Uint8Array( - getDataViewOrTypedArrayBuffer(idlValue), - getDataViewOrTypedArrayByteOffset(idlValue), - getDataViewOrTypedArrayByteLength(idlValue), - ) : - new Uint8Array( - idlValue, - 0, - ArrayBufferPrototypeGetByteLength(idlValue), - ); normalizedAlgorithm[member] = TypedArrayPrototypeSlice( - idlValueBytes, + getBufferSourceBytes(idlValue), ); } else if (idlType === 'HashAlgorithmIdentifier') { normalizedAlgorithm[member] = normalizeAlgorithm(idlValue, 'digest'); @@ -690,6 +699,26 @@ function getDataViewOrTypedArrayByteLength(V) { DataViewPrototypeGetByteLength(V) : TypedArrayPrototypeGetByteLength(V); } +function getBufferSourceByteLength(V) { + return ArrayBufferIsView(V) ? + getDataViewOrTypedArrayByteLength(V) : + ArrayBufferPrototypeGetByteLength(V); +} + +function getBufferSourceBytes(V) { + return ArrayBufferIsView(V) ? + new Uint8Array( + getDataViewOrTypedArrayBuffer(V), + getDataViewOrTypedArrayByteOffset(V), + getDataViewOrTypedArrayByteLength(V), + ) : + new Uint8Array(V, 0, ArrayBufferPrototypeGetByteLength(V)); +} + +function getOptionalByteLength(V) { + return V === undefined ? 0 : TypedArrayPrototypeGetByteLength(V); +} + function hasAnyNotIn(set, checks) { for (const s of set) if (!ArrayPrototypeIncludes(checks, s)) @@ -848,9 +877,10 @@ function jobPromiseThen(promise, onFulfilled, onRejected) { // Returns undefined if the conversion was unsuccessful. function bigIntArrayToUnsignedInt(input) { let result = 0; + const length = TypedArrayPrototypeGetLength(input); - for (let n = 0; n < input.length; ++n) { - const n_reversed = input.length - n - 1; + for (let n = 0; n < length; ++n) { + const n_reversed = length - n - 1; if (n_reversed >= 4 && input[n]) return; // Too large result |= input[n] << 8 * n_reversed; @@ -861,9 +891,10 @@ function bigIntArrayToUnsignedInt(input) { function bigIntArrayToUnsignedBigInt(input) { let result = 0n; + const length = TypedArrayPrototypeGetLength(input); - for (let n = 0; n < input.length; ++n) { - const n_reversed = input.length - n - 1; + for (let n = 0; n < length; ++n) { + const n_reversed = length - n - 1; result |= BigInt(input[n]) << 8n * BigInt(n_reversed); } @@ -903,6 +934,19 @@ for (let n = 0; n < kCanonicalUsageOrder.length; n++) { kUsageByMask[mask] = usage; } +/** + * Collects a key usage list into a set. + * @param {string[]} usages + * @returns {SafeSet} + */ +function toUsagesSet(usages) { + const usagesSet = new SafeSet(); + for (let n = 0; n < usages.length; n++) { + usagesSet.add(usages[n]); + } + return usagesSet; +} + /** * Returns a bit mask representing the usages from `usageSet`. * @param {SafeSet} usageSet @@ -1031,10 +1075,13 @@ function secureHeapUsed() { module.exports = { getArrayBufferOrView, + getBufferSourceByteLength, + getBufferSourceBytes, getCiphers, getCurves, getDataViewOrTypedArrayBuffer, getHashes, + getOptionalByteLength, kHandle, setEngine, toBuf, @@ -1060,6 +1107,7 @@ module.exports = { getStringOption, getUsagesMask, getUsagesFromMask, + toUsagesSet, hasUsage, secureHeapUsed, getCachedHashId, diff --git a/lib/internal/crypto/webcrypto.js b/lib/internal/crypto/webcrypto.js index ff8cb8062891..8943da946fce 100644 --- a/lib/internal/crypto/webcrypto.js +++ b/lib/internal/crypto/webcrypto.js @@ -69,6 +69,7 @@ const { numBitsToBytes, prepareWebCryptoResult, validateMaxBufferLength, + getOptionalByteLength, } = require('internal/crypto/util'); const { @@ -130,9 +131,17 @@ function prepareSubtleMethod(receiver, method, argLength, required) { } function convertSubtleArgument(prefix, converter, value, index) { + // Mirrors makeOptions() in internal/webidl, including why it stays an + // ordinary literal: the converters read every member below, and an absent + // one would resolve through %Object.prototype%. return webidl.converters[converter](value, { prefix, context: kArgumentContexts[index], + code: undefined, + enforceRange: undefined, + clamp: undefined, + allowShared: undefined, + allowResizable: undefined, }); } @@ -1802,8 +1811,8 @@ function check(op, alg, length) { case 'digest': { if ((normalizedAlgorithm.name === 'cSHAKE128' || normalizedAlgorithm.name === 'cSHAKE256') && - (normalizedAlgorithm.functionName?.byteLength || - normalizedAlgorithm.customization?.byteLength)) { + (getOptionalByteLength(normalizedAlgorithm.functionName) || + getOptionalByteLength(normalizedAlgorithm.customization))) { return CShakeJob !== undefined; } return true; diff --git a/lib/internal/crypto/webcrypto_util.js b/lib/internal/crypto/webcrypto_util.js index 1b802a6dc466..065520c59cb3 100644 --- a/lib/internal/crypto/webcrypto_util.js +++ b/lib/internal/crypto/webcrypto_util.js @@ -1,7 +1,7 @@ 'use strict'; const { - ArrayPrototypePush, + ArrayPrototypePushApply, SafeSet, } = primordials; @@ -19,6 +19,7 @@ const { const { hasAnyNotIn, validateKeyOps, + toUsagesSet, } = require('internal/crypto/util'); const { @@ -60,7 +61,7 @@ function verifyAcceptableKeyUse(subject, usagesSet, allowed) { * @returns {SafeSet} */ function validateKeyUsages(usages, allowed, subject) { - const usagesSet = new SafeSet(usages); + const usagesSet = toUsagesSet(usages); verifyAcceptableKeyUse(subject, usagesSet, allowed); return usagesSet; } @@ -115,12 +116,8 @@ function getKeyPairUsages(usagesSet, allowed) { */ function createKeyUsages(publicUsages, privateUsages) { const keygen = []; - for (let n = 0; n < publicUsages.length; n++) { - ArrayPrototypePush(keygen, publicUsages[n]); - } - for (let n = 0; n < privateUsages.length; n++) { - ArrayPrototypePush(keygen, privateUsages[n]); - } + ArrayPrototypePushApply(keygen, publicUsages); + ArrayPrototypePushApply(keygen, privateUsages); return { __proto__: null, public: publicUsages, diff --git a/lib/internal/crypto/webidl.js b/lib/internal/crypto/webidl.js index cd90c36e96ca..5dc263120eae 100644 --- a/lib/internal/crypto/webidl.js +++ b/lib/internal/crypto/webidl.js @@ -1,15 +1,15 @@ 'use strict'; const { - ArrayBufferIsView, ArrayPrototypeIncludes, MathPow, NumberParseInt, ObjectPrototypeHasOwnProperty, StringPrototypeCharCodeAt, + StringPrototypeSplit, StringPrototypeStartsWith, StringPrototypeToLowerCase, - Uint8Array, + TypedArrayPrototypeGetLength, } = primordials; const { @@ -26,6 +26,8 @@ const { } = require('internal/crypto/keys'); const { validateMaxBufferLength, + getBufferSourceByteLength, + getBufferSourceBytes, kNamedCurveAliases, numBitsToBytes, } = require('internal/crypto/util'); @@ -40,7 +42,7 @@ const { } = require('internal/webidl'); function validateByteLength(buf, name, target) { - if (buf.byteLength !== target) { + if (getBufferSourceByteLength(buf) !== target) { throw lazyDOMException( `${name} must contain exactly ${target} bytes`, 'OperationError'); @@ -126,6 +128,9 @@ function enforceRangeOptions(opts) { context: opts.context, code: opts.code, enforceRange: true, + clamp: undefined, + allowShared: undefined, + allowResizable: undefined, }; } @@ -256,7 +261,7 @@ converters.AesKeyGenParams = createDictionaryConverter( function validateZeroLength(parameterName) { return (V, dict) => { - if (V.byteLength) { + if (getBufferSourceByteLength(V)) { throw lazyDOMException( `Non zero-length ${parameterName} is not supported.`, 'NotSupportedError'); } @@ -272,19 +277,18 @@ function validateCShakeOutputLength(V) { } function bufferSourceEqualsAscii(V, string) { - if (V.byteLength !== string.length) return false; + if (getBufferSourceByteLength(V) !== string.length) return false; - const bytes = ArrayBufferIsView(V) ? - new Uint8Array(V.buffer, V.byteOffset, V.byteLength) : - new Uint8Array(V); - for (let i = 0; i < bytes.length; i++) { + const bytes = getBufferSourceBytes(V); + const length = TypedArrayPrototypeGetLength(bytes); + for (let i = 0; i < length; i++) { if (bytes[i] !== StringPrototypeCharCodeAt(string, i)) return false; } return true; } function validateCShakeFunctionName(V) { - if (V.byteLength === 0 || + if (getBufferSourceByteLength(V) === 0 || bufferSourceEqualsAscii(V, 'KMAC') || bufferSourceEqualsAscii(V, 'TupleHash') || bufferSourceEqualsAscii(V, 'ParallelHash')) { @@ -340,7 +344,12 @@ function validateHmacKeyLength(parameterName, zeroError) { }; } -for (const { 0: name, 1: zeroError } of [['HmacKeyGenParams', 'OperationError'], ['HmacImportParams', 'DataError']]) { +const kHmacDictionaries = [ + ['HmacKeyGenParams', 'OperationError'], + ['HmacImportParams', 'DataError'], +]; +for (let i = 0; i < kHmacDictionaries.length; i++) { + const { 0: name, 1: zeroError } = kHmacDictionaries[i]; converters[name] = createDictionaryConverter( name, [ dictAlgorithm, @@ -524,7 +533,7 @@ converters.AeadParams = createDictionaryConverter( validateMaxBufferLength(V, 'algorithm.iv'); break; case 'aes-ocb': - if (V.byteLength > 15) { + if (getBufferSourceByteLength(V) > 15) { throw lazyDOMException( 'AES-OCB algorithm.iv must be no more than 15 bytes', 'OperationError'); @@ -633,7 +642,8 @@ converters.ContextParams = createDictionaryConverter( if (process.features.openssl_is_boringssl) { this.validator = undefined; } else { - let { 0: major, 1: minor } = process.versions.openssl.split('.'); + let { 0: major, 1: minor } = + StringPrototypeSplit(process.versions.openssl, '.'); major = NumberParseInt(major, 10); minor = NumberParseInt(minor, 10); if (major > 3 || (major === 3 && minor >= 2)) { @@ -656,7 +666,7 @@ converters.Argon2Params = createDictionaryConverter( key: 'nonce', converter: converters.BufferSource, validator: (V) => { - if (V.byteLength < 8) { + if (getBufferSourceByteLength(V) < 8) { throw lazyDOMException('nonce must be at least 8 bytes', 'OperationError'); } }, @@ -722,7 +732,9 @@ converters.Argon2Params = createDictionaryConverter( ], ]); -for (const name of ['KmacKeyGenParams', 'KmacImportParams']) { +const kKmacDictionaries = ['KmacKeyGenParams', 'KmacImportParams']; +for (let i = 0; i < kKmacDictionaries.length; i++) { + const name = kKmacDictionaries[i]; converters[name] = createDictionaryConverter( name, [ dictAlgorithm, diff --git a/test/parallel/test-webcrypto-prototype-pollution.mjs b/test/parallel/test-webcrypto-prototype-pollution.mjs new file mode 100644 index 000000000000..9a35444ee968 --- /dev/null +++ b/test/parallel/test-webcrypto-prototype-pollution.mjs @@ -0,0 +1,469 @@ +// Flags: --expose-internals + +import * as common from '../common/index.mjs'; +import assert from 'node:assert'; +import { createRequire } from 'node:module'; + +if (!common.hasCrypto) common.skip('missing crypto'); + +// Regression tests for prototype pollution reaching WebCrypto input validation +// and normalization, via BufferSource prototype accessors, inherited +// %Object.prototype% keys, or %Array.prototype%[%Symbol.iterator%]. See +// test-webcrypto-promise-prototype-pollution.mjs for the promise side. + +const require = createRequire(import.meta.url); +const { kSupportedAlgorithms } = require('internal/crypto/util'); +const { getFips } = require('node:crypto'); +const { hasOpenSSL } = require('../common/crypto'); +const { subtle } = globalThis.crypto; + +const TypedArrayPrototype = Object.getPrototypeOf(Uint8Array.prototype); +const data = new TextEncoder().encode('prototype pollution'); +const modulusLength = getFips() === 1 ? 2048 : 1024; + +// Avoids SubtleCrypto.supports(), which warns and invokes the registry's +// experimental-algorithm getters. +function supports(operation, name) { + return Object.hasOwn(kSupportedAlgorithms[operation] ?? {}, name); +} + +// Each poison is { target, key, ...descriptor }. +async function withPoisoned(poisons, fn) { + const saved = []; + for (const { target, key, ...descriptor } of poisons) { + saved.push([target, key, Object.getOwnPropertyDescriptor(target, key)]); + Object.defineProperty(target, key, { + __proto__: null, + configurable: true, + ...descriptor, + }); + } + try { + return await fn(); + } finally { + for (let i = saved.length - 1; i >= 0; i--) { + const { 0: target, 1: key, 2: descriptor } = saved[i]; + if (descriptor === undefined) { + delete target[key]; + } else { + Object.defineProperty(target, key, descriptor); + } + } + } +} + +function poisonTypedArrayByteLength(value) { + return [{ target: TypedArrayPrototype, key: 'byteLength', get: () => value }]; +} + +function inherited(key, value) { + return [{ target: Object.prototype, key, value, writable: true }]; +} + +const poisonArrayIterator = [{ + target: Array.prototype, + key: Symbol.iterator, + value: () => ({ next: () => ({ done: true, value: undefined }) }), + writable: true, +}]; + +// A poisoned array iterator breaks assert too, so settle under the poison and +// assert once it has been restored. +async function settleUnderPoison(poisons, fn) { + const outcome = { __proto__: null, value: undefined, error: undefined }; + await withPoisoned(poisons, async () => { + try { + outcome.value = await fn(); + } catch (err) { + outcome.error = err; + } + }); + return outcome; +} + +// validateByteLength(). Unguarded, the empty iv reaches OpenSSL, which also +// fails with OperationError, hence the message assertion. +{ + const key = await subtle.importKey( + 'raw-secret', new Uint8Array(16), 'AES-CBC', false, ['encrypt']); + await withPoisoned(poisonTypedArrayByteLength(16), common.mustCall(() => + assert.rejects( + subtle.encrypt({ name: 'AES-CBC', iv: new Uint8Array(0) }, key, data), + { + name: 'OperationError', + message: /algorithm\.iv must contain exactly 16 bytes/, + }))); +} + +// validateMaxBufferLength(). +{ + const key = await subtle.importKey( + 'raw-secret', new Uint8Array(32), 'HKDF', false, ['deriveBits']); + await withPoisoned(poisonTypedArrayByteLength(0), common.mustCall(() => + assert.rejects( + subtle.deriveBits({ + name: 'HKDF', + hash: 'SHA-256', + salt: new Uint8Array(0), + info: new Uint8Array(4096), + }, key, 8), + { + name: 'OperationError', + message: /algorithm\.info must be at most 1024 bytes/, + }))); +} + +// aesImportKey(). +await withPoisoned(poisonTypedArrayByteLength(16), common.mustCall(async () => { + const key = await subtle.importKey( + 'raw-secret', new Uint8Array(32), 'AES-GCM', true, ['encrypt']); + assert.strictEqual(key.algorithm.length, 256); +})); + +// validateCShakeFunctionName(). +if (supports('digest', 'cSHAKE128')) { + await withPoisoned(poisonTypedArrayByteLength(0), common.mustCall(() => + assert.rejects( + subtle.digest({ + name: 'cSHAKE128', + outputLength: 256, + functionName: new Uint8Array([0x41, 0x42, 0x43, 0x44]), + }, data), + { + name: 'NotSupportedError', + message: /Unsupported CShakeParams functionName/, + }))); + + // asyncDigest() picks the cSHAKE job over plain SHAKE on a non-empty + // customization. + if (hasOpenSSL(3)) { + const algorithm = { + name: 'cSHAKE128', + outputLength: 256, + customization: new Uint8Array([1, 2, 3]), + }; + const expected = new Uint8Array(await subtle.digest(algorithm, data)); + const plain = new Uint8Array( + await subtle.digest({ name: 'cSHAKE128', outputLength: 256 }, data)); + assert.notDeepStrictEqual(expected, plain); + await withPoisoned(poisonTypedArrayByteLength(0), + common.mustCall(async () => { + assert.deepStrictEqual( + new Uint8Array(await subtle.digest(algorithm, data)), + expected); + })); + } +} + +// AeadParams: AES-OCB caps the iv at 15 bytes. +if (supports('encrypt', 'AES-OCB')) { + const key = await subtle.importKey( + 'raw-secret', new Uint8Array(16), 'AES-OCB', false, ['encrypt']); + await withPoisoned(poisonTypedArrayByteLength(12), common.mustCall(() => + assert.rejects( + subtle.encrypt({ name: 'AES-OCB', iv: new Uint8Array(20) }, key, data), + { + name: 'OperationError', + message: /algorithm\.iv must be no more than 15 bytes/, + }))); +} + +// Argon2Params: the nonce has an 8 byte minimum. +if (supports('deriveBits', 'Argon2id')) { + const key = await subtle.importKey( + 'raw-secret', new Uint8Array(32), 'Argon2id', false, ['deriveBits']); + await withPoisoned(poisonTypedArrayByteLength(16), common.mustCall(() => + assert.rejects( + subtle.deriveBits({ + name: 'Argon2id', + nonce: new Uint8Array(4), + memory: 32, + passes: 1, + parallelism: 1, + }, key, 256), + { + name: 'OperationError', + message: /nonce must be at least 8 bytes/, + }))); +} + +// bigIntArrayToUnsignedInt(): TypedArray `length` is a prototype accessor. +await withPoisoned( + [{ target: TypedArrayPrototype, key: 'length', get: () => 0 }], + common.mustCall(async () => { + const { publicKey } = await subtle.generateKey({ + name: 'RSA-OAEP', + modulusLength, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, true, ['encrypt', 'decrypt']); + assert.strictEqual(publicKey.algorithm.modulusLength, modulusLength); + assert.deepStrictEqual( + publicKey.algorithm.publicExponent, new Uint8Array([1, 0, 1])); + })); + +// ecdhDeriveBits() bounds the request by the native job's ArrayBuffer. +{ + const { privateKey, publicKey } = await subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits']); + await withPoisoned( + [{ target: ArrayBuffer.prototype, key: 'byteLength', get: () => 1e9 }], + common.mustCall(() => assert.rejects( + subtle.deriveBits({ name: 'ECDH', public: publicKey }, privateKey, 8192), + { name: 'OperationError' }))); +} + +// simpleAlgorithmDictionaries relies on a miss returning undefined. +{ + const { privateKey, publicKey } = await subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits']); + await withPoisoned( + inherited('EcdhKeyDeriveParams', + { keys: ['public'], types: { public: 'BufferSource' } }), + common.mustCall(async () => { + const bits = await subtle.deriveBits( + { name: 'ECDH', public: publicKey }, privateKey, 128); + assert.strictEqual(bits.byteLength, 16); + })); + + await withPoisoned( + inherited('AesKeyGenParams', + { keys: ['name'], types: { name: 'AlgorithmIdentifier' } }), + common.mustCall(async () => { + const key = await subtle.generateKey( + { name: 'AES-GCM', length: 128 }, false, ['encrypt']); + assert.strictEqual(key.algorithm.length, 128); + })); +} + +// createDictionaryConverter() reads optional member descriptor keys. +{ + const key = await subtle.generateKey( + { name: 'AES-GCM', length: 128 }, false, ['encrypt']); + const encrypt = () => subtle.encrypt( + { name: 'AES-GCM', iv: new Uint8Array(12) }, key, data); + + for (const poison of [ + inherited('required', true), + inherited('defaultValue', () => 9999), + inherited('validator', common.mustNotCall('Object.prototype.validator')), + ]) { + await withPoisoned(poison, common.mustCall(async () => { + assert.strictEqual((await encrypt()).byteLength, data.byteLength + 16); + })); + } +} + +// Conversion options are read by key by the Web IDL converters. +{ + const key = await subtle.importKey( + 'raw-secret', new Uint8Array(32), 'HKDF', false, ['deriveBits']); + const hkdf = (length) => subtle.deriveBits({ + name: 'HKDF', + hash: 'SHA-256', + salt: new Uint8Array(0), + info: new Uint8Array(0), + }, key, length); + + // [EnforceRange] and [Clamp] are not set for a plain `unsigned long`, so + // 2 ** 32 wraps to 0 rather than throwing or clamping. + for (const attribute of ['enforceRange', 'clamp']) { + await withPoisoned( + inherited(attribute, true), + common.mustCall(async () => { + await assert.rejects(hkdf(2 ** 32), { code: 'ERR_OUT_OF_RANGE' }); + })); + } + + // [AllowResizable] is not set for BufferSource. + await withPoisoned(inherited('allowResizable', true), common.mustCall(() => + subtle.digest('SHA-256', new ArrayBuffer(8, { maxByteLength: 1024 })) + )); + + // makeException() falls back to ERR_INVALID_ARG_TYPE. + await withPoisoned(inherited('code', 'ERR_POLLUTED'), common.mustCall(() => + assert.rejects(subtle.digest('SHA-256', 'not a BufferSource'), + { code: 'ERR_INVALID_ARG_TYPE' }))); +} + +// enforceRangeOptions(): [EnforceRange] uses IntegerPart, not round-half-even. +{ + const key = await subtle.importKey( + 'raw-secret', new Uint8Array(4), 'PBKDF2', false, ['deriveBits']); + const pbkdf2 = (iterations) => subtle.deriveBits({ + name: 'PBKDF2', + hash: 'SHA-256', + salt: new Uint8Array(16), + iterations, + }, key, 8); + + const expected = new Uint8Array(await pbkdf2(1)); + await withPoisoned(inherited('clamp', true), common.mustCall(async () => { + assert.deepStrictEqual(new Uint8Array(await pbkdf2(1.5)), expected); + })); +} + +// keyDetail() is filled in by C++ with an ordinary [[Set]]. +{ + const { publicKey } = await subtle.generateKey({ + name: 'RSA-PSS', + modulusLength, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, true, ['sign', 'verify']); + const spki = await subtle.exportKey('spki', publicKey); + + await withPoisoned([ + { + target: Object.prototype, key: 'modulusLength', + get: () => 8192, set() {}, + }, + { + target: Object.prototype, key: 'publicExponent', + get: () => new Uint8Array([9, 9, 9]), set() {}, + }, + ], common.mustCall(async () => { + const imported = await subtle.importKey( + 'spki', spki, { name: 'RSA-PSS', hash: 'SHA-256' }, true, ['verify']); + assert.strictEqual(imported.algorithm.modulusLength, modulusLength); + assert.deepStrictEqual( + imported.algorithm.publicExponent, new Uint8Array([1, 0, 1])); + })); +} + +{ + const { publicKey } = await subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-384' }, true, ['sign', 'verify']); + const spki = await subtle.exportKey('spki', publicKey); + + await withPoisoned( + [{ + target: Object.prototype, key: 'namedCurve', + get: () => 'prime256v1', set() {}, + }], + common.mustCall(() => assert.rejects( + subtle.importKey( + 'spki', spki, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify']), + { name: 'DataError', message: /Named curve mismatch/ }))); +} + +// Key usages under a poisoned array iterator. Callers pass a Set so the +// spec-mandated sequence conversion still yields the requested usage; only +// WebCrypto's own re-iteration of that array sees the poison. +{ + // Every Set has to be built before the poison is installed, otherwise the + // Set constructor itself iterates its array argument and comes out empty. + const signOnly = new Set(['sign']); + const encryptOnly = new Set(['encrypt']); + const decryptOnly = new Set(['decrypt']); + const decapsulateKeyOnly = new Set(['decapsulateKey']); + + // Secret keys reject empty usages anyway, so match the message: the usage + // has to be rejected as unsupported, not as missing. + const cases = [ + { + name: 'AES-GCM', + message: /Unsupported key usage for AES-GCM key/, + importKey: () => subtle.importKey( + 'raw-secret', new Uint8Array(32), 'AES-GCM', false, signOnly), + }, + { + name: 'HKDF', + message: /Unsupported key usage for a HKDF key/, + importKey: () => subtle.importKey( + 'raw-secret', new Uint8Array(32), 'HKDF', false, encryptOnly), + }, + ]; + + const addPublicKeyCase = async (name, algorithm, usages, disallowed) => { + if (!supports('importKey', name)) return; + const { publicKey } = await subtle.generateKey(algorithm, true, usages); + const spki = await subtle.exportKey('spki', publicKey); + cases.push({ + name, + importKey: () => subtle.importKey( + 'spki', spki, algorithm, true, disallowed), + }); + }; + + await addPublicKeyCase('ECDSA', { name: 'ECDSA', namedCurve: 'P-256' }, + ['sign', 'verify'], signOnly); + await addPublicKeyCase('Ed25519', { name: 'Ed25519' }, + ['sign', 'verify'], signOnly); + await addPublicKeyCase('RSA-OAEP', { + name: 'RSA-OAEP', + modulusLength, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, ['encrypt', 'decrypt'], decryptOnly); + await addPublicKeyCase('ML-DSA-44', { name: 'ML-DSA-44' }, + ['sign', 'verify'], signOnly); + await addPublicKeyCase('ML-KEM-512', { name: 'ML-KEM-512' }, + ['encapsulateKey', 'decapsulateKey'], + decapsulateKeyOnly); + + for (const { name, message, importKey } of cases) { + const outcome = await settleUnderPoison(poisonArrayIterator, importKey); + assert.strictEqual(outcome.value, undefined, name); + assert.strictEqual(outcome.error?.name, 'SyntaxError', name); + if (message !== undefined) assert.match(outcome.error.message, message); + } +} + +// The registry, the Web IDL converters and the hash name aliases are built at +// module load, so poisoning those needs a fresh process. The child bodies are +// written as real functions and stringified into -e so that they stay linted. +async function runInFreshProcess(fn, args, expected) { + const { code, stdout, stderr } = await common.spawnPromisified( + process.execPath, ['-e', `(${fn})(${args})`]); + assert.strictEqual(code, 0, stderr); + assert.strictEqual(stdout.trim(), expected, stderr); +} + +// Only the load happens under the poison: a sequence argument would +// legitimately come out empty while the caller's iterator is broken. +async function pollutedArrayIteratorChild(kmac) { + const real = Array.prototype[Symbol.iterator]; + Array.prototype[Symbol.iterator] = () => ({ next: () => ({ done: true }) }); + const { subtle } = globalThis.crypto; + const out = []; + try { + out.push((await subtle.digest('SHA-256', new Uint8Array(4))).byteLength); + } finally { + Array.prototype[Symbol.iterator] = real; + } + const hmac = await subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); + out.push((await subtle.sign('HMAC', hmac, new Uint8Array(4))).byteLength); + const aes = await subtle.generateKey( + { name: 'AES-GCM', length: 128 }, false, ['encrypt']); + out.push((await subtle.encrypt( + { name: 'AES-GCM', iv: new Uint8Array(12) }, aes, new Uint8Array(4), + )).byteLength); + if (kmac) { + const key = await subtle.generateKey( + { name: 'KMAC128', length: 128 }, false, ['sign']); + out.push((await subtle.sign( + { name: 'KMAC128', outputLength: 256 }, key, new Uint8Array(4), + )).byteLength); + } + console.log(out.join(',')); +} + +// kHashNames indexes its aliases at load time. +async function pollutedHashNameChild() { + Object.prototype['SHA-256'] = { 1: 'md5', 2: 'POLLUTED' }; + const { subtle } = globalThis.crypto; + const key = await subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256' }, true, ['sign']); + const signature = await subtle.sign('HMAC', key, new Uint8Array(4)); + const { alg } = await subtle.exportKey('jwk', key); + console.log(`${signature.byteLength},${alg}`); +} + +{ + const kmac = supports('generateKey', 'KMAC128'); + await runInFreshProcess(pollutedArrayIteratorChild, kmac, + kmac ? '32,32,20,32' : '32,32,20'); + await runInFreshProcess(pollutedHashNameChild, '', '32,HS256'); +} From 219495fe2ce8a57a1afd71dfbc5327c828a31c93 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Sun, 9 Aug 2026 22:11:21 +0200 Subject: [PATCH 143/344] test: account for [EnforceRange] in test-webcrypto-prototype-pollution Signed-off-by: Filip Skokan PR-URL: https://github.com/nodejs/node/pull/65173 Reviewed-By: Aviv Keller Reviewed-By: Chemi Atlow --- test/parallel/test-webcrypto-prototype-pollution.mjs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/test/parallel/test-webcrypto-prototype-pollution.mjs b/test/parallel/test-webcrypto-prototype-pollution.mjs index 9a35444ee968..a7104c2b7ae4 100644 --- a/test/parallel/test-webcrypto-prototype-pollution.mjs +++ b/test/parallel/test-webcrypto-prototype-pollution.mjs @@ -265,14 +265,15 @@ await withPoisoned( info: new Uint8Array(0), }, key, length); - // [EnforceRange] and [Clamp] are not set for a plain `unsigned long`, so - // 2 ** 32 wraps to 0 rather than throwing or clamping. + // deriveBits length is [EnforceRange], so 2 ** 32 must throw even when + // conversion option properties are inherited from Object.prototype. for (const attribute of ['enforceRange', 'clamp']) { await withPoisoned( inherited(attribute, true), - common.mustCall(async () => { - await assert.rejects(hkdf(2 ** 32), { code: 'ERR_OUT_OF_RANGE' }); - })); + common.mustCall(() => assert.rejects(hkdf(2 ** 32), { + code: 'ERR_OUT_OF_RANGE', + name: 'TypeError', + }))); } // [AllowResizable] is not set for BufferSource. From fd7e9044335c5406032481865f05af8a25dce13e Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Sun, 2 Aug 2026 16:09:14 +0200 Subject: [PATCH 144/344] test: update tests to run with OpenSSL >= 3.0 FIPS mode Signed-off-by: Filip Skokan PR-URL: https://github.com/nodejs/node/pull/64960 Fixes: https://github.com/nodejs/node/issues/48379 Reviewed-By: Antoine du Hamel Reviewed-By: Aviv Keller --- lib/internal/crypto/webcrypto.js | 1 + test/common/crypto.js | 14 +- test/fixtures/keys/Makefile | 28 ++ test/fixtures/keys/agent1-fips.pfx | Bin 0 -> 3808 bytes test/fixtures/keys/ec-fips.pfx | Bin 0 -> 1261 bytes test/parallel/test-crypto-argon2-job.js | 4 +- test/parallel/test-crypto-argon2.js | 13 +- .../parallel/test-crypto-async-sign-verify.js | 61 ++-- .../test-crypto-authenticated-stream.js | 11 + test/parallel/test-crypto-authenticated.js | 51 ++- test/parallel/test-crypto-certificate.js | 10 +- .../test-crypto-cipheriv-decipheriv.js | 31 +- test/parallel/test-crypto-classes.js | 15 +- test/parallel/test-crypto-dh-constructor.js | 7 +- test/parallel/test-crypto-dh-curves.js | 182 ++++++----- test/parallel/test-crypto-dh-generate-keys.js | 5 +- test/parallel/test-crypto-dh-leak.js | 5 +- test/parallel/test-crypto-dh-modp2-views.js | 34 +- test/parallel/test-crypto-dh-modp2.js | 53 +-- test/parallel/test-crypto-dh-odd-key.js | 15 +- test/parallel/test-crypto-dh-shared.js | 6 +- test/parallel/test-crypto-dh.js | 8 +- test/parallel/test-crypto-ecdh-convert-key.js | 17 +- test/parallel/test-crypto-eddsa-variants.js | 14 +- test/parallel/test-crypto-encap-decap.js | 13 +- test/parallel/test-crypto-getcipherinfo.js | 8 +- test/parallel/test-crypto-hkdf.js | 8 +- test/parallel/test-crypto-hmac.js | 30 +- test/parallel/test-crypto-job-error-parity.js | 32 +- .../test-crypto-key-objects-messageport.js | 3 +- .../test-crypto-key-objects-to-crypto-key.js | 21 +- test/parallel/test-crypto-key-objects.js | 304 ++++++++++++------ test/parallel/test-crypto-key-store.js | 44 +-- test/parallel/test-crypto-keygen-async-dsa.js | 12 +- ...ypto-keygen-async-elliptic-curve-jwk-ec.js | 8 +- ...pto-keygen-async-elliptic-curve-jwk-rsa.js | 3 +- ...-crypto-keygen-async-elliptic-curve-jwk.js | 9 +- ...-keygen-async-encrypted-private-key-der.js | 6 +- ...ypto-keygen-async-encrypted-private-key.js | 11 +- ...nc-explicit-elliptic-curve-encrypted.js.js | 12 +- ...en-async-named-elliptic-curve-encrypted.js | 12 +- test/parallel/test-crypto-keygen-async-rsa.js | 15 +- .../parallel/test-crypto-keygen-bit-length.js | 27 +- .../parallel/test-crypto-keygen-dh-classic.js | 3 +- test/parallel/test-crypto-keygen-eddsa.js | 9 +- ...crypto-keygen-empty-passphrase-no-error.js | 12 +- ...rypto-keygen-empty-passphrase-no-prompt.js | 23 +- ...o-keygen-invalid-parameter-encoding-dsa.js | 8 +- ...ypto-keygen-key-object-without-encoding.js | 5 +- .../test-crypto-keygen-key-objects.js | 8 +- .../test-crypto-keygen-missing-oid.js | 5 +- ...test-crypto-keygen-no-rsassa-pss-params.js | 9 +- ...pto-keygen-non-standard-public-exponent.js | 46 +-- test/parallel/test-crypto-keygen-promisify.js | 8 +- test/parallel/test-crypto-keygen-raw.js | 32 +- .../test-crypto-keygen-rfc8017-9-1.js | 7 +- .../test-crypto-keygen-rfc8017-a-2-3.js | 10 +- test/parallel/test-crypto-keygen-rsa-pss.js | 8 +- test/parallel/test-crypto-keygen-sync.js | 10 +- .../test-crypto-keyobject-brand-check.js | 5 +- .../test-crypto-keyobject-clone-transfer.js | 3 +- .../test-crypto-keyobject-hidden-slots.js | 16 +- .../test-crypto-keyobject-no-own-symbols.js | 3 +- test/parallel/test-crypto-pbkdf2.js | 185 ++++++++--- .../test-crypto-pqc-encrypted-pkcs8.js | 25 +- .../test-crypto-private-decrypt-gh32240.js | 42 ++- ...t-crypto-publicDecrypt-fails-first-time.js | 10 +- test/parallel/test-crypto-rsa-dsa.js | 146 ++++++--- test/parallel/test-crypto-scrypt.js | 47 ++- test/parallel/test-crypto-secure-heap.js | 5 +- test/parallel/test-crypto-sign-verify.js | 214 ++++++++---- test/parallel/test-crypto-worker-thread.js | 3 +- test/parallel/test-crypto.js | 65 ++-- .../test-https-agent-additional-options.js | 15 +- ...test-https-agent-pfx-object-array-reuse.js | 55 +++- .../test-https-agent-session-eviction.js | 31 +- test/parallel/test-https-pfx.js | 21 +- ...ttps-selfsigned-no-keycertsign-no-crash.js | 3 +- test/parallel/test-tls-alert.js | 21 +- .../test-tls-client-getephemeralkeyinfo.js | 82 +++-- test/parallel/test-tls-client-mindhsize.js | 15 +- test/parallel/test-tls-dhe.js | 55 +++- test/parallel/test-tls-ecdh-multiple.js | 27 +- .../test-tls-env-extra-ca-with-options.js | 25 +- test/parallel/test-tls-getprotocol.js | 42 ++- test/parallel/test-tls-honorcipherorder.js | 3 +- test/parallel/test-tls-invalid-pfx.js | 7 +- test/parallel/test-tls-min-max-version.js | 45 ++- test/parallel/test-tls-multi-key.js | 12 + test/parallel/test-tls-multi-pfx.js | 39 ++- test/parallel/test-tls-passphrase.js | 28 ++ .../test-tls-pfx-authorizationerror.js | 30 +- test/parallel/test-tls-session-cache.js | 9 +- test/parallel/test-tls-set-ciphers.js | 136 ++++---- test/parallel/test-tls-write-error.js | 19 +- ...-webcrypto-aead-decrypt-detached-buffer.js | 38 ++- test/parallel/test-webcrypto-constructors.js | 44 ++- .../test-webcrypto-cryptokey-hidden-slots.js | 3 +- .../test-webcrypto-deduplicate-usages.js | 21 +- .../test-webcrypto-derivebits-argon2.js | 4 +- .../test-webcrypto-derivebits-cfrg.js | 11 + test/parallel/test-webcrypto-derivebits.js | 61 +++- .../parallel/test-webcrypto-derivekey-cfrg.js | 11 + test/parallel/test-webcrypto-derivekey.js | 124 ++++--- test/parallel/test-webcrypto-digest.js | 40 ++- .../test-webcrypto-encrypt-decrypt-aes.js | 9 + ...rypto-encrypt-decrypt-chacha20-poly1305.js | 11 + .../test-webcrypto-encrypt-decrypt.js | 11 +- .../test-webcrypto-export-import-cfrg.js | 16 +- test/parallel/test-webcrypto-export-import.js | 9 +- .../test-webcrypto-get-public-key.mjs | 11 + test/parallel/test-webcrypto-keygen.js | 79 ++++- ...-webcrypto-promise-prototype-pollution.mjs | 61 +++- .../test-webcrypto-raw-format-aliases.js | 20 +- .../test-webcrypto-sign-verify-ecdsa.js | 33 +- .../test-webcrypto-sign-verify-eddsa.js | 4 +- .../test-webcrypto-sign-verify-hmac.js | 5 +- .../test-webcrypto-sign-verify-kmac.js | 92 ++++-- .../test-webcrypto-sign-verify-ml-dsa.js | 4 +- .../test-webcrypto-sign-verify-rsa.js | 48 ++- test/parallel/test-webcrypto-sign-verify.js | 3 +- test/parallel/test-webcrypto-supports.mjs | 5 + test/parallel/test-webcrypto-wrap-unwrap.js | 63 +++- ...t-crypto-argon2-nonblocking-constructor.js | 4 +- test/pummel/test-crypto-dh-keys.js | 12 +- test/pummel/test-dh-regr.js | 27 +- .../test-webcrypto-derivebits-pbkdf2.js | 26 +- test/sequential/test-async-wrap-getasyncid.js | 4 +- test/wpt/status/WebCryptoAPI.cjs | 86 ++++- test/wpt/test-webcrypto.js | 28 ++ 130 files changed, 2869 insertions(+), 1014 deletions(-) create mode 100644 test/fixtures/keys/agent1-fips.pfx create mode 100644 test/fixtures/keys/ec-fips.pfx diff --git a/lib/internal/crypto/webcrypto.js b/lib/internal/crypto/webcrypto.js index 8943da946fce..c2803cca1372 100644 --- a/lib/internal/crypto/webcrypto.js +++ b/lib/internal/crypto/webcrypto.js @@ -1617,6 +1617,7 @@ class SubtleCrypto { } // Implements https://wicg.github.io/webcrypto-modern-algos/#SubtleCrypto-method-supports + // 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'); diff --git a/test/common/crypto.js b/test/common/crypto.js index f50d3895a178..63f7487f15d3 100644 --- a/test/common/crypto.js +++ b/test/common/crypto.js @@ -50,9 +50,14 @@ function assertApproximateSize(key, expectedSize) { function testEncryptDecrypt(publicKey, privateKey) { const message = 'Hello Node.js world!'; const plaintext = Buffer.from(message, 'utf8'); + const withOaepHash = (key) => { + if (!hasFIPS(3)) return key; + if (key?.key !== undefined) return { ...key, oaepHash: 'sha256' }; + return { key, oaepHash: 'sha256' }; + }; for (const key of [publicKey, privateKey]) { - const ciphertext = publicEncrypt(key, plaintext); - const received = privateDecrypt(privateKey, ciphertext); + const ciphertext = publicEncrypt(withOaepHash(key), plaintext); + const received = privateDecrypt(withOaepHash(privateKey), ciphertext); assert.strictEqual(received.toString('utf8'), message); } } @@ -118,6 +123,10 @@ const hasOpenSSL = (major = 0, minor = 0, patch = 0) => { return OPENSSL_VERSION_NUMBER >= opensslVersionNumber(major, minor, patch); }; +const hasFIPS = (major = 0, minor = 0, patch = 0) => { + return crypto.getFips() === 1 && hasOpenSSL(major, minor, patch); +}; + let opensslCli = null; module.exports = { @@ -134,6 +143,7 @@ module.exports = { sec1Exp, sec1EncExp, hasOpenSSL, + hasFIPS, get hasOpenSSL3() { return hasOpenSSL(3); }, diff --git a/test/fixtures/keys/Makefile b/test/fixtures/keys/Makefile index 128e928a5916..3ef61d00afff 100644 --- a/test/fixtures/keys/Makefile +++ b/test/fixtures/keys/Makefile @@ -8,6 +8,7 @@ all: \ ca5-cert.pem \ ca6-cert.pem \ agent1-cert.pem \ + agent1-fips.pfx \ agent1.pfx \ agent2-cert.pem \ agent3-cert.pem \ @@ -39,6 +40,7 @@ all: \ dsa_private_encrypted_1025.pem \ dsa_public_1025.pem \ ec-cert.pem \ + ec-fips.pfx \ ec.pfx \ fake-cnnic-root-cert.pem \ intermediate-ca-cert.pem \ @@ -444,6 +446,20 @@ agent1.pfx: agent1-cert.pem agent1-key.pem ca1-cert.pem -out agent1.pfx \ -password pass:sample +# PKCS12KDF is unavailable under FIPS properties. Use PBMAC1 with PBKDF2 +# instead, alongside AES-256/PBKDF2 key protection. +agent1-fips.pfx: agent1-cert.pem agent1-key.pem ca1-cert.pem + openssl pkcs12 -export \ + -keypbe AES-256-CBC \ + -certpbe AES-256-CBC \ + -iter 2048 \ + -pbmac1_pbkdf2 \ + -in agent1-cert.pem \ + -inkey agent1-key.pem \ + -certfile ca1-cert.pem \ + -out agent1-fips.pfx \ + -password pass:password + agent1-verify: agent1-cert.pem ca1-cert.pem openssl verify -CAfile ca1-cert.pem agent1-cert.pem @@ -787,6 +803,18 @@ ec.pfx: ec-cert.pem ec-key.pem -out ec.pfx \ -password pass: +# See agent1-fips.pfx for why the FIPS fixture uses PBMAC1. +ec-fips.pfx: ec-cert.pem ec-key.pem + openssl pkcs12 -export \ + -keypbe AES-256-CBC \ + -certpbe AES-256-CBC \ + -iter 2048 \ + -pbmac1_pbkdf2 \ + -in ec-cert.pem \ + -inkey ec-key.pem \ + -out ec-fips.pfx \ + -password pass:password + dh512.pem: openssl dhparam -out dh512.pem 512 diff --git a/test/fixtures/keys/agent1-fips.pfx b/test/fixtures/keys/agent1-fips.pfx new file mode 100644 index 0000000000000000000000000000000000000000..5613cfae430b4c83b74d8c7c0aac3fa4d1449d7f GIT binary patch literal 3808 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 Oi}QF-1I&N^_J082g(BYo literal 0 HcmV?d00001 diff --git a/test/fixtures/keys/ec-fips.pfx b/test/fixtures/keys/ec-fips.pfx new file mode 100644 index 0000000000000000000000000000000000000000..1c26d641b9fc78bc68993098dee50019150d6390 GIT binary patch literal 1261 zcmXqLVtL8L$ZXKW62!)-)#lOmotKfFaX}M{GfNYTJy6)%po!@fLW*rc6Vq*=&~+w8 z1|W3-A;V}859hG*8Uz^_BDg#TDl7s|lDkUY3*M~$d)_kP?ddrOn3y;i40zZ$Aht4b zvN9ORvT-J~c`&9jvoLD02zXmWU1#}p;NYjTtLzUuiWN3AF(qi@7lRJU)=;!=C!07r5l)TNC*s1^t-Y%Ea=Oj$r^n2NA7L>HcPa@p{a|1$;ZgE zA7zdwuC09kMB`dT{(}i$gaae^KjyiAZkulREjY77e@P?j``ZUL+-7cCrMGL}Tc6D6 zpKs?*W^TIhEX#Kp&&5ur-omhhJvOWhw^;1*Wq-A=DEd~X{zSF+Wooyp7 z>FGLK=WKrS*FV*L9)`TDKcwdR$g)nd`DD3r}lNfh56-F4xh`e zf{_PL)EXtbe_&~}dBYMLye(u&g6bJ><7IId7HgXO9?d-@`TN(JlRxEWtu?sZE~-_o zS+#`a2xnRD!X497xh9J|Jb8CRjYiX@V=af~M92Fc{=M(W#lH3vq4PSrBUu03-_a@_ zS|8f5PI7D7$;f~DQ!LLrDX#m!<&5sz+eh~58V7b(m#Kd}em0`*TiDtkzjJuH9+;{e zyBui6;$RO@D(d=NE;k0vEsTS>BA=g6>q;cobRC=^oX1!B zL}6m>eLLfi>aO;Qq83j#CeGaAWdGw|u$5gS7yq|{36e&4R6;IKvE864z+#cf!C=gr zSs1tC*VDa*TXk!7XQ@?rUkG#jc45N7)!K_Q6?|<}oqj6nesD?_|=GK8@fzb`gw*oeQ zJU27p}){EC33o5$^y1 literal 0 HcmV?d00001 diff --git a/test/parallel/test-crypto-argon2-job.js b/test/parallel/test-crypto-argon2-job.js index 37a0127958cf..b5bf48e197eb 100644 --- a/test/parallel/test-crypto-argon2-job.js +++ b/test/parallel/test-crypto-argon2-job.js @@ -4,10 +4,12 @@ const common = require('../common'); if (!common.hasCrypto) common.skip('missing crypto'); -const { hasOpenSSL } = require('../common/crypto'); +const { hasFIPS, hasOpenSSL } = require('../common/crypto'); if (!hasOpenSSL(3, 2)) common.skip('requires OpenSSL >= 3.2'); +if (hasFIPS(3)) + common.skip('Argon2 is not available in FIPS mode'); // Exercises the native Argon2 job directly via internalBinding, bypassing // the JS validators, to ensure that if invalid parameters ever reach the diff --git a/test/parallel/test-crypto-argon2.js b/test/parallel/test-crypto-argon2.js index 1f238e61a61d..447812877e9c 100644 --- a/test/parallel/test-crypto-argon2.js +++ b/test/parallel/test-crypto-argon2.js @@ -3,7 +3,7 @@ const common = require('../common'); if (!common.hasCrypto) common.skip('missing crypto'); -const { hasOpenSSL } = require('../common/crypto'); +const { hasFIPS, hasOpenSSL } = require('../common/crypto'); if (!hasOpenSSL(3, 2)) common.skip('requires OpenSSL >= 3.2'); @@ -28,6 +28,17 @@ const secret = Buffer.alloc(8, 0x03); const associatedData = Buffer.alloc(12, 0x04); const defaults = { message, nonce, parallelism: 1, tagLength: 64, memory: 8, passes: 3 }; +if (hasFIPS(3)) { + assert.throws(() => crypto.argon2Sync('argon2id', defaults), { + code: 'ERR_OSSL_EVP_UNSUPPORTED', + }); + crypto.argon2('argon2id', defaults, common.mustCall((err, result) => { + assert.strictEqual(err?.code, 'ERR_OSSL_EVP_UNSUPPORTED'); + assert.strictEqual(result, undefined); + })); + return; +} + const good = [ // Test vectors from RFC 9106 https://www.rfc-editor.org/rfc/rfc9106.html#name-test-vectors // and OpenSSL 3.2 https://github.com/openssl/openssl/blob/6dfa998f7ea150f9c6d4e4727cf6d5c82a68a8da/test/recipes/30-test_evp_data/evpkdf_argon2.txt diff --git a/test/parallel/test-crypto-async-sign-verify.js b/test/parallel/test-crypto-async-sign-verify.js index bee83eaf8de0..96b4b5d90679 100644 --- a/test/parallel/test-crypto-async-sign-verify.js +++ b/test/parallel/test-crypto-async-sign-verify.js @@ -3,12 +3,14 @@ const common = require('../common'); if (!common.hasCrypto) common.skip('missing crypto'); -const { hasOpenSSL3 } = require('../common/crypto'); +const { hasOpenSSL, hasFIPS } = require('../common/crypto'); const assert = require('assert'); const util = require('util'); const crypto = require('crypto'); const fixtures = require('../common/fixtures'); +const fips3 = hasFIPS(3); + function test( publicFixture, privateFixture, @@ -65,6 +67,15 @@ function test( } } +function testSignFailure(privateFixture, algorithm, options, code) { + const key = { key: fixtures.readKey(privateFixture), ...options }; + const data = Buffer.from('Hello world'); + assert.throws(() => crypto.sign(algorithm, data, key), { code }); + crypto.sign(algorithm, data, key, common.mustCall((err) => { + assert.strictEqual(err?.code, code); + })); +} + // RSA w/ default padding test('rsa_public.pem', 'rsa_private.pem', 'sha256', true); test('rsa_public.pem', 'rsa_private.pem', 'sha256', true, @@ -94,14 +105,19 @@ if (!process.features.openssl_is_boringssl) { test('ed448_public.pem', 'ed448_private.pem', undefined, true); // ECDSA w/ der signature encoding - test('ec_secp256k1_public.pem', 'ec_secp256k1_private.pem', 'sha384', - false); - test('ec_secp256k1_public.pem', 'ec_secp256k1_private.pem', 'sha384', - false, { dsaEncoding: 'der' }); - - // ECDSA w/ ieee-p1363 signature encoding - test('ec_secp256k1_public.pem', 'ec_secp256k1_private.pem', 'sha384', false, - { dsaEncoding: 'ieee-p1363' }); + if (fips3) { + testSignFailure('ec_secp256k1_private.pem', 'sha384', {}, + 'ERR_OSSL_EVP_OPERATION_NOT_SUPPORTED_FOR_THIS_KEYTYPE'); + } else { + test('ec_secp256k1_public.pem', 'ec_secp256k1_private.pem', 'sha384', + false); + test('ec_secp256k1_public.pem', 'ec_secp256k1_private.pem', 'sha384', + false, { dsaEncoding: 'der' }); + + // ECDSA w/ ieee-p1363 signature encoding + test('ec_secp256k1_public.pem', 'ec_secp256k1_private.pem', 'sha384', false, + { dsaEncoding: 'ieee-p1363' }); + } // DSA w/ der signature encoding test('dsa_public.pem', 'dsa_private.pem', 'sha256', @@ -157,7 +173,7 @@ MCowBQYDK2VuAyEA6pwGRbadNQAI/tYN8+/p/0/hbsdHfOEGr1ADiLVk/Gc= let expected = /no default digest/; let expectedCode = 'ERR_OSSL_EVP_NO_DEFAULT_DIGEST'; - if (hasOpenSSL3 || process.features.openssl_is_boringssl) { + if (hasOpenSSL(3) || process.features.openssl_is_boringssl) { expected = /operation[\s_]not[\s_]supported[\s_]for[\s_]this[\s_]keytype/i; expectedCode = 'ERR_OSSL_EVP_OPERATION_NOT_SUPPORTED_FOR_THIS_KEYTYPE'; } @@ -170,12 +186,21 @@ MCowBQYDK2VuAyEA6pwGRbadNQAI/tYN8+/p/0/hbsdHfOEGr1ADiLVk/Gc= } { - const { privateKey } = crypto.generateKeyPairSync('rsa', { - modulusLength: 512 - }); - crypto.sign('sha512', 'message', privateKey, common.mustCall((err) => { - assert.ok(err); - assert.match(err.message, /digest[\s_]too[\s_]big[\s_]for[\s_]rsa[\s_]key/i); - assert.match(err.code, /^ERR_OSSL_.*DIGEST_TOO_BIG_FOR_RSA_KEY$/); - })); + if (fips3) { + crypto.generateKeyPair('rsa', { modulusLength: 512 }, + common.mustCall((err) => { + assert.strictEqual( + err?.code, 'ERR_OSSL_RSA_INVALID_MODULUS'); + })); + } else { + const { privateKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 512 + }); + crypto.sign('sha512', 'message', privateKey, common.mustCall((err) => { + assert.ok(err); + assert.match( + err.message, /digest[\s_]too[\s_]big[\s_]for[\s_]rsa[\s_]key/i); + assert.match(err.code, /^ERR_OSSL_.*DIGEST_TOO_BIG_FOR_RSA_KEY$/); + })); + } } diff --git a/test/parallel/test-crypto-authenticated-stream.js b/test/parallel/test-crypto-authenticated-stream.js index 51b928ec36be..45736d3303dc 100644 --- a/test/parallel/test-crypto-authenticated-stream.js +++ b/test/parallel/test-crypto-authenticated-stream.js @@ -6,6 +6,7 @@ if (!common.hasCrypto) const assert = require('assert'); const crypto = require('crypto'); +const { hasFIPS } = require('../common/crypto'); const fs = require('fs'); const stream = require('stream'); const tmpdir = require('../common/tmpdir'); @@ -120,6 +121,16 @@ function test(config) { return; } + if (hasFIPS(3)) { + assert.throws(() => crypto.createDecipheriv( + config.cipher, config.key, config.iv, { + authTagLength: config.authTagLength, + }), { + code: 'ERR_CRYPTO_UNSUPPORTED_OPERATION', + }); + return; + } + direct(config); mstream(config); fstream(config); diff --git a/test/parallel/test-crypto-authenticated.js b/test/parallel/test-crypto-authenticated.js index 2a4e2a1520a3..4062a5b5495f 100644 --- a/test/parallel/test-crypto-authenticated.js +++ b/test/parallel/test-crypto-authenticated.js @@ -29,9 +29,10 @@ const assert = require('assert'); const crypto = require('crypto'); const { inspect } = require('util'); const fixtures = require('../common/fixtures'); -const { hasOpenSSL3 } = require('../common/crypto'); +const { hasOpenSSL, hasFIPS } = require('../common/crypto'); -const isFipsEnabled = crypto.getFips(); +const isFipsEnabled = crypto.getFips() === 1; +const fips3 = hasFIPS(3); // // Test authenticated encryption modes. @@ -559,6 +560,14 @@ for (const test of TEST_CASES) { const ciphertext = Buffer.concat([cipher.update(plain), cipher.final()]); const tag = cipher.getAuthTag(); + if (fips3 && mode === 'ccm') { + assert.throws(() => crypto.createDecipheriv( + `aes-128-${mode}`, key, iv, opts), { + code: 'ERR_CRYPTO_UNSUPPORTED_OPERATION', + }); + continue; + } + const decipher = crypto.createDecipheriv(`aes-128-${mode}`, key, iv, opts); decipher.setAuthTag(tag); assert.throws(() => { @@ -636,7 +645,7 @@ for (const test of TEST_CASES) { const cipher = crypto.createCipheriv('aes-128-ccm', key, iv, opts); assert.throws(() => { cipher.final(); - }, hasOpenSSL3 ? { + }, hasOpenSSL(3) ? { code: 'ERR_OSSL_TAG_NOT_SET' } : { message: /Unsupported state/ @@ -644,7 +653,14 @@ for (const test of TEST_CASES) { } } -if (!process.features.openssl_is_boringssl) { +if (fips3) { + assert.throws(() => crypto.createCipheriv( + 'chacha20-poly1305', Buffer.alloc(32), Buffer.alloc(12), { + authTagLength: 16, + }), { + code: 'ERR_OSSL_EVP_UNSUPPORTED', + }); +} else if (!process.features.openssl_is_boringssl) { const key = Buffer.alloc(32); const iv = Buffer.alloc(12); @@ -662,7 +678,7 @@ if (!process.features.openssl_is_boringssl) { // ChaCha20-Poly1305 should respect the authTagLength option and should not // require the authentication tag before calls to update() during decryption. -if (!process.features.openssl_is_boringssl) { +if (!fips3 && !process.features.openssl_is_boringssl) { const key = Buffer.alloc(32); const iv = Buffer.alloc(12); @@ -713,7 +729,7 @@ if (!process.features.openssl_is_boringssl) { // shorter tags as long as their length was valid according to NIST SP 800-38D. // For ChaCha20-Poly1305, we intentionally deviate from that because there are // no recommended or approved authentication tag lengths below 16 bytes. -if (!process.features.openssl_is_boringssl) { +if (!fips3 && !process.features.openssl_is_boringssl) { const rfcTestCases = TEST_CASES.filter(({ algo, tampered }) => { return algo === 'chacha20-poly1305' && tampered === false; }); @@ -752,7 +768,7 @@ if (!process.features.openssl_is_boringssl) { } // https://github.com/nodejs/node/issues/45874 -if (!process.features.openssl_is_boringssl) { +if (!fips3 && !process.features.openssl_is_boringssl) { const rfcTestCases = TEST_CASES.filter(({ algo, tampered }) => { return algo === 'chacha20-poly1305' && tampered === false; }); @@ -798,13 +814,20 @@ if (ciphers.includes('aes-128-ccm')) { const tag = cipher.getAuthTag(); assert.strictEqual(tag.length, 16); - const decipher = crypto.createDecipheriv('aes-128-ccm', key, nonce, { - authTagLength: 16, - }); - decipher.setAuthTag(tag); - decipher.setAAD(Buffer.alloc(0), { plaintextLength: 0 }); - decipher.update(new DataView(new ArrayBuffer(0))); - decipher.final(); + if (fips3) { + assert.throws(() => crypto.createDecipheriv( + 'aes-128-ccm', key, nonce, { authTagLength: 16 }), { + code: 'ERR_CRYPTO_UNSUPPORTED_OPERATION', + }); + } else { + const decipher = crypto.createDecipheriv('aes-128-ccm', key, nonce, { + authTagLength: 16, + }); + decipher.setAuthTag(tag); + decipher.setAAD(Buffer.alloc(0), { plaintextLength: 0 }); + decipher.update(new DataView(new ArrayBuffer(0))); + decipher.final(); + } } else { common.printSkipMessage('Skipping unsupported aes-128-ccm test'); } diff --git a/test/parallel/test-crypto-certificate.js b/test/parallel/test-crypto-certificate.js index 28d20ba61c75..6462654680ea 100644 --- a/test/parallel/test-crypto-certificate.js +++ b/test/parallel/test-crypto-certificate.js @@ -26,6 +26,7 @@ if (!common.hasCrypto) const assert = require('assert'); const crypto = require('crypto'); +const { hasFIPS } = require('../common/crypto'); const { Certificate } = crypto; const fixtures = require('../common/fixtures'); @@ -42,7 +43,7 @@ function copyArrayBuffer(buf) { function checkMethods(certificate) { if (!process.features.openssl_is_boringssl) - assert.strictEqual(certificate.verifySpkac(spkacValid), true); + assert.strictEqual(certificate.verifySpkac(spkacValid), !hasFIPS(3)); assert.strictEqual(certificate.verifySpkac(spkacFail), false); assert.strictEqual( @@ -59,9 +60,10 @@ function checkMethods(certificate) { if (!process.features.openssl_is_boringssl) { const ab = copyArrayBuffer(spkacValid); - assert.strictEqual(certificate.verifySpkac(ab), true); - assert.strictEqual(certificate.verifySpkac(new Uint8Array(ab)), true); - assert.strictEqual(certificate.verifySpkac(new DataView(ab)), true); + const expected = !hasFIPS(3); + assert.strictEqual(certificate.verifySpkac(ab), expected); + assert.strictEqual(certificate.verifySpkac(new Uint8Array(ab)), expected); + assert.strictEqual(certificate.verifySpkac(new DataView(ab)), expected); } } diff --git a/test/parallel/test-crypto-cipheriv-decipheriv.js b/test/parallel/test-crypto-cipheriv-decipheriv.js index 095458e7d0b4..b1965c5a80c1 100644 --- a/test/parallel/test-crypto-cipheriv-decipheriv.js +++ b/test/parallel/test-crypto-cipheriv-decipheriv.js @@ -5,8 +5,9 @@ if (!common.hasCrypto) const assert = require('assert'); const crypto = require('crypto'); -const { hasOpenSSL3 } = require('../common/crypto'); -const isFipsEnabled = crypto.getFips(); +const { hasOpenSSL, hasFIPS } = require('../common/crypto'); +const isFipsEnabled = crypto.getFips() === 1; +const fips3 = hasFIPS(3); function testCipher1(key, iv) { // Test encryption and decryption with explicit key and iv @@ -86,10 +87,12 @@ function testCipher3(key, iv) { { const Cipheriv = crypto.Cipheriv; - const key = '123456789012345678901234'; - const iv = '12345678'; + const algorithm = fips3 ? 'aes-128-cbc' : 'des-ede3-cbc'; + const key = fips3 ? + '1234567890123456' : '123456789012345678901234'; + const iv = fips3 ? '1234567890123456' : '12345678'; - const instance = Cipheriv('des-ede3-cbc', key, iv); + const instance = Cipheriv(algorithm, key, iv); assert(instance instanceof Cipheriv, 'Cipheriv is expected to return a new ' + 'instance when called without `new`'); @@ -119,10 +122,12 @@ function testCipher3(key, iv) { { const Decipheriv = crypto.Decipheriv; - const key = '123456789012345678901234'; - const iv = '12345678'; + const algorithm = fips3 ? 'aes-128-cbc' : 'des-ede3-cbc'; + const key = fips3 ? + '1234567890123456' : '123456789012345678901234'; + const iv = fips3 ? '1234567890123456' : '12345678'; - const instance = Decipheriv('des-ede3-cbc', key, iv); + const instance = Decipheriv(algorithm, key, iv); assert(instance instanceof Decipheriv, 'Decipheriv expected to return a new' + ' instance when called without `new`'); @@ -153,8 +158,10 @@ function testCipher3(key, iv) { testCipher1('0123456789abcd0123456789', '12345678'); testCipher1('0123456789abcd0123456789', Buffer.from('12345678')); testCipher1(Buffer.from('0123456789abcd0123456789'), '12345678'); -testCipher1(Buffer.from('0123456789abcd0123456789'), Buffer.from('12345678')); -testCipher2(Buffer.from('0123456789abcd0123456789'), Buffer.from('12345678')); +testCipher1( + Buffer.from('0123456789abcd0123456789'), Buffer.from('12345678')); +testCipher2( + Buffer.from('0123456789abcd0123456789'), Buffer.from('12345678')); if (!isFipsEnabled) { testCipher3(Buffer.from('000102030405060708090A0B0C0D0E0F', 'hex'), @@ -207,8 +214,8 @@ assert.throws( errMessage); // But all other IV lengths should be accepted. -const minIvLength = hasOpenSSL3 ? 8 : 1; -const maxIvLength = hasOpenSSL3 ? 64 : 256; +const minIvLength = hasOpenSSL(3) ? 8 : 1; +const maxIvLength = hasOpenSSL(3) ? 64 : 256; for (let n = minIvLength; n < maxIvLength; n += 1) { if (isFipsEnabled && n < 12) continue; crypto.createCipheriv('aes-128-gcm', Buffer.alloc(16), Buffer.alloc(n)); diff --git a/test/parallel/test-crypto-classes.js b/test/parallel/test-crypto-classes.js index 429bc91d4412..48d68c93fb62 100644 --- a/test/parallel/test-crypto-classes.js +++ b/test/parallel/test-crypto-classes.js @@ -6,7 +6,7 @@ if (!common.hasCrypto) { common.skip('missing crypto'); } const crypto = require('crypto'); -const { hasOpenSSL3 } = require('../common/crypto'); +const { hasOpenSSL, hasFIPS } = require('../common/crypto'); // 'ClassName' : ['args', 'for', 'constructor'] const TEST_CASES = { @@ -21,8 +21,17 @@ const TEST_CASES = { 'ECDH': ['prime256v1'], }; -if (!crypto.getFips()) { - TEST_CASES.DiffieHellman = [hasOpenSSL3 ? 1024 : 256]; +if (hasFIPS(3)) { + TEST_CASES.Hmac = ['sha1', '0123456789abcdef']; + TEST_CASES.Cipheriv = [ + 'aes-128-cbc', '0123456789abcdef', '1234567890abcdef']; + TEST_CASES.Decipheriv = TEST_CASES.Cipheriv; + TEST_CASES.Sign = ['RSA-SHA256']; + TEST_CASES.Verify = ['RSA-SHA256']; + TEST_CASES.DiffieHellman = [2048]; + TEST_CASES.DiffieHellmanGroup = ['modp14']; +} else if (crypto.getFips() !== 1) { + TEST_CASES.DiffieHellman = [hasOpenSSL(3) ? 1024 : 256]; } for (const [clazz, args] of Object.entries(TEST_CASES)) { diff --git a/test/parallel/test-crypto-dh-constructor.js b/test/parallel/test-crypto-dh-constructor.js index eb8674932484..28747ac3a726 100644 --- a/test/parallel/test-crypto-dh-constructor.js +++ b/test/parallel/test-crypto-dh-constructor.js @@ -5,9 +5,10 @@ if (!common.hasCrypto) const assert = require('assert'); const crypto = require('crypto'); -const { hasOpenSSL3 } = require('../common/crypto'); +const { hasOpenSSL, hasFIPS } = require('../common/crypto'); -const size = crypto.getFips() || hasOpenSSL3 ? 1024 : 256; +const size = hasFIPS(3) ? + 2048 : (crypto.getFips() === 1 || hasOpenSSL(3) ? 1024 : 256); const dh1 = crypto.createDiffieHellman(size); const p1 = dh1.getPrime('buffer'); @@ -21,7 +22,7 @@ const p1 = dh1.getPrime('buffer'); { const DiffieHellmanGroup = crypto.DiffieHellmanGroup; - const dhg = DiffieHellmanGroup('modp5'); + const dhg = DiffieHellmanGroup(hasFIPS(3) ? 'modp14' : 'modp5'); assert(dhg instanceof DiffieHellmanGroup, 'DiffieHellmanGroup is expected ' + 'to return a new instance when ' + 'called without `new`'); diff --git a/test/parallel/test-crypto-dh-curves.js b/test/parallel/test-crypto-dh-curves.js index f14c58e7c200..c2449a292894 100644 --- a/test/parallel/test-crypto-dh-curves.js +++ b/test/parallel/test-crypto-dh-curves.js @@ -5,7 +5,7 @@ if (!common.hasCrypto) const assert = require('assert'); const crypto = require('crypto'); -const { hasOpenSSL } = require('../common/crypto'); +const { hasOpenSSL, hasFIPS } = require('../common/crypto'); const { DH_CHECK_P_NOT_PRIME, DH_CHECK_P_NOT_SAFE_PRIME, @@ -123,112 +123,118 @@ if (availableCurves.has('prime256v1') && availableCurves.has('secp256k1')) { // ECDH should check that point is on curve const ecdh3 = crypto.createECDH('secp256k1'); - const key3 = ecdh3.generateKeys(); - - assert.throws( - () => ecdh2.computeSecret(key3, 'latin1', 'buffer'), - { - code: 'ERR_CRYPTO_ECDH_INVALID_PUBLIC_KEY', - name: 'Error', - message: 'Public key is not valid for specified curve' + if (hasFIPS(3)) { + assert.throws(() => ecdh3.generateKeys(), { + code: 'ERR_CRYPTO_OPERATION_FAILED', }); + } else { + const key3 = ecdh3.generateKeys(); - // ECDH should allow .setPrivateKey()/.setPublicKey() - const ecdh4 = crypto.createECDH('prime256v1'); + assert.throws( + () => ecdh2.computeSecret(key3, 'latin1', 'buffer'), + { + code: 'ERR_CRYPTO_ECDH_INVALID_PUBLIC_KEY', + name: 'Error', + message: 'Public key is not valid for specified curve' + }); - ecdh4.setPrivateKey(ecdh1.getPrivateKey()); - ecdh4.setPublicKey(ecdh1.getPublicKey()); + // ECDH should allow .setPrivateKey()/.setPublicKey() + const ecdh4 = crypto.createECDH('prime256v1'); - assert.throws(() => { - ecdh4.setPublicKey(ecdh3.getPublicKey()); - }, { message: 'Failed to convert Buffer to EC_POINT' }); + ecdh4.setPrivateKey(ecdh1.getPrivateKey()); + ecdh4.setPublicKey(ecdh1.getPublicKey()); - // Verify that we can use ECDH without having to use newly generated keys. - const ecdh5 = crypto.createECDH('secp256k1'); + assert.throws(() => { + ecdh4.setPublicKey(ecdh3.getPublicKey()); + }, { message: 'Failed to convert Buffer to EC_POINT' }); - // Verify errors are thrown when retrieving keys from an uninitialized object. - assert.throws(() => { - ecdh5.getPublicKey(); - }, /^Error: Failed to get ECDH public key$/); + // Verify that we can use ECDH without having to use newly generated keys. + const ecdh5 = crypto.createECDH('secp256k1'); - assert.throws(() => { - ecdh5.getPrivateKey(); - }, /^Error: Failed to get ECDH private key$/); + // Verify errors are thrown when retrieving keys from an uninitialized object. + assert.throws(() => { + ecdh5.getPublicKey(); + }, /^Error: Failed to get ECDH public key$/); + + assert.throws(() => { + ecdh5.getPrivateKey(); + }, /^Error: Failed to get ECDH private key$/); - // A valid private key for the secp256k1 curve. - const cafebabeKey = 'cafebabe'.repeat(8); - // Associated compressed and uncompressed public keys (points). - const cafebabePubPtComp = + // A valid private key for the secp256k1 curve. + const cafebabeKey = 'cafebabe'.repeat(8); + // Associated compressed and uncompressed public keys (points). + const cafebabePubPtComp = '03672a31bfc59d3f04548ec9b7daeeba2f61814e8ccc40448045007f5479f693a3'; - const cafebabePubPtUnComp = + const cafebabePubPtUnComp = '04672a31bfc59d3f04548ec9b7daeeba2f61814e8ccc40448045007f5479f693a3' + '2e02c7f93d13dc2732b760ca377a5897b9dd41a1c1b29dc0442fdce6d0a04d1d'; - ecdh5.setPrivateKey(cafebabeKey, 'hex'); - assert.strictEqual(ecdh5.getPrivateKey('hex'), cafebabeKey); - // Show that the public point (key) is generated while setting the - // private key. - assert.strictEqual(ecdh5.getPublicKey('hex'), cafebabePubPtUnComp); - - // Compressed and uncompressed public points/keys for other party's - // private key. - // 0xDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF - const peerPubPtComp = + ecdh5.setPrivateKey(cafebabeKey, 'hex'); + assert.strictEqual(ecdh5.getPrivateKey('hex'), cafebabeKey); + // Show that the public point (key) is generated while setting the + // private key. + assert.strictEqual(ecdh5.getPublicKey('hex'), cafebabePubPtUnComp); + + // Compressed and uncompressed public points/keys for other party's + // private key. + // 0xDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF + const peerPubPtComp = '02c6b754b20826eb925e052ee2c25285b162b51fdca732bcf67e39d647fb6830ae'; - const peerPubPtUnComp = + const peerPubPtUnComp = '04c6b754b20826eb925e052ee2c25285b162b51fdca732bcf67e39d647fb6830ae' + 'b651944a574a362082a77e3f2b5d9223eb54d7f2f76846522bf75f3bedb8178e'; - const sharedSecret = + const sharedSecret = '1da220b5329bbe8bfd19ceef5a5898593f411a6f12ea40f2a8eead9a5cf59970'; - assert.strictEqual(ecdh5.computeSecret(peerPubPtComp, 'hex', 'hex'), - sharedSecret); - assert.strictEqual(ecdh5.computeSecret(peerPubPtUnComp, 'hex', 'hex'), - sharedSecret); - - // Verify that we still have the same key pair as before the computation. - assert.strictEqual(ecdh5.getPrivateKey('hex'), cafebabeKey); - assert.strictEqual(ecdh5.getPublicKey('hex'), cafebabePubPtUnComp); + assert.strictEqual(ecdh5.computeSecret(peerPubPtComp, 'hex', 'hex'), + sharedSecret); + assert.strictEqual(ecdh5.computeSecret(peerPubPtUnComp, 'hex', 'hex'), + sharedSecret); - // Verify setting and getting compressed and non-compressed serializations. - ecdh5.setPublicKey(cafebabePubPtComp, 'hex'); - assert.strictEqual(ecdh5.getPublicKey('hex'), cafebabePubPtUnComp); - assert.strictEqual( - ecdh5.getPublicKey('hex', 'compressed'), - cafebabePubPtComp - ); - ecdh5.setPublicKey(cafebabePubPtUnComp, 'hex'); - assert.strictEqual(ecdh5.getPublicKey('hex'), cafebabePubPtUnComp); - assert.strictEqual( - ecdh5.getPublicKey('hex', 'compressed'), - cafebabePubPtComp - ); - - // Show why allowing the public key to be set on this type - // does not make sense. - ecdh5.setPublicKey(peerPubPtComp, 'hex'); - assert.strictEqual(ecdh5.getPublicKey('hex'), peerPubPtUnComp); - assert.throws(() => { - // Error because the public key does not match the private key anymore. - ecdh5.computeSecret(peerPubPtComp, 'hex', 'hex'); - }, /Invalid key pair/); - - // Set to a valid key to show that later attempts to set an invalid key are - // rejected. - ecdh5.setPrivateKey(cafebabeKey, 'hex'); - - // Some invalid private keys for the secp256k1 curve. - const errMessage = /Private key is not valid for specified curve/; - ['0000000000000000000000000000000000000000000000000000000000000000', - 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141', - 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF', - ].forEach((element) => { - assert.throws(() => { - ecdh5.setPrivateKey(element, 'hex'); - }, errMessage); - // Verify object state did not change. + // Verify that we still have the same key pair as before the computation. assert.strictEqual(ecdh5.getPrivateKey('hex'), cafebabeKey); - }); + assert.strictEqual(ecdh5.getPublicKey('hex'), cafebabePubPtUnComp); + + // Verify setting and getting compressed and non-compressed serializations. + ecdh5.setPublicKey(cafebabePubPtComp, 'hex'); + assert.strictEqual(ecdh5.getPublicKey('hex'), cafebabePubPtUnComp); + assert.strictEqual( + ecdh5.getPublicKey('hex', 'compressed'), + cafebabePubPtComp + ); + ecdh5.setPublicKey(cafebabePubPtUnComp, 'hex'); + assert.strictEqual(ecdh5.getPublicKey('hex'), cafebabePubPtUnComp); + assert.strictEqual( + ecdh5.getPublicKey('hex', 'compressed'), + cafebabePubPtComp + ); + + // Show why allowing the public key to be set on this type + // does not make sense. + ecdh5.setPublicKey(peerPubPtComp, 'hex'); + assert.strictEqual(ecdh5.getPublicKey('hex'), peerPubPtUnComp); + assert.throws(() => { + // Error because the public key does not match the private key anymore. + ecdh5.computeSecret(peerPubPtComp, 'hex', 'hex'); + }, /Invalid key pair/); + + // Set to a valid key to show that later attempts to set an invalid key are + // rejected. + ecdh5.setPrivateKey(cafebabeKey, 'hex'); + + // Some invalid private keys for the secp256k1 curve. + const errMessage = /Private key is not valid for specified curve/; + ['0000000000000000000000000000000000000000000000000000000000000000', + 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141', + 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF', + ].forEach((element) => { + assert.throws(() => { + ecdh5.setPrivateKey(element, 'hex'); + }, errMessage); + // Verify object state did not change. + assert.strictEqual(ecdh5.getPrivateKey('hex'), cafebabeKey); + }); + } } // Use of invalid keys was not cleaning up ERR stack, and was causing diff --git a/test/parallel/test-crypto-dh-generate-keys.js b/test/parallel/test-crypto-dh-generate-keys.js index acf7e2d09b2b..d074ba957516 100644 --- a/test/parallel/test-crypto-dh-generate-keys.js +++ b/test/parallel/test-crypto-dh-generate-keys.js @@ -6,10 +6,11 @@ if (!common.hasCrypto) const assert = require('assert'); const crypto = require('crypto'); -const { hasOpenSSL3 } = require('../common/crypto'); +const { hasOpenSSL, hasFIPS } = require('../common/crypto'); { - const size = crypto.getFips() || hasOpenSSL3 ? 1024 : 256; + const size = hasFIPS(3) ? + 2048 : (crypto.getFips() === 1 || hasOpenSSL(3) ? 1024 : 256); function unlessInvalidState(f) { try { diff --git a/test/parallel/test-crypto-dh-leak.js b/test/parallel/test-crypto-dh-leak.js index df1ba89737c6..8d5141eef4b1 100644 --- a/test/parallel/test-crypto-dh-leak.js +++ b/test/parallel/test-crypto-dh-leak.js @@ -9,11 +9,12 @@ if (common.isASan) const assert = require('assert'); const crypto = require('crypto'); -const { hasOpenSSL3 } = require('../common/crypto'); +const { hasOpenSSL, hasFIPS } = require('../common/crypto'); const before = process.memoryUsage.rss(); { - const size = crypto.getFips() || hasOpenSSL3 ? 1024 : 256; + const size = hasFIPS(3) ? + 2048 : (crypto.getFips() === 1 || hasOpenSSL(3) ? 1024 : 256); const dh = crypto.createDiffieHellman(size); const publicKey = dh.generateKeys(); const privateKey = dh.getPrivateKey(); diff --git a/test/parallel/test-crypto-dh-modp2-views.js b/test/parallel/test-crypto-dh-modp2-views.js index a28e615b7f35..e32c515e1536 100644 --- a/test/parallel/test-crypto-dh-modp2-views.js +++ b/test/parallel/test-crypto-dh-modp2-views.js @@ -5,24 +5,30 @@ if (!common.hasCrypto) const assert = require('assert'); const crypto = require('crypto'); -const { modp2buf } = require('../common/crypto'); +const { hasFIPS, modp2buf } = require('../common/crypto'); if (process.features.openssl_is_boringssl) { common.skip('Skipping unsupported Diffie-Hellman tests'); } -const modp2 = crypto.createDiffieHellmanGroup('modp2'); +if (hasFIPS(3)) { + assert.throws(() => crypto.createDiffieHellman(1024), { + code: 'ERR_INVALID_ARG_VALUE', + }); +} else { + const modp2 = crypto.createDiffieHellmanGroup('modp2'); -const views = common.getArrayBufferViews(modp2buf); -for (const buf of [modp2buf, ...views]) { - // Ensure specific generator (string with encoding) works as expected with - // any ArrayBufferViews as the first argument to createDiffieHellman(). - const exmodp2 = crypto.createDiffieHellman(buf, '02', 'hex'); - modp2.generateKeys(); - exmodp2.generateKeys(); - const modp2Secret = modp2.computeSecret(exmodp2.getPublicKey()) - .toString('hex'); - const exmodp2Secret = exmodp2.computeSecret(modp2.getPublicKey()) - .toString('hex'); - assert.strictEqual(modp2Secret, exmodp2Secret); + const views = common.getArrayBufferViews(modp2buf); + for (const buf of [modp2buf, ...views]) { + // Ensure specific generator (string with encoding) works as expected with + // any ArrayBufferViews as the first argument to createDiffieHellman(). + const exmodp2 = crypto.createDiffieHellman(buf, '02', 'hex'); + modp2.generateKeys(); + exmodp2.generateKeys(); + const modp2Secret = modp2.computeSecret(exmodp2.getPublicKey()) + .toString('hex'); + const exmodp2Secret = exmodp2.computeSecret(modp2.getPublicKey()) + .toString('hex'); + assert.strictEqual(modp2Secret, exmodp2Secret); + } } diff --git a/test/parallel/test-crypto-dh-modp2.js b/test/parallel/test-crypto-dh-modp2.js index eb262f235ff3..0bf36f93a39a 100644 --- a/test/parallel/test-crypto-dh-modp2.js +++ b/test/parallel/test-crypto-dh-modp2.js @@ -5,44 +5,51 @@ if (!common.hasCrypto) const assert = require('assert'); const crypto = require('crypto'); -const { modp2buf } = require('../common/crypto'); +const { hasFIPS, modp2buf } = require('../common/crypto'); if (process.features.openssl_is_boringssl) { common.skip('Skipping unsupported Diffie-Hellman tests'); } -const modp2 = crypto.createDiffieHellmanGroup('modp2'); +if (hasFIPS(3)) { + assert.throws(() => crypto.createDiffieHellman(1024), { + code: 'ERR_INVALID_ARG_VALUE', + }); +} else { + const modp2 = crypto.createDiffieHellmanGroup('modp2'); -{ + { // Ensure specific generator (buffer) works as expected. - const exmodp2 = crypto.createDiffieHellman(modp2buf, Buffer.from([2])); - modp2.generateKeys(); - exmodp2.generateKeys(); - const modp2Secret = modp2.computeSecret(exmodp2.getPublicKey()) + const exmodp2 = crypto.createDiffieHellman(modp2buf, Buffer.from([2])); + modp2.generateKeys(); + exmodp2.generateKeys(); + const modp2Secret = modp2.computeSecret(exmodp2.getPublicKey()) .toString('hex'); - const exmodp2Secret = exmodp2.computeSecret(modp2.getPublicKey()) + const exmodp2Secret = exmodp2.computeSecret(modp2.getPublicKey()) .toString('hex'); - assert.strictEqual(modp2Secret, exmodp2Secret); -} + assert.strictEqual(modp2Secret, exmodp2Secret); + } -{ + { // Ensure specific generator (string without encoding) works as expected. - const exmodp2 = crypto.createDiffieHellman(modp2buf, '\x02'); - exmodp2.generateKeys(); - const modp2Secret = modp2.computeSecret(exmodp2.getPublicKey()) + const exmodp2 = crypto.createDiffieHellman(modp2buf, '\x02'); + exmodp2.generateKeys(); + const modp2Secret = modp2.computeSecret(exmodp2.getPublicKey()) .toString('hex'); - const exmodp2Secret = exmodp2.computeSecret(modp2.getPublicKey()) + const exmodp2Secret = exmodp2.computeSecret(modp2.getPublicKey()) .toString('hex'); - assert.strictEqual(modp2Secret, exmodp2Secret); -} + assert.strictEqual(modp2Secret, exmodp2Secret); + } -{ + { // Ensure specific generator (numeric) works as expected. - const exmodp2 = crypto.createDiffieHellman(modp2buf, 2); - exmodp2.generateKeys(); - const modp2Secret = modp2.computeSecret(exmodp2.getPublicKey()) + const exmodp2 = crypto.createDiffieHellman(modp2buf, 2); + exmodp2.generateKeys(); + const modp2Secret = modp2.computeSecret(exmodp2.getPublicKey()) .toString('hex'); - const exmodp2Secret = exmodp2.computeSecret(modp2.getPublicKey()) + const exmodp2Secret = exmodp2.computeSecret(modp2.getPublicKey()) .toString('hex'); - assert.strictEqual(modp2Secret, exmodp2Secret); + assert.strictEqual(modp2Secret, exmodp2Secret); + } + } diff --git a/test/parallel/test-crypto-dh-odd-key.js b/test/parallel/test-crypto-dh-odd-key.js index fbe42be425ed..c96227770e3d 100644 --- a/test/parallel/test-crypto-dh-odd-key.js +++ b/test/parallel/test-crypto-dh-odd-key.js @@ -27,19 +27,24 @@ if (!common.hasCrypto) { const assert = require('assert'); const crypto = require('crypto'); -const { hasOpenSSL3 } = require('../common/crypto'); +const { hasOpenSSL, hasFIPS } = require('../common/crypto'); function test() { const odd = Buffer.alloc(39, 'A'); - const c = crypto.createDiffieHellman(hasOpenSSL3 ? 1024 : 32); + const size = hasFIPS(3) ? 2048 : (hasOpenSSL(3) ? 1024 : 32); + const c = crypto.createDiffieHellman(size); c.setPrivateKey(odd); c.generateKeys(); } -// FIPS requires a length of at least 1024 -if (!crypto.getFips()) { +if (hasFIPS(3)) { + test(); + assert.throws(() => crypto.createDiffieHellman(1024), { + code: 'ERR_INVALID_ARG_VALUE', + }); +} else if (crypto.getFips() !== 1) { test(); } else { - assert.throws(function() { test(); }, /key size too small/); + assert.throws(test, /key size too small/); } diff --git a/test/parallel/test-crypto-dh-shared.js b/test/parallel/test-crypto-dh-shared.js index 515405034d76..5e6db278324d 100644 --- a/test/parallel/test-crypto-dh-shared.js +++ b/test/parallel/test-crypto-dh-shared.js @@ -5,9 +5,11 @@ if (!common.hasCrypto) const assert = require('assert'); const crypto = require('crypto'); +const { hasFIPS } = require('../common/crypto'); -const alice = crypto.createDiffieHellmanGroup('modp5'); -const bob = crypto.createDiffieHellmanGroup('modp5'); +const group = hasFIPS(3) ? 'modp14' : 'modp5'; +const alice = crypto.createDiffieHellmanGroup(group); +const bob = crypto.createDiffieHellmanGroup(group); alice.generateKeys(); bob.generateKeys(); const aSecret = alice.computeSecret(bob.getPublicKey()).toString('hex'); diff --git a/test/parallel/test-crypto-dh.js b/test/parallel/test-crypto-dh.js index 8a3dee5b0756..dc55c5226efb 100644 --- a/test/parallel/test-crypto-dh.js +++ b/test/parallel/test-crypto-dh.js @@ -7,11 +7,13 @@ if (!common.hasCrypto) { const assert = require('assert'); const crypto = require('crypto'); const { - hasOpenSSL3, + hasOpenSSL, + hasFIPS, } = require('../common/crypto'); { - const size = crypto.getFips() || hasOpenSSL3 ? 1024 : 256; + const size = hasFIPS(3) ? + 2048 : (crypto.getFips() === 1 || hasOpenSSL(3) ? 1024 : 256); const dh1 = crypto.createDiffieHellman(size); const p1 = dh1.getPrime('buffer'); const dh2 = crypto.createDiffieHellman(p1, 'buffer'); @@ -57,7 +59,7 @@ const { assert.strictEqual(secret1, secret4); let wrongBlockLength; - if (hasOpenSSL3) { + if (hasOpenSSL(3)) { wrongBlockLength = { message: /wrong[\s_]final[\s_]block[\s_]length/i, code: /ERR_OSSL_(EVP_)?WRONG_FINAL_BLOCK_LENGTH/, diff --git a/test/parallel/test-crypto-ecdh-convert-key.js b/test/parallel/test-crypto-ecdh-convert-key.js index c0046099df9e..8c910b1b6052 100644 --- a/test/parallel/test-crypto-ecdh-convert-key.js +++ b/test/parallel/test-crypto-ecdh-convert-key.js @@ -6,6 +6,7 @@ if (!common.hasCrypto) const assert = require('assert'); const { ECDH, createSign, getCurves } = require('crypto'); +const { hasFIPS } = require('../common/crypto'); // A valid private key for the secp256k1 curve. const cafebabeKey = 'cafebabe'.repeat(8); @@ -93,11 +94,17 @@ if (getCurves().includes('secp256k1')) { // Compare to getPublicKey. const ecdh1 = ECDH('secp256k1'); - ecdh1.generateKeys(); - ecdh1.setPrivateKey(cafebabeKey, 'hex'); - assert.strictEqual(ecdh1.getPublicKey('hex', 'uncompressed'), uncompressed); - assert.strictEqual(ecdh1.getPublicKey('hex', 'compressed'), compressed); - assert.strictEqual(ecdh1.getPublicKey('hex', 'hybrid'), hybrid); + if (hasFIPS(3)) { + assert.throws(() => ecdh1.generateKeys(), { + code: 'ERR_CRYPTO_OPERATION_FAILED', + }); + } else { + ecdh1.generateKeys(); + ecdh1.setPrivateKey(cafebabeKey, 'hex'); + assert.strictEqual(ecdh1.getPublicKey('hex', 'uncompressed'), uncompressed); + assert.strictEqual(ecdh1.getPublicKey('hex', 'compressed'), compressed); + assert.strictEqual(ecdh1.getPublicKey('hex', 'hybrid'), hybrid); + } } // See https://github.com/nodejs/node/issues/26133, failed ConvertKey diff --git a/test/parallel/test-crypto-eddsa-variants.js b/test/parallel/test-crypto-eddsa-variants.js index 691534f13f5b..3a75a7bf4f6f 100644 --- a/test/parallel/test-crypto-eddsa-variants.js +++ b/test/parallel/test-crypto-eddsa-variants.js @@ -7,6 +7,7 @@ const assert = require('assert'); const crypto = require('crypto'); const { hasOpenSSL, + hasFIPS, } = require('../common/crypto'); // RFC 8032 Section 7 test vectors for Ed25519, Ed25519ctx, and Ed448. @@ -191,7 +192,18 @@ for (const v of vectors) { const signKey = context ? { key: privateKey, context } : privateKey; const verifyKey = context ? { key: publicKey, context } : publicKey; - const sig = crypto.sign(null, message, signKey); + let sig; + try { + sig = crypto.sign(null, message, signKey); + } catch (err) { + if (!hasFIPS(3) || + (!v.algorithm.endsWith('ctx') && !v.context)) { + throw err; + } + assert.strictEqual( + err.code, 'ERR_OSSL_INVALID_EDDSA_INSTANCE_FOR_ATTEMPTED_OPERATION'); + continue; + } assert.deepStrictEqual(sig, expectedSig); assert.strictEqual( crypto.verify(null, message, verifyKey, expectedSig), true); diff --git a/test/parallel/test-crypto-encap-decap.js b/test/parallel/test-crypto-encap-decap.js index f2259194a9e1..7cafa0f2aa93 100644 --- a/test/parallel/test-crypto-encap-decap.js +++ b/test/parallel/test-crypto-encap-decap.js @@ -6,10 +6,11 @@ if (!common.hasCrypto) const assert = require('assert'); const crypto = require('crypto'); const fixtures = require('../common/fixtures'); -const { hasOpenSSL } = require('../common/crypto'); +const { hasOpenSSL, hasFIPS } = require('../common/crypto'); const { promisify } = require('util'); const isBoringSSL = process.features.openssl_is_boringssl; +const isFips = hasFIPS(3); if (!hasOpenSSL(3) && !isBoringSSL) { assert.throws(() => crypto.encapsulate(), { code: 'ERR_CRYPTO_KEM_NOT_SUPPORTED' }); @@ -36,7 +37,7 @@ const keys = { privateKey: fixtures.readKey('rsa_pss_private_2048.pem', 'ascii'), }, 'p-256': { - supported: hasOpenSSL(3, 2), // DHKEM was added in 3.2 + supported: hasOpenSSL(3, 2) && !isFips, // DHKEM was added in 3.2 publicKey: fixtures.readKey('ec_p256_public.pem', 'ascii'), privateKey: fixtures.readKey('ec_p256_private.pem', 'ascii'), sharedSecretLength: 32, @@ -44,7 +45,7 @@ const keys = { raw: true, }, 'p-384': { - supported: hasOpenSSL(3, 2), // DHKEM was added in 3.2 + supported: hasOpenSSL(3, 2) && !isFips, // DHKEM was added in 3.2 publicKey: fixtures.readKey('ec_p384_public.pem', 'ascii'), privateKey: fixtures.readKey('ec_p384_private.pem', 'ascii'), sharedSecretLength: 48, @@ -52,7 +53,7 @@ const keys = { raw: true, }, 'p-521': { - supported: hasOpenSSL(3, 2), // DHKEM was added in 3.2 + supported: hasOpenSSL(3, 2) && !isFips, // DHKEM was added in 3.2 publicKey: fixtures.readKey('ec_p521_public.pem', 'ascii'), privateKey: fixtures.readKey('ec_p521_private.pem', 'ascii'), sharedSecretLength: 64, @@ -65,7 +66,7 @@ const keys = { privateKey: fixtures.readKey('ec_secp256k1_private.pem', 'ascii'), }, 'x25519': { - supported: hasOpenSSL(3, 2), // DHKEM was added in 3.2 + supported: hasOpenSSL(3, 2) && !isFips, // DHKEM was added in 3.2 publicKey: fixtures.readKey('x25519_public.pem', 'ascii'), privateKey: fixtures.readKey('x25519_private.pem', 'ascii'), sharedSecretLength: 32, @@ -73,7 +74,7 @@ const keys = { raw: true, }, 'x448': { - supported: hasOpenSSL(3, 2), // DHKEM was added in 3.2 + supported: hasOpenSSL(3, 2) && !isFips, // DHKEM was added in 3.2 publicKey: fixtures.readKey('x448_public.pem', 'ascii'), privateKey: fixtures.readKey('x448_private.pem', 'ascii'), sharedSecretLength: 64, diff --git a/test/parallel/test-crypto-getcipherinfo.js b/test/parallel/test-crypto-getcipherinfo.js index d55985aa3c7f..4f9f2975ad6d 100644 --- a/test/parallel/test-crypto-getcipherinfo.js +++ b/test/parallel/test-crypto-getcipherinfo.js @@ -6,8 +6,9 @@ if (!common.hasCrypto) const { getCiphers, - getCipherInfo + getCipherInfo, } = require('crypto'); +const { hasFIPS } = require('../common/crypto'); const assert = require('assert'); @@ -76,7 +77,10 @@ if (!process.features.openssl_is_boringssl) { } assert(!getCipherInfo('aes-128-ocb', { ivLength: 16 })); -if (!process.features.openssl_is_boringssl) { +if (hasFIPS(3)) { + assert.strictEqual( + getCipherInfo('aes-128-ocb', { ivLength: 12 }), undefined); +} else if (!process.features.openssl_is_boringssl) { for (let n = 1; n < 16; n++) assert(getCipherInfo('aes-128-ocb', { ivLength: n })); } else { diff --git a/test/parallel/test-crypto-hkdf.js b/test/parallel/test-crypto-hkdf.js index 242e278707d7..bfde3b324331 100644 --- a/test/parallel/test-crypto-hkdf.js +++ b/test/parallel/test-crypto-hkdf.js @@ -13,7 +13,7 @@ const { hkdfSync, getHashes } = require('crypto'); -const { hasOpenSSL3 } = require('../common/crypto'); +const { hasOpenSSL } = require('../common/crypto'); { assert.throws(() => hkdf(), { @@ -120,12 +120,12 @@ const { hasOpenSSL3 } = require('../common/crypto'); } const algorithms = [ - ['sha256', 'secret', 'salt', 'info', 10], + ['sha256', '0123456789abcdef', '0123456789abcdef', 'info', 10], ['sha256', '', '', '', 10], ['sha256', '', 'salt', '', 10], ['sha512', 'secret', 'salt', '', 15], ]; -if (!hasOpenSSL3 && !process.features.openssl_is_boringssl) +if (!hasOpenSSL(3) && !process.features.openssl_is_boringssl) algorithms.push(['whirlpool', 'secret', '', 'info', 20]); algorithms.forEach(([ hash, secret, salt, info, length ]) => { @@ -216,7 +216,7 @@ algorithms.forEach(([ hash, secret, salt, info, length ]) => { }); -if (!hasOpenSSL3) { +if (!hasOpenSSL(3)) { const kKnownUnsupported = ['shake128', 'shake256']; for (const hash of getHashes()) { if (kKnownUnsupported.includes(hash)) continue; diff --git a/test/parallel/test-crypto-hmac.js b/test/parallel/test-crypto-hmac.js index 48857c7cf027..29740a2fc247 100644 --- a/test/parallel/test-crypto-hmac.js +++ b/test/parallel/test-crypto-hmac.js @@ -6,6 +6,9 @@ if (!common.hasCrypto) { const assert = require('assert'); const crypto = require('crypto'); +const { hasFIPS } = require('../common/crypto'); + +const fips3 = hasFIPS(3); { const Hmac = crypto.Hmac; @@ -24,7 +27,7 @@ assert.throws( // This used to segfault. See: https://github.com/nodejs/node/issues/9819 assert.throws( - () => crypto.createHmac('sha256', 'key').digest({ + () => crypto.createHmac('sha256', '0123456789abcdef').digest({ toString: () => { throw new Error('boom'); }, }), { @@ -40,9 +43,14 @@ assert.throws( }); function testHmac(algo, key, data, expected) { - // FIPS does not support MD5. - if (crypto.getFips() && algo === 'md5') + if (crypto.getFips() === 1 && algo === 'md5') { + if (fips3) { + assert.throws(() => crypto.createHmac(algo, Buffer.alloc(32)), { + code: 'ERR_OSSL_EVP_UNSUPPORTED', + }); + } return; + } if (!Array.isArray(data)) data = [data]; @@ -70,12 +78,13 @@ function testHmac(algo, key, data, expected) { { // Historically, dss1 and DSS1 are SHA-1 aliases. + const key = '0123456789abcdef'; const expected = - crypto.createHmac('sha1', 'key').update('data').digest('hex'); + crypto.createHmac('sha1', key).update('data').digest('hex'); for (const algo of ['dss1', 'DSS1']) { assert.strictEqual( - crypto.createHmac(algo, 'key').update('data').digest('hex'), + crypto.createHmac(algo, key).update('data').digest('hex'), expected); } } @@ -419,9 +428,12 @@ const rfc2202_sha1 = [ for (const { key, data, hmac } of rfc2202_sha1) testHmac('sha1', key, data, hmac); -assert.strictEqual( - crypto.createHmac('sha256', 'w00t').digest('ucs2'), - crypto.createHmac('sha256', 'w00t').digest().toString('ucs2')); +{ + const key = '0123456789abcdef'; + assert.strictEqual( + crypto.createHmac('sha256', key).digest('ucs2'), + crypto.createHmac('sha256', key).digest().toString('ucs2')); +} // Check initialized -> uninitialized state transition after calling digest(). { @@ -460,7 +472,7 @@ assert.strictEqual( { assert.throws( - () => crypto.createHmac('sha7', 'key'), + () => crypto.createHmac('sha7', '0123456789abcdef'), /Invalid digest/); } diff --git a/test/parallel/test-crypto-job-error-parity.js b/test/parallel/test-crypto-job-error-parity.js index f10c910ff67b..ee9bd27bba6b 100644 --- a/test/parallel/test-crypto-job-error-parity.js +++ b/test/parallel/test-crypto-job-error-parity.js @@ -8,11 +8,13 @@ const common = require('../common'); if (!common.hasCrypto) common.skip('missing crypto'); -const { hasOpenSSL } = require('../common/crypto'); +const { hasOpenSSL, hasFIPS } = require('../common/crypto'); const assert = require('assert'); const crypto = require('crypto'); const fixtures = require('../common/fixtures'); +const fips3 = hasFIPS(3); + function getError(fn) { let err; assert.throws(fn, (e) => { err = e; return true; }); @@ -69,14 +71,26 @@ const data = Buffer.from('test data'); // Sign: RSA key too small for digest (OpenSSL error) { const { privateKey } = crypto.generateKeyPairSync('rsa', { - modulusLength: 512, + modulusLength: fips3 ? 2048 : 512, }); + const key = fips3 ? { + key: privateKey, + padding: crypto.constants.RSA_PKCS1_PSS_PADDING, + saltLength: 256, + } : privateKey; + const digest = fips3 ? 'sha256' : 'sha512'; + + const syncErr = getError(() => crypto.sign(digest, data, key)); + if (fips3) { + assert.strictEqual(syncErr.code, + 'ERR_OSSL_RSA_DATA_TOO_LARGE_FOR_KEY_SIZE'); + } else { + assert.match( + syncErr.message, /digest[\s_]too[\s_]big[\s_]for[\s_]rsa[\s_]key/i); + } - const syncErr = getError(() => crypto.sign('sha512', data, privateKey)); - assert.match(syncErr.message, /digest[\s_]too[\s_]big[\s_]for[\s_]rsa[\s_]key/i); - - crypto.sign('sha512', data, privateKey, common.mustCall((asyncErr) => { - assertErrorMatch(syncErr, asyncErr, 'sign: RSA 512 + sha512'); + crypto.sign(digest, data, key, common.mustCall((asyncErr) => { + assertErrorMatch(syncErr, asyncErr, 'sign: RSA policy error'); })); } @@ -160,7 +174,9 @@ const data = Buffer.from('test data'); // DH: Mismatched DH group params (OpenSSL error) { - const alice = crypto.generateKeyPairSync('dh', { group: 'modp5' }); + const alice = crypto.generateKeyPairSync('dh', { + group: fips3 ? 'modp14' : 'modp5', + }); const bob = crypto.generateKeyPairSync('dh', { group: 'modp18' }); const syncErr = getError(() => diff --git a/test/parallel/test-crypto-key-objects-messageport.js b/test/parallel/test-crypto-key-objects-messageport.js index d23fde0d00d7..a379b84185ce 100644 --- a/test/parallel/test-crypto-key-objects-messageport.js +++ b/test/parallel/test-crypto-key-objects-messageport.js @@ -9,6 +9,7 @@ const { generateKeyPairSync, KeyObject, } = require('crypto'); +const { hasFIPS } = require('../common/crypto'); const { subtle } = globalThis.crypto; const { createContext } = require('vm'); const { @@ -45,7 +46,7 @@ process.env.HAS_STARTED_WORKER = 1; // The main thread generates keys and passes them to worker threads. const secretKey = generateKeySync('aes', { length: 128 }); const { publicKey, privateKey } = generateKeyPairSync('rsa', { - modulusLength: 1024 + modulusLength: hasFIPS(3) ? 2048 : 1024 }); const cryptoKey = await subtle.generateKey( { name: 'AES-CBC', length: 128 }, false, ['encrypt']); diff --git a/test/parallel/test-crypto-key-objects-to-crypto-key.js b/test/parallel/test-crypto-key-objects-to-crypto-key.js index a498df144f33..32228e7c8a06 100644 --- a/test/parallel/test-crypto-key-objects-to-crypto-key.js +++ b/test/parallel/test-crypto-key-objects-to-crypto-key.js @@ -12,7 +12,9 @@ const { randomBytes, generateKeyPairSync, } = require('crypto'); +const { hasFIPS } = require('../common/crypto'); const { kSupportedAlgorithms } = require('internal/crypto/util'); +const rejectsXCurves = hasFIPS(3, 5); const hashes = Object.keys(kSupportedAlgorithms.digest).filter((name) => { return name.startsWith('SHA-') || name.startsWith('SHA3-'); @@ -232,6 +234,13 @@ function ecVectors(name, usagesByType) { } function cfrgVectors(name, usagesByType) { + if (rejectsXCurves && name.startsWith('X')) { + assert.throws(() => generateKeyPairSync(name.toLowerCase()), { + code: 'ERR_OSSL_EVP_UNSUPPORTED', + }); + return []; + } + const keyPair = generateKeyPairSync(name.toLowerCase()); return asymmetricVectors(keyPair, name, usagesByType); } @@ -317,9 +326,11 @@ const invalid = { 'HMAC': () => macInvalid( { name: 'HMAC', hash: 'SHA-256' }, 'HmacImportParams.length cannot be 0'), - 'X25519': () => invalidAsymmetricKeyType('X25519', 'Ed25519'), }; +if (!rejectsXCurves) + invalid.X25519 = () => invalidAsymmetricKeyType('X25519', 'Ed25519'); + for (const name of ['AES-CBC', 'AES-CTR', 'AES-GCM', 'AES-OCB']) { if (name in kSupportedAlgorithms.importKey) tests[name] = symmetricVectors(name, ['encrypt', 'decrypt']); @@ -350,9 +361,11 @@ for (const [name, usages, invalidAlgorithm] of [ ]) { if (name in kSupportedAlgorithms.importKey) { tests[name] = cfrgVectors(name, usages); - invalid[name] = () => { - invalidAsymmetricKeyType(name, invalidAlgorithm); - }; + if (!rejectsXCurves) { + invalid[name] = () => { + invalidAsymmetricKeyType(name, invalidAlgorithm); + }; + } } } diff --git a/test/parallel/test-crypto-key-objects.js b/test/parallel/test-crypto-key-objects.js index dadeb780cf6c..ba9a05387f21 100644 --- a/test/parallel/test-crypto-key-objects.js +++ b/test/parallel/test-crypto-key-objects.js @@ -24,7 +24,17 @@ const { generateKeyPairSync, } = require('crypto'); -const { hasOpenSSL3 } = require('../common/crypto'); +const { + hasOpenSSL, + hasFIPS, +} = require('../common/crypto'); + +const fips3 = hasFIPS(3); +const fips35 = hasFIPS(3, 5); +const fips30 = fips3 && !fips35; +const fips4 = hasFIPS(4); +const rejectsXCurves = fips35; +const fipsDigestErrorCode = 'ERR_OSSL_DIGEST_NOT_ALLOWED'; const fixtures = require('../common/fixtures'); @@ -201,15 +211,26 @@ const privateDsa = fixtures.readKey('dsa_private_encrypted_1025.pem', // It should also be possible to import an encrypted private key as a public // key. + const passphrase = 'password'; + if (fips4) { + assert.throws(() => privateKey.export({ + type: 'pkcs8', + format: 'pem', + passphrase: '123', + cipher: 'aes-128-cbc' + }), { + code: 'ERR_OSSL_PASSWORD_STRENGTH_TOO_WEAK', + }); + } const decryptedKey = createPublicKey({ key: privateKey.export({ type: 'pkcs8', format: 'pem', - passphrase: '123', + passphrase, cipher: 'aes-128-cbc' }), format: 'pem', - passphrase: '123' + passphrase }); assert.strictEqual(decryptedKey.type, 'public'); assert.strictEqual(decryptedKey.asymmetricKeyType, 'rsa'); @@ -328,7 +349,7 @@ const privateDsa = fixtures.readKey('dsa_private_encrypted_1025.pem', // This should not cause a crash: https://github.com/nodejs/node/issues/25247 assert.throws(() => { createPrivateKey({ key: '' }); - }, hasOpenSSL3 ? { + }, hasOpenSSL(3) ? { message: 'error:1E08010C:DECODER routines::unsupported', } : process.features.openssl_is_boringssl ? { message: 'error:0900006e:PEM routines:OPENSSL_internal:NO_START_LINE', @@ -360,7 +381,7 @@ const privateDsa = fixtures.readKey('dsa_private_encrypted_1025.pem', type: 'pkcs1' }); createPrivateKey({ key, format: 'der', type: 'pkcs1' }); - }, hasOpenSSL3 ? { + }, hasOpenSSL(3) ? { message: /error:1E08010C:DECODER routines::unsupported/, library: 'DECODER routines' } : process.features.openssl_is_boringssl ? { @@ -415,6 +436,8 @@ for (const info of [ } }, ]) { const keyType = info.keyType; + const fipsUnsupported = + rejectsXCurves && keyType.startsWith('x'); if (process.features.openssl_is_boringssl && keyType.endsWith('448')) { common.printSkipMessage(`Skipping unsupported ${keyType} test case`); @@ -433,19 +456,32 @@ for (const info of [ } { - const key = createPrivateKey({ key: info.jwk, format: 'jwk' }); - assert.strictEqual(key.type, 'private'); - assert.strictEqual(key.asymmetricKeyType, keyType); - assert.strictEqual(key.symmetricKeySize, undefined); - assert.strictEqual( - key.export({ type: 'pkcs8', format: 'pem' }), info.private); - assert.deepStrictEqual( - key.export({ format: 'jwk' }), info.jwk); + if (fipsUnsupported) { + assert.throws( + () => createPrivateKey({ key: info.jwk, format: 'jwk' }), + { code: 'ERR_CRYPTO_INVALID_JWK' }); + } else { + const key = createPrivateKey({ key: info.jwk, format: 'jwk' }); + assert.strictEqual(key.type, 'private'); + assert.strictEqual(key.asymmetricKeyType, keyType); + assert.strictEqual(key.symmetricKeySize, undefined); + assert.strictEqual( + key.export({ type: 'pkcs8', format: 'pem' }), info.private); + assert.deepStrictEqual( + key.export({ format: 'jwk' }), info.jwk); + } } { - for (const input of [ - info.private, info.public, { key: info.jwk, format: 'jwk' }]) { + const inputs = [info.private, info.public]; + if (fipsUnsupported) { + assert.throws( + () => createPublicKey({ key: info.jwk, format: 'jwk' }), + { code: 'ERR_CRYPTO_INVALID_JWK' }); + } else { + inputs.push({ key: info.jwk, format: 'jwk' }); + } + for (const input of inputs) { const key = createPublicKey(input); assert.strictEqual(key.type, 'public'); assert.strictEqual(key.asymmetricKeyType, keyType); @@ -469,21 +505,32 @@ for (const info of [ assert(Buffer.isBuffer(rawPriv)); assert(Buffer.isBuffer(rawPub)); - const importedPriv = createPrivateKey({ + const privateOptions = { key: rawPriv, format: 'raw-private', asymmetricKeyType: keyType, - }); - assert.strictEqual(importedPriv.type, 'private'); - assert.strictEqual(importedPriv.asymmetricKeyType, keyType); - assert.deepStrictEqual( - importedPriv.export({ format: 'raw-private' }), rawPriv); - - const importedPub = createPublicKey({ + }; + const publicOptions = { key: rawPub, format: 'raw-public', asymmetricKeyType: keyType, - }); - assert.strictEqual(importedPub.type, 'public'); - assert.strictEqual(importedPub.asymmetricKeyType, keyType); - assert.deepStrictEqual( - importedPub.export({ format: 'raw-public' }), rawPub); + }; + if (fipsUnsupported) { + assert.throws( + () => createPrivateKey(privateOptions), + { code: 'ERR_INVALID_ARG_VALUE' }); + assert.throws( + () => createPublicKey(publicOptions), + { code: 'ERR_INVALID_ARG_VALUE' }); + } else { + const importedPriv = createPrivateKey(privateOptions); + assert.strictEqual(importedPriv.type, 'private'); + assert.strictEqual(importedPriv.asymmetricKeyType, keyType); + assert.deepStrictEqual( + importedPriv.export({ format: 'raw-private' }), rawPriv); + + const importedPub = createPublicKey(publicOptions); + assert.strictEqual(importedPub.type, 'public'); + assert.strictEqual(importedPub.asymmetricKeyType, keyType); + assert.deepStrictEqual( + importedPub.export({ format: 'raw-public' }), rawPub); + } } } @@ -582,6 +629,7 @@ for (const info of [ } }, ]) { const { keyType, namedCurve } = info; + const fipsUnsupported = fips3 && namedCurve === 'secp256k1'; if (process.features.openssl_is_boringssl && !getCurves().includes(namedCurve)) { common.printSkipMessage(`Skipping unsupported ${keyType} test case`); @@ -601,20 +649,33 @@ for (const info of [ } { - const key = createPrivateKey({ key: info.jwk, format: 'jwk' }); - assert.strictEqual(key.type, 'private'); - assert.strictEqual(key.asymmetricKeyType, keyType); - assert.deepStrictEqual(key.asymmetricKeyDetails, { namedCurve }); - assert.strictEqual(key.symmetricKeySize, undefined); - assert.strictEqual( - key.export({ type: 'pkcs8', format: 'pem' }), info.private); - assert.deepStrictEqual( - key.export({ format: 'jwk' }), info.jwk); + if (fipsUnsupported) { + assert.throws( + () => createPrivateKey({ key: info.jwk, format: 'jwk' }), + { code: 'ERR_CRYPTO_INVALID_JWK' }); + } else { + const key = createPrivateKey({ key: info.jwk, format: 'jwk' }); + assert.strictEqual(key.type, 'private'); + assert.strictEqual(key.asymmetricKeyType, keyType); + assert.deepStrictEqual(key.asymmetricKeyDetails, { namedCurve }); + assert.strictEqual(key.symmetricKeySize, undefined); + assert.strictEqual( + key.export({ type: 'pkcs8', format: 'pem' }), info.private); + assert.deepStrictEqual( + key.export({ format: 'jwk' }), info.jwk); + } } { - for (const input of [ - info.private, info.public, { key: info.jwk, format: 'jwk' }]) { + const inputs = [info.private, info.public]; + if (fipsUnsupported) { + assert.throws( + () => createPublicKey({ key: info.jwk, format: 'jwk' }), + { code: 'ERR_CRYPTO_INVALID_JWK' }); + } else { + inputs.push({ key: info.jwk, format: 'jwk' }); + } + for (const input of inputs) { const key = createPublicKey(input); assert.strictEqual(key.type, 'public'); assert.strictEqual(key.asymmetricKeyType, keyType); @@ -649,23 +710,35 @@ for (const info of [ assert(Buffer.isBuffer(rawPriv)); assert(Buffer.isBuffer(rawPub)); - const importedPriv = createPrivateKey({ + const privateOptions = { key: rawPriv, format: 'raw-private', asymmetricKeyType: keyType, namedCurve, - }); - assert.strictEqual(importedPriv.type, 'private'); - assert.strictEqual(importedPriv.asymmetricKeyType, keyType); - assert.deepStrictEqual( - importedPriv.export({ format: 'raw-private' }), rawPriv); + }; - const importedPub = createPublicKey({ + const publicOptions = { key: rawPub, format: 'raw-public', asymmetricKeyType: keyType, namedCurve, - }); - assert.strictEqual(importedPub.type, 'public'); - assert.strictEqual(importedPub.asymmetricKeyType, keyType); - assert.deepStrictEqual( - importedPub.export({ format: 'raw-public' }), rawPub); + }; + if (fipsUnsupported) { + assert.throws( + () => createPrivateKey(privateOptions), + { code: 'ERR_INVALID_ARG_VALUE' }); + assert.throws( + () => createPublicKey(publicOptions), + { code: 'ERR_INVALID_ARG_VALUE' }); + } else { + const importedPriv = createPrivateKey(privateOptions); + assert.strictEqual(importedPriv.type, 'private'); + assert.strictEqual(importedPriv.asymmetricKeyType, keyType); + assert.deepStrictEqual( + importedPriv.export({ format: 'raw-private' }), rawPriv); + + const importedPub = createPublicKey(publicOptions); + assert.strictEqual(importedPub.type, 'public'); + assert.strictEqual(importedPub.asymmetricKeyType, keyType); + assert.deepStrictEqual( + importedPub.export({ format: 'raw-public' }), rawPub); + } } } @@ -717,7 +790,7 @@ for (const info of [ { // Reading an encrypted key without a passphrase should fail. - assert.throws(() => createPrivateKey(privateDsa), hasOpenSSL3 ? { + assert.throws(() => createPrivateKey(privateDsa), hasOpenSSL(3) ? { name: 'Error', message: 'error:07880109:common libcrypto routines::interrupted or ' + 'cancelled', @@ -733,7 +806,7 @@ for (const info of [ key: privateDsa, format: 'pem', passphrase: Buffer.alloc(1025, 'a') - }), hasOpenSSL3 ? { name: 'Error' } : { + }), hasOpenSSL(3) ? { name: 'Error' } : { code: 'ERR_OSSL_PEM_BAD_PASSWORD_READ', name: 'Error' }); @@ -744,8 +817,10 @@ for (const info of [ key: privateDsa, format: 'pem', passphrase: Buffer.alloc(1024, 'a') - }), { - message: /bad decrypt|BAD_DECRYPT/ + }), fips4 ? { + code: 'ERR_OSSL_INVALID_SALT_LENGTH', + } : { + message: /bad decrypt|BAD_DECRYPT/, }); const publicKey = createPublicKey(publicDsa); @@ -756,11 +831,28 @@ for (const info of [ () => publicKey.export({ format: 'jwk' }), { code: 'ERR_CRYPTO_JWK_UNSUPPORTED_KEY_TYPE' }); - const privateKey = createPrivateKey({ - key: privateDsa, + const privateKeyData = + createPrivateKey(fixtures.readKey('dsa_private.pem')).export({ + type: 'pkcs8', + format: 'pem', + cipher: 'aes-256-cbc', + passphrase: 'password', + }); + const privateKeyOptions = { + key: privateKeyData, format: 'pem', - passphrase: 'secret' - }); + passphrase: 'password', + }; + if (fips4) { + assert.throws(() => createPrivateKey({ + key: privateDsa, + format: 'pem', + passphrase: 'secret', + }), { + code: 'ERR_OSSL_PASSWORD_STRENGTH_TOO_WEAK', + }); + } + const privateKey = createPrivateKey(privateKeyOptions); assert.strictEqual(privateKey.type, 'private'); assert.strictEqual(privateKey.asymmetricKeyType, 'dsa'); assert.strictEqual(privateKey.symmetricKeySize, undefined); @@ -804,20 +896,27 @@ if (!process.features.openssl_is_boringssl) { { code: 'ERR_CRYPTO_JWK_UNSUPPORTED_KEY_TYPE' }); for (const key of [privatePem, privateKey]) { - // Any algorithm should work. - for (const algo of ['sha1', 'sha256']) { - // Any salt length should work. - for (const saltLength of [undefined, 8, 10, 12, 16, 18, 20]) { - const signature = createSign(algo) - .update('foo') - .sign({ key, saltLength }); - - for (const pkey of [key, publicKey, publicPem]) { - const okay = createVerify(algo) - .update('foo') - .verify({ key: pkey, saltLength }, signature); - - assert.ok(okay); + if (fips30) { + // With no explicit parameters, this key defaults to SHA-1 for PSS. + assert.throws(() => createSign('sha256').update('foo').sign(key), { + code: fipsDigestErrorCode, + }); + } else { + // Any algorithm should work. + for (const algo of ['sha1', 'sha256']) { + // Any salt length should work. + for (const saltLength of [undefined, 8, 10, 12, 16, 18, 20]) { + const signature = createSign(algo) + .update('foo') + .sign({ key, saltLength }); + + for (const pkey of [key, publicKey, publicPem]) { + const okay = createVerify(algo) + .update('foo') + .verify({ key: pkey, saltLength }, signature); + + assert.ok(okay); + } } } } @@ -885,12 +984,15 @@ if (!process.features.openssl_is_boringssl) { // Signing with anything other than sha256 should fail. assert.throws(() => { createSign('sha1').sign(key); - }, /digest not allowed/); + }, fips30 ? { + code: fipsDigestErrorCode, + } : /digest not allowed/); // Signing with salt lengths less than 16 bytes should fail. for (const saltLength of [8, 10, 12]) { assert.throws(() => { - createSign('sha1').sign({ key, saltLength }); + createSign(fips3 ? 'sha256' : 'sha1') + .sign({ key, saltLength }); }, /pss saltlen too small/); } @@ -983,7 +1085,9 @@ if (!process.features.openssl_is_boringssl) { for (const algo of ['sha1', 'sha256']) { assert.throws(() => { createSign(algo).sign(key); - }, /digest not allowed/); + }, fips30 && algo === 'sha1' ? { + code: fipsDigestErrorCode, + } : /digest not allowed/); } // sha512 should produce a valid signature. @@ -1046,20 +1150,26 @@ if (!process.features.openssl_is_boringssl) { // provider is currently in use. const namedCurve = getCurves().find((curve) => !supported.includes(curve)); assert(namedCurve); - const keyPair = generateKeyPairSync('ec', { namedCurve }); - const { publicKey, privateKey } = keyPair; - assert.throws( - () => publicKey.export({ format: 'jwk' }), - { - code: 'ERR_CRYPTO_JWK_UNSUPPORTED_CURVE', - message: `Unsupported JWK EC curve: ${namedCurve}.` - }); - assert.throws( - () => privateKey.export({ format: 'jwk' }), - { - code: 'ERR_CRYPTO_JWK_UNSUPPORTED_CURVE', - message: `Unsupported JWK EC curve: ${namedCurve}.` + if (fips3) { + assert.throws(() => generateKeyPairSync('ec', { namedCurve }), { + code: 'ERR_OSSL_EC_UNKNOWN_GROUP', }); + } else { + const keyPair = generateKeyPairSync('ec', { namedCurve }); + const { publicKey, privateKey } = keyPair; + assert.throws( + () => publicKey.export({ format: 'jwk' }), + { + code: 'ERR_CRYPTO_JWK_UNSUPPORTED_CURVE', + message: `Unsupported JWK EC curve: ${namedCurve}.` + }); + assert.throws( + () => privateKey.export({ format: 'jwk' }), + { + code: 'ERR_CRYPTO_JWK_UNSUPPORTED_CURVE', + message: `Unsupported JWK EC curve: ${namedCurve}.` + }); + } } { @@ -1102,12 +1212,18 @@ if (!process.features.openssl_is_boringssl) { { const first = generateKeyPairSync('ed25519'); - const second = generateKeyPairSync('x25519'); + if (rejectsXCurves) { + assert.throws(() => generateKeyPairSync('x25519'), { + code: 'ERR_OSSL_EVP_UNSUPPORTED', + }); + } else { + const second = generateKeyPairSync('x25519'); - assert(!first.publicKey.equals(second.publicKey)); - assert(!first.publicKey.equals(second.privateKey)); - assert(!first.privateKey.equals(second.privateKey)); - assert(!first.privateKey.equals(second.publicKey)); + assert(!first.publicKey.equals(second.publicKey)); + assert(!first.publicKey.equals(second.privateKey)); + assert(!first.privateKey.equals(second.privateKey)); + assert(!first.privateKey.equals(second.publicKey)); + } } { diff --git a/test/parallel/test-crypto-key-store.js b/test/parallel/test-crypto-key-store.js index b6f012416673..58a23192b28f 100644 --- a/test/parallel/test-crypto-key-store.js +++ b/test/parallel/test-crypto-key-store.js @@ -2,7 +2,7 @@ const common = require('../common'); if (!common.hasCrypto) common.skip('missing crypto'); -const { hasOpenSSL } = require('../common/crypto'); +const { hasFIPS, hasOpenSSL } = require('../common/crypto'); if (!hasOpenSSL(3)) common.skip('requires OpenSSL 3.x'); @@ -84,26 +84,32 @@ const data = Buffer.from('hello store'); } { - const alice = generateKeyPairSync('x25519'); - const bob = generateKeyPairSync('x25519'); - const file = path.join(tmpdir.path, 'x25519.pem'); - fs.writeFileSync(file, alice.privateKey.export({ - format: 'pem', - type: 'pkcs8', - })); - const url = pathToFileURL(file); + if (hasFIPS(3, 5)) { + assert.throws(() => generateKeyPairSync('x25519'), { + code: 'ERR_OSSL_EVP_UNSUPPORTED', + }); + } else { + const alice = generateKeyPairSync('x25519'); + const bob = generateKeyPairSync('x25519'); + const file = path.join(tmpdir.path, 'x25519.pem'); + fs.writeFileSync(file, alice.privateKey.export({ + format: 'pem', + type: 'pkcs8', + })); + const url = pathToFileURL(file); - const expected = diffieHellman({ - privateKey: alice.privateKey, - publicKey: bob.publicKey, - }); - assert.deepStrictEqual( - diffieHellman({ privateKey: url, publicKey: bob.publicKey }), - expected); + const expected = diffieHellman({ + privateKey: alice.privateKey, + publicKey: bob.publicKey, + }); + assert.deepStrictEqual( + diffieHellman({ privateKey: url, publicKey: bob.publicKey }), + expected); - if (hasOpenSSL(3, 2)) { - const { sharedKey, ciphertext } = encapsulate(alice.publicKey); - assert.deepStrictEqual(decapsulate(url, ciphertext), sharedKey); + if (hasOpenSSL(3, 2)) { + const { sharedKey, ciphertext } = encapsulate(alice.publicKey); + assert.deepStrictEqual(decapsulate(url, ciphertext), sharedKey); + } } } diff --git a/test/parallel/test-crypto-keygen-async-dsa.js b/test/parallel/test-crypto-keygen-async-dsa.js index d7c857d35e21..6c7129c7efba 100644 --- a/test/parallel/test-crypto-keygen-async-dsa.js +++ b/test/parallel/test-crypto-keygen-async-dsa.js @@ -17,7 +17,7 @@ const { spkiExp, } = require('../common/crypto'); -const { hasOpenSSL3 } = require('../common/crypto'); +const { hasOpenSSL } = require('../common/crypto'); // Test async DSA key generation. { @@ -27,7 +27,7 @@ const { hasOpenSSL3 } = require('../common/crypto'); }; generateKeyPair('dsa', { - modulusLength: hasOpenSSL3 ? 2048 : 512, + modulusLength: hasOpenSSL(3) ? 2048 : 512, divisorLength: 256, publicKeyEncoding: { type: 'spki', @@ -35,7 +35,7 @@ const { hasOpenSSL3 } = require('../common/crypto'); }, privateKeyEncoding: { cipher: 'aes-128-cbc', - passphrase: 'secret', + passphrase: 'password', ...privateKeyEncoding } }, common.mustSucceed((publicKey, privateKeyDER) => { @@ -44,8 +44,8 @@ const { hasOpenSSL3 } = require('../common/crypto'); // The private key is DER-encoded. assert(Buffer.isBuffer(privateKeyDER)); - assertApproximateSize(publicKey, hasOpenSSL3 ? 1194 : 440); - assertApproximateSize(privateKeyDER, hasOpenSSL3 ? 721 : 336); + assertApproximateSize(publicKey, hasOpenSSL(3) ? 1194 : 440); + assertApproximateSize(privateKeyDER, hasOpenSSL(3) ? 721 : 336); // Since the private key is encrypted, signing shouldn't work anymore. assert.throws(() => { @@ -63,7 +63,7 @@ const { hasOpenSSL3 } = require('../common/crypto'); testSignVerify(publicKey, { key: privateKeyDER, ...privateKeyEncoding, - passphrase: 'secret' + passphrase: 'password' }); })); } diff --git a/test/parallel/test-crypto-keygen-async-elliptic-curve-jwk-ec.js b/test/parallel/test-crypto-keygen-async-elliptic-curve-jwk-ec.js index b0945dcc83a2..bde6dc1d694b 100644 --- a/test/parallel/test-crypto-keygen-async-elliptic-curve-jwk-ec.js +++ b/test/parallel/test-crypto-keygen-async-elliptic-curve-jwk-ec.js @@ -8,6 +8,7 @@ const assert = require('assert'); const { generateKeyPair, } = require('crypto'); +const { hasFIPS } = require('../common/crypto'); // Test async elliptic curve key generation with 'jwk' encoding and named // curve. @@ -24,7 +25,12 @@ for (const curve of ['P-384', 'P-256', 'P-521', 'secp256k1']) { privateKeyEncoding: { format: 'jwk' } - }, common.mustSucceed((publicKey, privateKey) => { + }, common.mustCall((err, publicKey, privateKey) => { + if (hasFIPS(3) && curve === 'secp256k1') { + assert.strictEqual(err?.code, 'ERR_OSSL_EC_UNKNOWN_GROUP'); + return; + } + assert.ifError(err); assert.strictEqual(typeof publicKey, 'object'); assert.strictEqual(typeof privateKey, 'object'); assert.strictEqual(publicKey.x, privateKey.x); diff --git a/test/parallel/test-crypto-keygen-async-elliptic-curve-jwk-rsa.js b/test/parallel/test-crypto-keygen-async-elliptic-curve-jwk-rsa.js index 449d1a97f9f6..f1a1bdf8322f 100644 --- a/test/parallel/test-crypto-keygen-async-elliptic-curve-jwk-rsa.js +++ b/test/parallel/test-crypto-keygen-async-elliptic-curve-jwk-rsa.js @@ -8,11 +8,12 @@ const assert = require('assert'); const { generateKeyPair, } = require('crypto'); +const { hasFIPS } = require('../common/crypto'); // Test async elliptic curve key generation with 'jwk' encoding and RSA. { generateKeyPair('rsa', { - modulusLength: 1024, + modulusLength: hasFIPS(3) ? 2048 : 1024, publicKeyEncoding: { format: 'jwk' }, diff --git a/test/parallel/test-crypto-keygen-async-elliptic-curve-jwk.js b/test/parallel/test-crypto-keygen-async-elliptic-curve-jwk.js index 731960b0d56a..cfef18c889b6 100644 --- a/test/parallel/test-crypto-keygen-async-elliptic-curve-jwk.js +++ b/test/parallel/test-crypto-keygen-async-elliptic-curve-jwk.js @@ -8,6 +8,8 @@ const assert = require('assert'); const { generateKeyPair, } = require('crypto'); +const { hasFIPS } = require('../common/crypto'); +const rejectsXCurves = hasFIPS(3, 5); // Test async elliptic curve key generation with 'jwk' encoding. { @@ -23,7 +25,12 @@ const { privateKeyEncoding: { format: 'jwk' } - }, common.mustSucceed((publicKey, privateKey) => { + }, common.mustCall((err, publicKey, privateKey) => { + if (rejectsXCurves && type.startsWith('x')) { + assert.strictEqual(err?.code, 'ERR_OSSL_EVP_UNSUPPORTED'); + return; + } + assert.ifError(err); assert.strictEqual(typeof publicKey, 'object'); assert.strictEqual(typeof privateKey, 'object'); assert.strictEqual(publicKey.x, privateKey.x); diff --git a/test/parallel/test-crypto-keygen-async-encrypted-private-key-der.js b/test/parallel/test-crypto-keygen-async-encrypted-private-key-der.js index 3203dfe16eb6..c1bc6fc95ea0 100644 --- a/test/parallel/test-crypto-keygen-async-encrypted-private-key-der.js +++ b/test/parallel/test-crypto-keygen-async-encrypted-private-key-der.js @@ -10,15 +10,17 @@ const { } = require('crypto'); const { assertApproximateSize, + hasFIPS, testEncryptDecrypt, testSignVerify, } = require('../common/crypto'); // Test async RSA key generation with an encrypted private key, but encoded as DER. { + const isFips = hasFIPS(3); generateKeyPair('rsa', { publicExponent: 0x10001, - modulusLength: 512, + modulusLength: isFips ? 2048 : 512, publicKeyEncoding: { type: 'pkcs1', format: 'der' @@ -29,7 +31,7 @@ const { } }, common.mustSucceed((publicKeyDER, privateKeyDER) => { assert(Buffer.isBuffer(publicKeyDER)); - assertApproximateSize(publicKeyDER, 74); + assertApproximateSize(publicKeyDER, isFips ? 270 : 74); assert(Buffer.isBuffer(privateKeyDER)); diff --git a/test/parallel/test-crypto-keygen-async-encrypted-private-key.js b/test/parallel/test-crypto-keygen-async-encrypted-private-key.js index 727cccc6f3ef..37a0121742df 100644 --- a/test/parallel/test-crypto-keygen-async-encrypted-private-key.js +++ b/test/parallel/test-crypto-keygen-async-encrypted-private-key.js @@ -10,15 +10,18 @@ const { } = require('crypto'); const { assertApproximateSize, + hasFIPS, testEncryptDecrypt, testSignVerify, } = require('../common/crypto'); // Test async RSA key generation with an encrypted private key, but encoded as DER. { + const isFips = hasFIPS(3); + const passphrase = 'password'; generateKeyPair('rsa', { publicExponent: 0x10001, - modulusLength: 512, + modulusLength: isFips ? 2048 : 512, publicKeyEncoding: { type: 'pkcs1', format: 'der' @@ -27,11 +30,11 @@ const { type: 'pkcs8', format: 'der', cipher: 'aes-256-cbc', - passphrase: 'secret' + passphrase } }, common.mustSucceed((publicKeyDER, privateKeyDER) => { assert(Buffer.isBuffer(publicKeyDER)); - assertApproximateSize(publicKeyDER, 74); + assertApproximateSize(publicKeyDER, isFips ? 270 : 74); assert(Buffer.isBuffer(privateKeyDER)); @@ -59,7 +62,7 @@ const { key: privateKeyDER, format: 'der', type: 'pkcs8', - passphrase: 'secret' + passphrase }; testEncryptDecrypt(publicKey, privateKey); testSignVerify(publicKey, privateKey); diff --git a/test/parallel/test-crypto-keygen-async-explicit-elliptic-curve-encrypted.js.js b/test/parallel/test-crypto-keygen-async-explicit-elliptic-curve-encrypted.js.js index c3b8ab6e8f50..081e709f46ec 100644 --- a/test/parallel/test-crypto-keygen-async-explicit-elliptic-curve-encrypted.js.js +++ b/test/parallel/test-crypto-keygen-async-explicit-elliptic-curve-encrypted.js.js @@ -12,10 +12,11 @@ const { generateKeyPair, } = require('crypto'); const { + hasFIPS, testSignVerify, spkiExp, sec1EncExp, - hasOpenSSL3, + hasOpenSSL, } = require('../common/crypto'); { @@ -34,7 +35,12 @@ const { cipher: 'aes-128-cbc', passphrase: 'secret' } - }, common.mustSucceed((publicKey, privateKey) => { + }, common.mustCall((err, publicKey, privateKey) => { + if (hasFIPS(3)) { + assert.strictEqual(err?.code, 'ERR_OSSL_EVP_UNSUPPORTED'); + return; + } + assert.ifError(err); assert.strictEqual(typeof publicKey, 'string'); assert.match(publicKey, spkiExp); assert.strictEqual(typeof privateKey, 'string'); @@ -42,7 +48,7 @@ const { // Since the private key is encrypted, signing shouldn't work anymore. assert.throws(() => testSignVerify(publicKey, privateKey), - hasOpenSSL3 ? { + hasOpenSSL(3) ? { message: 'error:07880109:common libcrypto ' + 'routines::interrupted or cancelled' } : { diff --git a/test/parallel/test-crypto-keygen-async-named-elliptic-curve-encrypted.js b/test/parallel/test-crypto-keygen-async-named-elliptic-curve-encrypted.js index 0503ff74787f..84ea9d2f7a9a 100644 --- a/test/parallel/test-crypto-keygen-async-named-elliptic-curve-encrypted.js +++ b/test/parallel/test-crypto-keygen-async-named-elliptic-curve-encrypted.js @@ -9,10 +9,11 @@ const { generateKeyPair, } = require('crypto'); const { + hasFIPS, testSignVerify, spkiExp, sec1EncExp, - hasOpenSSL3, + hasOpenSSL, } = require('../common/crypto'); { @@ -31,7 +32,12 @@ const { cipher: 'aes-128-cbc', passphrase: 'secret' } - }, common.mustSucceed((publicKey, privateKey) => { + }, common.mustCall((err, publicKey, privateKey) => { + if (hasFIPS(3)) { + assert.strictEqual(err?.code, 'ERR_OSSL_EVP_UNSUPPORTED'); + return; + } + assert.ifError(err); assert.strictEqual(typeof publicKey, 'string'); assert.match(publicKey, spkiExp); assert.strictEqual(typeof privateKey, 'string'); @@ -39,7 +45,7 @@ const { // Since the private key is encrypted, signing shouldn't work anymore. assert.throws(() => testSignVerify(publicKey, privateKey), - hasOpenSSL3 ? { + hasOpenSSL(3) ? { message: 'error:07880109:common libcrypto ' + 'routines::interrupted or cancelled' } : { diff --git a/test/parallel/test-crypto-keygen-async-rsa.js b/test/parallel/test-crypto-keygen-async-rsa.js index c80d7d334929..7a372ded9fc5 100644 --- a/test/parallel/test-crypto-keygen-async-rsa.js +++ b/test/parallel/test-crypto-keygen-async-rsa.js @@ -10,17 +10,19 @@ const { } = require('crypto'); const { assertApproximateSize, + hasFIPS, testEncryptDecrypt, testSignVerify, pkcs1EncExp, - hasOpenSSL3, + hasOpenSSL, } = require('../common/crypto'); // Test async RSA key generation with an encrypted private key. { + const isFips = hasFIPS(3); generateKeyPair('rsa', { publicExponent: 0x10001, - modulusLength: 512, + modulusLength: isFips ? 2048 : 512, publicKeyEncoding: { type: 'pkcs1', format: 'der' @@ -31,7 +33,12 @@ const { cipher: 'aes-256-cbc', passphrase: 'secret' } - }, common.mustSucceed((publicKeyDER, privateKey) => { + }, common.mustCall((err, publicKeyDER, privateKey) => { + if (isFips) { + assert.strictEqual(err?.code, 'ERR_OSSL_EVP_UNSUPPORTED'); + return; + } + assert.ifError(err); assert(Buffer.isBuffer(publicKeyDER)); assertApproximateSize(publicKeyDER, 74); @@ -44,7 +51,7 @@ const { type: 'pkcs1', format: 'der', }; - const expectedError = hasOpenSSL3 ? { + const expectedError = hasOpenSSL(3) ? { name: 'Error', message: 'error:07880109:common libcrypto routines::interrupted or ' + 'cancelled' diff --git a/test/parallel/test-crypto-keygen-bit-length.js b/test/parallel/test-crypto-keygen-bit-length.js index 13234589a5d6..52765f3d7fe7 100644 --- a/test/parallel/test-crypto-keygen-bit-length.js +++ b/test/parallel/test-crypto-keygen-bit-length.js @@ -12,7 +12,9 @@ const assert = require('assert'); const { generateKeyPair, } = require('crypto'); -const { hasOpenSSL3 } = require('../common/crypto'); +const { hasOpenSSL, hasFIPS } = require('../common/crypto'); + +const fips3 = hasFIPS(3); // This tests check that generateKeyPair returns correct bit length in // KeyObject's asymmetricKeyDetails. @@ -20,23 +22,38 @@ const { hasOpenSSL3 } = require('../common/crypto'); { generateKeyPair('rsa', { modulusLength: 513, - }, common.mustSucceed((publicKey, privateKey) => { + }, common.mustCall((err, publicKey, privateKey) => { + if (fips3) { + assert.strictEqual(err?.code, 'ERR_OSSL_RSA_INVALID_MODULUS'); + return; + } + assert.ifError(err); assert.strictEqual(privateKey.asymmetricKeyDetails.modulusLength, 513); assert.strictEqual(publicKey.asymmetricKeyDetails.modulusLength, 513); })); generateKeyPair('rsa-pss', { modulusLength: 513, - }, common.mustSucceed((publicKey, privateKey) => { + }, common.mustCall((err, publicKey, privateKey) => { + if (fips3) { + assert.strictEqual(err?.code, 'ERR_OSSL_RSA_INVALID_MODULUS'); + return; + } + assert.ifError(err); assert.strictEqual(privateKey.asymmetricKeyDetails.modulusLength, 513); assert.strictEqual(publicKey.asymmetricKeyDetails.modulusLength, 513); })); - if (hasOpenSSL3) { + if (hasOpenSSL(3)) { generateKeyPair('dsa', { modulusLength: 2049, divisorLength: 256, - }, common.mustSucceed((publicKey, privateKey) => { + }, common.mustCall((err, publicKey, privateKey) => { + if (fips3) { + assert.strictEqual(err?.code, 'ERR_OSSL_DSA_BAD_FFC_PARAMETERS'); + return; + } + assert.ifError(err); assert.strictEqual(privateKey.asymmetricKeyDetails.modulusLength, 2049); assert.strictEqual(publicKey.asymmetricKeyDetails.modulusLength, 2049); })); diff --git a/test/parallel/test-crypto-keygen-dh-classic.js b/test/parallel/test-crypto-keygen-dh-classic.js index 44af7730126a..13c5db2d0603 100644 --- a/test/parallel/test-crypto-keygen-dh-classic.js +++ b/test/parallel/test-crypto-keygen-dh-classic.js @@ -11,11 +11,12 @@ const assert = require('assert'); const { generateKeyPair, } = require('crypto'); +const { hasFIPS } = require('../common/crypto'); // Test classic Diffie-Hellman key generation. { generateKeyPair('dh', { - primeLength: 512 + primeLength: hasFIPS(3) ? 2048 : 512 }, common.mustSucceed((publicKey, privateKey) => { assert.strictEqual(publicKey.type, 'public'); assert.strictEqual(publicKey.asymmetricKeyType, 'dh'); diff --git a/test/parallel/test-crypto-keygen-eddsa.js b/test/parallel/test-crypto-keygen-eddsa.js index 0a132235ea28..5dbdf9a7aeea 100644 --- a/test/parallel/test-crypto-keygen-eddsa.js +++ b/test/parallel/test-crypto-keygen-eddsa.js @@ -8,6 +8,8 @@ const assert = require('assert'); const { generateKeyPair, } = require('crypto'); +const { hasFIPS } = require('../common/crypto'); +const rejectsXCurves = hasFIPS(3, 5); // Test EdDSA key generation. { @@ -16,7 +18,12 @@ const { common.printSkipMessage(`Skipping unsupported ${keyType} test case`); continue; } - generateKeyPair(keyType, common.mustSucceed((publicKey, privateKey) => { + generateKeyPair(keyType, common.mustCall((err, publicKey, privateKey) => { + if (rejectsXCurves && keyType.startsWith('x')) { + assert.strictEqual(err?.code, 'ERR_OSSL_EVP_UNSUPPORTED'); + return; + } + assert.ifError(err); assert.strictEqual(publicKey.type, 'public'); assert.strictEqual(publicKey.asymmetricKeyType, keyType); assert.deepStrictEqual(publicKey.asymmetricKeyDetails, {}); diff --git a/test/parallel/test-crypto-keygen-empty-passphrase-no-error.js b/test/parallel/test-crypto-keygen-empty-passphrase-no-error.js index 6c7938f99e1b..ae1ca23c29e8 100644 --- a/test/parallel/test-crypto-keygen-empty-passphrase-no-error.js +++ b/test/parallel/test-crypto-keygen-empty-passphrase-no-error.js @@ -8,11 +8,14 @@ const assert = require('assert'); const { generateKeyPair, } = require('crypto'); +const { hasFIPS } = require('../common/crypto'); + +const fips4 = hasFIPS(4); // Passing an empty passphrase string should not throw ERR_OSSL_CRYPTO_MALLOC_FAILURE even on OpenSSL 3. // Regression test for https://github.com/nodejs/node/issues/41428. generateKeyPair('rsa', { - modulusLength: 1024, + modulusLength: hasFIPS(3) ? 2048 : 1024, publicKeyEncoding: { type: 'spki', format: 'pem' @@ -23,7 +26,12 @@ generateKeyPair('rsa', { cipher: 'aes-256-cbc', passphrase: '' } -}, common.mustSucceed((publicKey, privateKey) => { +}, common.mustCall((err, publicKey, privateKey) => { + if (fips4) { + assert.strictEqual(err?.code, 'ERR_OSSL_PASSWORD_STRENGTH_TOO_WEAK'); + return; + } + assert.ifError(err); assert.strictEqual(typeof publicKey, 'string'); assert.strictEqual(typeof privateKey, 'string'); })); diff --git a/test/parallel/test-crypto-keygen-empty-passphrase-no-prompt.js b/test/parallel/test-crypto-keygen-empty-passphrase-no-prompt.js index cb873ff04748..ccf98bcd3766 100644 --- a/test/parallel/test-crypto-keygen-empty-passphrase-no-prompt.js +++ b/test/parallel/test-crypto-keygen-empty-passphrase-no-prompt.js @@ -10,23 +10,38 @@ const { generateKeyPair, } = require('crypto'); const { + hasFIPS, testSignVerify, - hasOpenSSL3, + hasOpenSSL, } = require('../common/crypto'); +const fips4 = hasFIPS(4); + // Passing an empty passphrase string should not cause OpenSSL's default // passphrase prompt in the terminal. // See https://github.com/nodejs/node/issues/35898. for (const type of ['pkcs1', 'pkcs8']) { generateKeyPair('rsa', { - modulusLength: 1024, + modulusLength: hasFIPS(3) ? 2048 : 1024, privateKeyEncoding: { type, format: 'pem', cipher: 'aes-256-cbc', passphrase: '' } - }, common.mustSucceed((publicKey, privateKey) => { + }, common.mustCall((err, publicKey, privateKey) => { + if (hasFIPS(3) && type === 'pkcs1') { + assert.strictEqual(err?.code, 'ERR_OSSL_EVP_UNSUPPORTED'); + return; + } + if (fips4) { + assert.strictEqual( + err?.code, + 'ERR_OSSL_PASSWORD_STRENGTH_TOO_WEAK', + ); + return; + } + assert.ifError(err); assert.strictEqual(publicKey.type, 'public'); for (const passphrase of ['', Buffer.alloc(0)]) { @@ -41,7 +56,7 @@ for (const type of ['pkcs1', 'pkcs8']) { // the key, and not specifying a passphrase should fail when decoding it. assert.throws(() => { return testSignVerify(publicKey, privateKey); - }, hasOpenSSL3 ? { + }, hasOpenSSL(3) ? { name: 'Error', code: 'ERR_OSSL_CRYPTO_INTERRUPTED_OR_CANCELLED', message: 'error:07880109:common libcrypto routines::interrupted or cancelled' diff --git a/test/parallel/test-crypto-keygen-invalid-parameter-encoding-dsa.js b/test/parallel/test-crypto-keygen-invalid-parameter-encoding-dsa.js index 9086e2e8a5f1..9d1fdf23bc0b 100644 --- a/test/parallel/test-crypto-keygen-invalid-parameter-encoding-dsa.js +++ b/test/parallel/test-crypto-keygen-invalid-parameter-encoding-dsa.js @@ -12,6 +12,9 @@ const assert = require('assert'); const { generateKeyPairSync, } = require('crypto'); +const { hasFIPS } = require('../common/crypto'); + +const fips3 = hasFIPS(3); // Test invalid parameter encoding. { @@ -25,7 +28,8 @@ const { } }), { name: 'Error', - code: 'ERR_CRYPTO_JWK_UNSUPPORTED_KEY_TYPE', - message: 'Unsupported JWK Key Type.' + code: fips3 ? 'ERR_OSSL_DSA_BAD_FFC_PARAMETERS' : + 'ERR_CRYPTO_JWK_UNSUPPORTED_KEY_TYPE', + ...!fips3 && { message: 'Unsupported JWK Key Type.' }, }); } diff --git a/test/parallel/test-crypto-keygen-key-object-without-encoding.js b/test/parallel/test-crypto-keygen-key-object-without-encoding.js index abcd282871b6..3c44d174df6f 100644 --- a/test/parallel/test-crypto-keygen-key-object-without-encoding.js +++ b/test/parallel/test-crypto-keygen-key-object-without-encoding.js @@ -9,6 +9,7 @@ const { generateKeyPair, } = require('crypto'); const { + hasFIPS, testEncryptDecrypt, testSignVerify, } = require('../common/crypto'); @@ -17,7 +18,7 @@ const { { // If no publicKeyEncoding is specified, a key object should be returned. generateKeyPair('rsa', { - modulusLength: 1024, + modulusLength: hasFIPS(3) ? 2048 : 1024, privateKeyEncoding: { type: 'pkcs1', format: 'pem' @@ -36,7 +37,7 @@ const { // If no privateKeyEncoding is specified, a key object should be returned. generateKeyPair('rsa', { - modulusLength: 1024, + modulusLength: hasFIPS(3) ? 2048 : 1024, publicKeyEncoding: { type: 'pkcs1', format: 'pem' diff --git a/test/parallel/test-crypto-keygen-key-objects.js b/test/parallel/test-crypto-keygen-key-objects.js index a0f1bdf2bcb5..2d22e99360e3 100644 --- a/test/parallel/test-crypto-keygen-key-objects.js +++ b/test/parallel/test-crypto-keygen-key-objects.js @@ -8,18 +8,20 @@ const assert = require('assert'); const { generateKeyPairSync, } = require('crypto'); +const { hasFIPS } = require('../common/crypto'); // Test sync key generation with key objects. { + const modulusLength = hasFIPS(3) ? 2048 : 512; const { publicKey, privateKey } = generateKeyPairSync('rsa', { - modulusLength: 512 + modulusLength }); assert.strictEqual(typeof publicKey, 'object'); assert.strictEqual(publicKey.type, 'public'); assert.strictEqual(publicKey.asymmetricKeyType, 'rsa'); assert.deepStrictEqual(publicKey.asymmetricKeyDetails, { - modulusLength: 512, + modulusLength, publicExponent: 65537n }); @@ -27,7 +29,7 @@ const { assert.strictEqual(privateKey.type, 'private'); assert.strictEqual(privateKey.asymmetricKeyType, 'rsa'); assert.deepStrictEqual(privateKey.asymmetricKeyDetails, { - modulusLength: 512, + modulusLength, publicExponent: 65537n }); } diff --git a/test/parallel/test-crypto-keygen-missing-oid.js b/test/parallel/test-crypto-keygen-missing-oid.js index 1e4f309292eb..afe95dbee40f 100644 --- a/test/parallel/test-crypto-keygen-missing-oid.js +++ b/test/parallel/test-crypto-keygen-missing-oid.js @@ -11,7 +11,7 @@ const { getCurves, } = require('crypto'); -const { hasOpenSSL3 } = require('../common/crypto'); +const { hasOpenSSL, hasFIPS } = require('../common/crypto'); // This test creates EC key pairs on curves without associated OIDs. // Specifying a key encoding should not crash. @@ -22,7 +22,8 @@ const { hasOpenSSL3 } = require('../common/crypto'); continue; const expectedErrorCode = - hasOpenSSL3 ? 'ERR_OSSL_MISSING_OID' : 'ERR_OSSL_EC_MISSING_OID'; + hasFIPS(3) ? 'ERR_OSSL_EC_UNKNOWN_GROUP' : + hasOpenSSL(3) ? 'ERR_OSSL_MISSING_OID' : 'ERR_OSSL_EC_MISSING_OID'; const params = { namedCurve, publicKeyEncoding: { diff --git a/test/parallel/test-crypto-keygen-no-rsassa-pss-params.js b/test/parallel/test-crypto-keygen-no-rsassa-pss-params.js index 559c6f0af051..778899972863 100644 --- a/test/parallel/test-crypto-keygen-no-rsassa-pss-params.js +++ b/test/parallel/test-crypto-keygen-no-rsassa-pss-params.js @@ -11,15 +11,17 @@ const assert = require('assert'); const { generateKeyPair, } = require('crypto'); +const { hasFIPS } = require('../common/crypto'); // 'rsa-pss' should not add a RSASSA-PSS-params sequence by default. // Regression test for: https://github.com/nodejs/node/issues/39936 { + const modulusLength = hasFIPS(3) ? 2048 : 512; generateKeyPair('rsa-pss', { - modulusLength: 512 + modulusLength }, common.mustSucceed((publicKey, privateKey) => { const expectedKeyDetails = { - modulusLength: 512, + modulusLength, publicExponent: 65537n }; assert.deepStrictEqual(publicKey.asymmetricKeyDetails, expectedKeyDetails); @@ -30,6 +32,7 @@ const { // AlgorithmIdentifier member of the SubjectPublicKeyInfo has the expected // length of 11 bytes (as opposed to > 11 bytes if node added params). const spki = publicKey.export({ format: 'der', type: 'spki' }); - assert.strictEqual(spki[3], 11, spki.toString('hex')); + assert.strictEqual( + spki[3], hasFIPS(3) ? 32 : 11, spki.toString('hex')); })); } diff --git a/test/parallel/test-crypto-keygen-non-standard-public-exponent.js b/test/parallel/test-crypto-keygen-non-standard-public-exponent.js index f54a9e8a6d9b..d6677683880e 100644 --- a/test/parallel/test-crypto-keygen-non-standard-public-exponent.js +++ b/test/parallel/test-crypto-keygen-non-standard-public-exponent.js @@ -8,28 +8,38 @@ const assert = require('assert'); const { generateKeyPairSync, } = require('crypto'); +const { hasFIPS } = require('../common/crypto'); // Test sync key generation with key objects with a non-standard // publicExponent { - const { publicKey, privateKey } = generateKeyPairSync('rsa', { - publicExponent: 3, - modulusLength: 512 - }); + if (hasFIPS(3)) { + assert.throws(() => generateKeyPairSync('rsa', { + publicExponent: 3, + modulusLength: 2048, + }), { + code: 'ERR_OSSL_RSA_PUB_EXPONENT_OUT_OF_RANGE', + }); + } else { + const { publicKey, privateKey } = generateKeyPairSync('rsa', { + publicExponent: 3, + modulusLength: 512 + }); - assert.strictEqual(typeof publicKey, 'object'); - assert.strictEqual(publicKey.type, 'public'); - assert.strictEqual(publicKey.asymmetricKeyType, 'rsa'); - assert.deepStrictEqual(publicKey.asymmetricKeyDetails, { - modulusLength: 512, - publicExponent: 3n - }); + assert.strictEqual(typeof publicKey, 'object'); + assert.strictEqual(publicKey.type, 'public'); + assert.strictEqual(publicKey.asymmetricKeyType, 'rsa'); + assert.deepStrictEqual(publicKey.asymmetricKeyDetails, { + modulusLength: 512, + publicExponent: 3n + }); - assert.strictEqual(typeof privateKey, 'object'); - assert.strictEqual(privateKey.type, 'private'); - assert.strictEqual(privateKey.asymmetricKeyType, 'rsa'); - assert.deepStrictEqual(privateKey.asymmetricKeyDetails, { - modulusLength: 512, - publicExponent: 3n - }); + assert.strictEqual(typeof privateKey, 'object'); + assert.strictEqual(privateKey.type, 'private'); + assert.strictEqual(privateKey.asymmetricKeyType, 'rsa'); + assert.deepStrictEqual(privateKey.asymmetricKeyDetails, { + modulusLength: 512, + publicExponent: 3n + }); + } } diff --git a/test/parallel/test-crypto-keygen-promisify.js b/test/parallel/test-crypto-keygen-promisify.js index cd6ca7d6e3e6..158367cef46b 100644 --- a/test/parallel/test-crypto-keygen-promisify.js +++ b/test/parallel/test-crypto-keygen-promisify.js @@ -10,6 +10,7 @@ const { } = require('crypto'); const { assertApproximateSize, + hasFIPS, testEncryptDecrypt, testSignVerify, pkcs1PubExp, @@ -19,9 +20,10 @@ const { promisify } = require('util'); // Test the util.promisified API with async RSA key generation. { + const isFips = hasFIPS(3); promisify(generateKeyPair)('rsa', { publicExponent: 0x10001, - modulusLength: 512, + modulusLength: isFips ? 2048 : 512, publicKeyEncoding: { type: 'pkcs1', format: 'pem' @@ -34,11 +36,11 @@ const { promisify } = require('util'); const { publicKey, privateKey } = keys; assert.strictEqual(typeof publicKey, 'string'); assert.match(publicKey, pkcs1PubExp); - assertApproximateSize(publicKey, 180); + assertApproximateSize(publicKey, isFips ? 426 : 180); assert.strictEqual(typeof privateKey, 'string'); assert.match(privateKey, pkcs1PrivExp); - assertApproximateSize(privateKey, 512); + assertApproximateSize(privateKey, isFips ? 1675 : 512); testEncryptDecrypt(publicKey, privateKey); testSignVerify(publicKey, privateKey); diff --git a/test/parallel/test-crypto-keygen-raw.js b/test/parallel/test-crypto-keygen-raw.js index e55c3f10eed8..41ef9362dcac 100644 --- a/test/parallel/test-crypto-keygen-raw.js +++ b/test/parallel/test-crypto-keygen-raw.js @@ -11,7 +11,9 @@ const { createPublicKey, createPrivateKey, } = require('crypto'); -const { hasOpenSSL } = require('../common/crypto'); +const { hasOpenSSL, hasFIPS } = require('../common/crypto'); + +const rejectsXCurves = hasFIPS(3, 5); // Test generateKeyPairSync with raw encoding for EdDSA/ECDH key types. { @@ -20,10 +22,17 @@ const { hasOpenSSL } = require('../common/crypto'); types.push('ed448', 'x448'); } for (const type of types) { - const { publicKey, privateKey } = generateKeyPairSync(type, { + const options = { publicKeyEncoding: { format: 'raw-public' }, privateKeyEncoding: { format: 'raw-private' }, - }); + }; + if (rejectsXCurves && type.startsWith('x')) { + assert.throws(() => generateKeyPairSync(type, options), { + code: 'ERR_OSSL_EVP_UNSUPPORTED', + }); + continue; + } + const { publicKey, privateKey } = generateKeyPairSync(type, options); assert(Buffer.isBuffer(publicKey)); assert(Buffer.isBuffer(privateKey)); @@ -54,13 +63,20 @@ const { hasOpenSSL } = require('../common/crypto'); types.push('ed448', 'x448'); } for (const type of types) { - generateKeyPair(type, { + const options = { publicKeyEncoding: { format: 'raw-public' }, privateKeyEncoding: { format: 'raw-private' }, - }, common.mustSucceed((publicKey, privateKey) => { - assert(Buffer.isBuffer(publicKey)); - assert(Buffer.isBuffer(privateKey)); - })); + }; + generateKeyPair(type, options, + common.mustCall((err, publicKey, privateKey) => { + if (rejectsXCurves && type.startsWith('x')) { + assert.strictEqual(err?.code, 'ERR_OSSL_EVP_UNSUPPORTED'); + return; + } + assert.ifError(err); + assert(Buffer.isBuffer(publicKey)); + assert(Buffer.isBuffer(privateKey)); + })); } } diff --git a/test/parallel/test-crypto-keygen-rfc8017-9-1.js b/test/parallel/test-crypto-keygen-rfc8017-9-1.js index fbefb1b4f642..3234084afe10 100644 --- a/test/parallel/test-crypto-keygen-rfc8017-9-1.js +++ b/test/parallel/test-crypto-keygen-rfc8017-9-1.js @@ -11,19 +11,20 @@ const assert = require('assert'); const { generateKeyPair, } = require('crypto'); +const { hasFIPS } = require('../common/crypto'); // RFC 8017, 9.1.: "Assuming that the mask generation function is based on a // hash function, it is RECOMMENDED that the hash function be the same as the // one that is applied to the message." { - + const modulusLength = hasFIPS(3) ? 2048 : 512; generateKeyPair('rsa-pss', { - modulusLength: 512, + modulusLength, hashAlgorithm: 'sha256', saltLength: 16 }, common.mustSucceed((publicKey, privateKey) => { const expectedKeyDetails = { - modulusLength: 512, + modulusLength, publicExponent: 65537n, hashAlgorithm: 'sha256', mgf1HashAlgorithm: 'sha256', diff --git a/test/parallel/test-crypto-keygen-rfc8017-a-2-3.js b/test/parallel/test-crypto-keygen-rfc8017-a-2-3.js index bc96d57ed0cd..f7cb70560e7e 100644 --- a/test/parallel/test-crypto-keygen-rfc8017-a-2-3.js +++ b/test/parallel/test-crypto-keygen-rfc8017-a-2-3.js @@ -11,16 +11,18 @@ const assert = require('assert'); const { generateKeyPair, } = require('crypto'); +const { hasFIPS } = require('../common/crypto'); // RFC 8017, A.2.3.: "For a given hashAlgorithm, the default value of // saltLength is the octet length of the hash value." { + const modulusLength = hasFIPS(3) ? 2048 : 512; generateKeyPair('rsa-pss', { - modulusLength: 512, + modulusLength, hashAlgorithm: 'sha512' }, common.mustSucceed((publicKey, privateKey) => { const expectedKeyDetails = { - modulusLength: 512, + modulusLength, publicExponent: 65537n, hashAlgorithm: 'sha512', mgf1HashAlgorithm: 'sha512', @@ -32,12 +34,12 @@ const { // It is still possible to explicitly set saltLength to 0. generateKeyPair('rsa-pss', { - modulusLength: 512, + modulusLength, hashAlgorithm: 'sha512', saltLength: 0 }, common.mustSucceed((publicKey, privateKey) => { const expectedKeyDetails = { - modulusLength: 512, + modulusLength, publicExponent: 65537n, hashAlgorithm: 'sha512', mgf1HashAlgorithm: 'sha512', diff --git a/test/parallel/test-crypto-keygen-rsa-pss.js b/test/parallel/test-crypto-keygen-rsa-pss.js index 3ce0d40e8d1b..88d522a223b9 100644 --- a/test/parallel/test-crypto-keygen-rsa-pss.js +++ b/test/parallel/test-crypto-keygen-rsa-pss.js @@ -13,14 +13,16 @@ const { generateKeyPair, } = require('crypto'); const { + hasFIPS, testEncryptDecrypt, testSignVerify, } = require('../common/crypto'); // Test RSA-PSS. { + const modulusLength = hasFIPS(3) ? 2048 : 512; generateKeyPair('rsa-pss', { - modulusLength: 512, + modulusLength, saltLength: 16, hashAlgorithm: 'sha256', mgf1HashAlgorithm: 'sha256' @@ -28,7 +30,7 @@ const { assert.strictEqual(publicKey.type, 'public'); assert.strictEqual(publicKey.asymmetricKeyType, 'rsa-pss'); assert.deepStrictEqual(publicKey.asymmetricKeyDetails, { - modulusLength: 512, + modulusLength, publicExponent: 65537n, hashAlgorithm: 'sha256', mgf1HashAlgorithm: 'sha256', @@ -38,7 +40,7 @@ const { assert.strictEqual(privateKey.type, 'private'); assert.strictEqual(privateKey.asymmetricKeyType, 'rsa-pss'); assert.deepStrictEqual(privateKey.asymmetricKeyDetails, { - modulusLength: 512, + modulusLength, publicExponent: 65537n, hashAlgorithm: 'sha256', mgf1HashAlgorithm: 'sha256', diff --git a/test/parallel/test-crypto-keygen-sync.js b/test/parallel/test-crypto-keygen-sync.js index a100379e21f1..54a91fcfbcae 100644 --- a/test/parallel/test-crypto-keygen-sync.js +++ b/test/parallel/test-crypto-keygen-sync.js @@ -10,6 +10,7 @@ const { } = require('crypto'); const { assertApproximateSize, + hasFIPS, testEncryptDecrypt, testSignVerify, pkcs1PubExp, @@ -19,9 +20,10 @@ const { // To make the test faster, we will only test sync key generation once and // with a relatively small key. { + const isFips = hasFIPS(3); const ret = generateKeyPairSync('rsa', { - publicExponent: 3, - modulusLength: 512, + publicExponent: isFips ? 0x10001 : 3, + modulusLength: isFips ? 2048 : 512, publicKeyEncoding: { type: 'pkcs1', format: 'pem' @@ -37,10 +39,10 @@ const { assert.strictEqual(typeof publicKey, 'string'); assert.match(publicKey, pkcs1PubExp); - assertApproximateSize(publicKey, 162); + assertApproximateSize(publicKey, isFips ? 426 : 162); assert.strictEqual(typeof privateKey, 'string'); assert.match(privateKey, pkcs8Exp); - assertApproximateSize(privateKey, 512); + assertApproximateSize(privateKey, isFips ? 1704 : 512); testEncryptDecrypt(publicKey, privateKey); testSignVerify(publicKey, privateKey); diff --git a/test/parallel/test-crypto-keyobject-brand-check.js b/test/parallel/test-crypto-keyobject-brand-check.js index ac0cf1b65f70..ed8e5bdae76e 100644 --- a/test/parallel/test-crypto-keyobject-brand-check.js +++ b/test/parallel/test-crypto-keyobject-brand-check.js @@ -15,6 +15,7 @@ const { generateKeyPairSync, KeyObject, } = require('node:crypto'); +const { hasFIPS } = require('../common/crypto'); const { types: { isKeyObject } } = require('node:util'); const invalidThis = { code: 'ERR_INVALID_THIS', name: 'TypeError' }; @@ -25,7 +26,9 @@ function getter(proto, name) { { const secret = createSecretKey(Buffer.alloc(16)); - const { publicKey } = generateKeyPairSync('rsa', { modulusLength: 1024 }); + const { publicKey } = generateKeyPairSync('rsa', { + modulusLength: hasFIPS(3) ? 2048 : 1024, + }); const type = getter(KeyObject.prototype, 'type'); const symmetricKeySize = diff --git a/test/parallel/test-crypto-keyobject-clone-transfer.js b/test/parallel/test-crypto-keyobject-clone-transfer.js index 1d68e4b9911a..6786df288589 100644 --- a/test/parallel/test-crypto-keyobject-clone-transfer.js +++ b/test/parallel/test-crypto-keyobject-clone-transfer.js @@ -16,6 +16,7 @@ const { sign, verify, } = require('node:crypto'); +const { hasFIPS } = require('../common/crypto'); const { MessageChannel, Worker } = require('node:worker_threads'); const { types: { isKeyObject } } = require('node:util'); @@ -85,7 +86,7 @@ function hmacDigest(key) { (async () => { const secret = createSecretKey(Buffer.alloc(16)); const { publicKey, privateKey } = generateKeyPairSync('rsa', { - modulusLength: 1024, + modulusLength: hasFIPS(3) ? 2048 : 1024, }); for (const key of [secret, publicKey, privateKey]) { diff --git a/test/parallel/test-crypto-keyobject-hidden-slots.js b/test/parallel/test-crypto-keyobject-hidden-slots.js index 1ea243ba0ab8..b3db2068541f 100644 --- a/test/parallel/test-crypto-keyobject-hidden-slots.js +++ b/test/parallel/test-crypto-keyobject-hidden-slots.js @@ -29,6 +29,7 @@ const { X509Certificate, } = require('node:crypto'); const { readFileSync } = require('node:fs'); +const { hasFIPS } = require('../common/crypto'); const fixtures = require('../common/fixtures'); function updateFinal(cipher, data = Buffer.alloc(16)) { @@ -110,12 +111,13 @@ function updateFinal(cipher, data = Buffer.alloc(16)) { } { + const modulusLength = hasFIPS(3) ? 2048 : 1024; const { publicKey } = generateKeyPairSync('rsa', { - modulusLength: 1024, + modulusLength, }); const details = publicKey.asymmetricKeyDetails; - assert.strictEqual(details.modulusLength, 1024); + assert.strictEqual(details.modulusLength, modulusLength); assert.strictEqual(details.publicExponent, 65537n); details.modulusLength = 1; @@ -124,7 +126,7 @@ function updateFinal(cipher, data = Buffer.alloc(16)) { const freshDetails = publicKey.asymmetricKeyDetails; assert.notStrictEqual(freshDetails, details); - assert.strictEqual(freshDetails.modulusLength, 1024); + assert.strictEqual(freshDetails.modulusLength, modulusLength); assert.strictEqual(freshDetails.publicExponent, 65537n); assert.strictEqual(freshDetails.extra, undefined); } @@ -150,7 +152,7 @@ function updateFinal(cipher, data = Buffer.alloc(16)) { { const { privateKey, publicKey } = generateKeyPairSync('rsa', { - modulusLength: 1024, + modulusLength: hasFIPS(3) ? 2048 : 1024, }); const originalType = Object.getOwnPropertyDescriptor(KeyObject.prototype, 'type'); @@ -175,8 +177,10 @@ function updateFinal(cipher, data = Buffer.alloc(16)) { verifier.update(data); assert.strictEqual(verifier.verify(publicKey, streamSignature), true); - const ciphertext = publicEncrypt(publicKey, data); - assert.deepStrictEqual(privateDecrypt(privateKey, ciphertext), data); + const options = hasFIPS(3) ? { oaepHash: 'sha256' } : {}; + const ciphertext = publicEncrypt({ key: publicKey, ...options }, data); + assert.deepStrictEqual( + privateDecrypt({ key: privateKey, ...options }, ciphertext), data); assert.strictEqual(publicKey.equals(createPublicKey(privateKey)), true); diff --git a/test/parallel/test-crypto-keyobject-no-own-symbols.js b/test/parallel/test-crypto-keyobject-no-own-symbols.js index f1539c6a0f7a..945032446784 100644 --- a/test/parallel/test-crypto-keyobject-no-own-symbols.js +++ b/test/parallel/test-crypto-keyobject-no-own-symbols.js @@ -12,6 +12,7 @@ const { createSecretKey, generateKeyPairSync, } = require('node:crypto'); +const { hasFIPS } = require('../common/crypto'); function assertNoOwnKeys(key) { assert.deepStrictEqual(Object.getOwnPropertySymbols(key), []); @@ -22,7 +23,7 @@ function assertNoOwnKeys(key) { { const secret = createSecretKey(Buffer.alloc(16)); const { publicKey, privateKey } = generateKeyPairSync('rsa', { - modulusLength: 1024, + modulusLength: hasFIPS(3) ? 2048 : 1024, }); for (const key of [secret, publicKey, privateKey]) { diff --git a/test/parallel/test-crypto-pbkdf2.js b/test/parallel/test-crypto-pbkdf2.js index 78b73ed6c4e0..7cd1206f4f02 100644 --- a/test/parallel/test-crypto-pbkdf2.js +++ b/test/parallel/test-crypto-pbkdf2.js @@ -5,7 +5,16 @@ if (!common.hasCrypto) const assert = require('assert'); const crypto = require('crypto'); -const { hasOpenSSL3 } = require('../common/crypto'); +const { + hasOpenSSL, + hasFIPS, +} = require('../common/crypto'); + +const fips4 = hasFIPS(4); +const validPassword = fips4 ? 'password' : 'pass'; +const validSalt = fips4 ? '0123456789abcdef' : 'salt'; +const validIterations = fips4 ? 1000 : 8; +const validKeyLength = fips4 ? 16 : 8; function runPBKDF2(password, salt, iterations, keylen, hash) { const syncResult = @@ -19,6 +28,25 @@ function runPBKDF2(password, salt, iterations, keylen, hash) { return syncResult; } +function assertPBKDF2Fails(password, salt, iterations, keylen, hash) { + const expected = { message: 'PBKDF2 derivation failed' }; + assert.throws( + () => crypto.pbkdf2Sync(password, salt, iterations, keylen, hash), + expected, + ); + crypto.pbkdf2( + password, + salt, + iterations, + keylen, + hash, + common.mustCall((err, result) => { + assert.strictEqual(err?.message, expected.message); + assert.strictEqual(result, undefined); + }), + ); +} + function testPBKDF2(password, salt, iterations, keylen, expected, encoding) { const actual = runPBKDF2(password, salt, iterations, keylen, 'sha256'); assert.strictEqual(actual.toString(encoding || 'latin1'), expected); @@ -28,36 +56,55 @@ function testPBKDF2(password, salt, iterations, keylen, expected, encoding) { // Test PBKDF2 with RFC 6070 test vectors (except #4) // -testPBKDF2('password', 'salt', 1, 20, - '\x12\x0f\xb6\xcf\xfc\xf8\xb3\x2c\x43\xe7\x22\x52' + - '\x56\xc4\xf8\x37\xa8\x65\x48\xc9'); - -testPBKDF2('password', 'salt', 2, 20, - '\xae\x4d\x0c\x95\xaf\x6b\x46\xd3\x2d\x0a\xdf\xf9' + - '\x28\xf0\x6d\xd0\x2a\x30\x3f\x8e'); - -testPBKDF2('password', 'salt', 4096, 20, - '\xc5\xe4\x78\xd5\x92\x88\xc8\x41\xaa\x53\x0d\xb6' + - '\x84\x5c\x4c\x8d\x96\x28\x93\xa0'); - -testPBKDF2('passwordPASSWORDpassword', - 'saltSALTsaltSALTsaltSALTsaltSALTsalt', - 4096, - 25, - '\x34\x8c\x89\xdb\xcb\xd3\x2b\x2f\x32\xd8\x14\xb8\x11' + - '\x6e\x84\xcf\x2b\x17\x34\x7e\xbc\x18\x00\x18\x1c'); - -testPBKDF2('pass\0word', 'sa\0lt', 4096, 16, - '\x89\xb6\x9d\x05\x16\xf8\x29\x89\x3c\x69\x62\x26\x65' + - '\x0a\x86\x87'); - -testPBKDF2('password', 'salt', 32, 32, - '64c486c55d30d4c5a079b8823b7d7cb37ff0556f537da8410233bcec330ed956', - 'hex'); +if (fips4) { + testPBKDF2(validPassword, validSalt, validIterations, 32, + '8514638175a45bc45eb1f22f04ff7d27' + + 'f4f8be480498c455ff4b494ce8d1e7d2', + 'hex'); + + for (const args of [ + ['short', validSalt, validIterations], + [validPassword, 'short', validIterations], + [validPassword, validSalt, 999], + ]) { + assertPBKDF2Fails(...args, 32, 'sha256'); + } + assertPBKDF2Fails( + validPassword, validSalt, validIterations, 8, 'sha256'); +} else { + testPBKDF2('password', 'salt', 1, 20, + '\x12\x0f\xb6\xcf\xfc\xf8\xb3\x2c\x43\xe7\x22\x52' + + '\x56\xc4\xf8\x37\xa8\x65\x48\xc9'); + + testPBKDF2('password', 'salt', 2, 20, + '\xae\x4d\x0c\x95\xaf\x6b\x46\xd3\x2d\x0a\xdf\xf9' + + '\x28\xf0\x6d\xd0\x2a\x30\x3f\x8e'); + + testPBKDF2('password', 'salt', 4096, 20, + '\xc5\xe4\x78\xd5\x92\x88\xc8\x41\xaa\x53\x0d\xb6' + + '\x84\x5c\x4c\x8d\x96\x28\x93\xa0'); + + testPBKDF2('passwordPASSWORDpassword', + 'saltSALTsaltSALTsaltSALTsaltSALTsalt', + 4096, + 25, + '\x34\x8c\x89\xdb\xcb\xd3\x2b\x2f\x32\xd8\x14\xb8\x11' + + '\x6e\x84\xcf\x2b\x17\x34\x7e\xbc\x18\x00\x18\x1c'); + + testPBKDF2('pass\0word', 'sa\0lt', 4096, 16, + '\x89\xb6\x9d\x05\x16\xf8\x29\x89\x3c\x69\x62\x26\x65' + + '\x0a\x86\x87'); + + testPBKDF2('password', 'salt', 32, 32, + '64c486c55d30d4c5a079b8823b7d7cb3' + + '7ff0556f537da8410233bcec330ed956', + 'hex'); +} // Error path should not leak memory (check with valgrind). assert.throws( - () => crypto.pbkdf2('password', 'salt', 1, 20, 'sha1'), + () => crypto.pbkdf2( + validPassword, validSalt, validIterations, 20, 'sha1'), { code: 'ERR_INVALID_ARG_TYPE', name: 'TypeError' @@ -66,7 +113,8 @@ assert.throws( for (const iterations of [-1, 0, 2147483648]) { assert.throws( - () => crypto.pbkdf2Sync('password', 'salt', iterations, 20, 'sha1'), + () => crypto.pbkdf2Sync( + validPassword, validSalt, iterations, 20, 'sha1'), { code: 'ERR_OUT_OF_RANGE', name: 'RangeError', @@ -77,7 +125,8 @@ for (const iterations of [-1, 0, 2147483648]) { ['str', null, undefined, [], {}].forEach((notNumber) => { assert.throws( () => { - crypto.pbkdf2Sync('password', 'salt', 1, notNumber, 'sha256'); + crypto.pbkdf2Sync( + validPassword, validSalt, validIterations, notNumber, 'sha256'); }, { code: 'ERR_INVALID_ARG_TYPE', name: 'TypeError', @@ -89,7 +138,7 @@ for (const iterations of [-1, 0, 2147483648]) { [Infinity, -Infinity, NaN].forEach((input) => { assert.throws( () => { - crypto.pbkdf2('password', 'salt', 1, input, 'sha256', + crypto.pbkdf2(validPassword, validSalt, validIterations, input, 'sha256', common.mustNotCall()); }, { code: 'ERR_OUT_OF_RANGE', @@ -102,7 +151,7 @@ for (const iterations of [-1, 0, 2147483648]) { [-1, 2147483648, 4294967296].forEach((input) => { assert.throws( () => { - crypto.pbkdf2('password', 'salt', 1, input, 'sha256', + crypto.pbkdf2(validPassword, validSalt, validIterations, input, 'sha256', common.mustNotCall()); }, { code: 'ERR_OUT_OF_RANGE', @@ -119,14 +168,16 @@ for (const iterations of [-1, 0, 2147483648]) { let posError; let posResult; try { - posResult = crypto.pbkdf2Sync('password', 'salt', 1, 0, 'sha256'); + posResult = crypto.pbkdf2Sync( + validPassword, validSalt, validIterations, 0, 'sha256'); } catch (err) { posError = err; } let negError; let negResult; try { - negResult = crypto.pbkdf2Sync('password', 'salt', 1, -0, 'sha256'); + negResult = crypto.pbkdf2Sync( + validPassword, validSalt, validIterations, -0, 'sha256'); } catch (err) { negError = err; } @@ -136,15 +187,22 @@ for (const iterations of [-1, 0, 2147483648]) { assert.deepStrictEqual(negResult, posResult); } - crypto.pbkdf2('password', 'salt', 1, -0, 'sha256', common.mustCall()); + crypto.pbkdf2( + validPassword, validSalt, validIterations, -0, 'sha256', + common.mustCall()); } // Should not get FATAL ERROR with empty password and salt // https://github.com/nodejs/node/issues/8571 -crypto.pbkdf2('', '', 1, 32, 'sha256', common.mustSucceed()); +if (fips4) { + assertPBKDF2Fails('', '', 1, 32, 'sha256'); +} else { + crypto.pbkdf2('', '', 1, 32, 'sha256', common.mustSucceed()); +} assert.throws( - () => crypto.pbkdf2('password', 'salt', 8, 8, common.mustNotCall()), + () => crypto.pbkdf2( + validPassword, validSalt, validIterations, 8, common.mustNotCall()), { code: 'ERR_INVALID_ARG_TYPE', name: 'TypeError', @@ -153,7 +211,7 @@ assert.throws( }); assert.throws( - () => crypto.pbkdf2Sync('password', 'salt', 8, 8), + () => crypto.pbkdf2Sync(validPassword, validSalt, validIterations, 8), { code: 'ERR_INVALID_ARG_TYPE', name: 'TypeError', @@ -162,7 +220,7 @@ assert.throws( }); assert.throws( - () => crypto.pbkdf2Sync('password', 'salt', 8, 8, null), + () => crypto.pbkdf2Sync(validPassword, validSalt, validIterations, 8, null), { code: 'ERR_INVALID_ARG_TYPE', name: 'TypeError', @@ -171,7 +229,8 @@ assert.throws( }); [1, {}, [], true, undefined, null].forEach((input) => { assert.throws( - () => crypto.pbkdf2(input, 'salt', 8, 8, 'sha256', common.mustNotCall()), + () => crypto.pbkdf2( + input, validSalt, validIterations, 8, 'sha256', common.mustNotCall()), { code: 'ERR_INVALID_ARG_TYPE', name: 'TypeError', @@ -179,7 +238,9 @@ assert.throws( ); assert.throws( - () => crypto.pbkdf2('pass', input, 8, 8, 'sha256', common.mustNotCall()), + () => crypto.pbkdf2( + validPassword, input, validIterations, 8, 'sha256', + common.mustNotCall()), { code: 'ERR_INVALID_ARG_TYPE', name: 'TypeError', @@ -187,7 +248,8 @@ assert.throws( ); assert.throws( - () => crypto.pbkdf2Sync(input, 'salt', 8, 8, 'sha256'), + () => crypto.pbkdf2Sync( + input, validSalt, validIterations, 8, 'sha256'), { code: 'ERR_INVALID_ARG_TYPE', name: 'TypeError', @@ -195,7 +257,8 @@ assert.throws( ); assert.throws( - () => crypto.pbkdf2Sync('pass', input, 8, 8, 'sha256'), + () => crypto.pbkdf2Sync( + validPassword, input, validIterations, 8, 'sha256'), { code: 'ERR_INVALID_ARG_TYPE', name: 'TypeError', @@ -206,7 +269,8 @@ assert.throws( ['test', {}, [], true, undefined, null].forEach((i) => { const received = common.invalidArgTypeHelper(i); assert.throws( - () => crypto.pbkdf2('pass', 'salt', i, 8, 'sha256', common.mustNotCall()), + () => crypto.pbkdf2( + validPassword, validSalt, i, 8, 'sha256', common.mustNotCall()), { code: 'ERR_INVALID_ARG_TYPE', name: 'TypeError', @@ -215,7 +279,7 @@ assert.throws( ); assert.throws( - () => crypto.pbkdf2Sync('pass', 'salt', i, 8, 'sha256'), + () => crypto.pbkdf2Sync(validPassword, validSalt, i, 8, 'sha256'), { code: 'ERR_INVALID_ARG_TYPE', name: 'TypeError', @@ -224,15 +288,32 @@ assert.throws( ); }); +if (fips4) { + assertPBKDF2Fails( + new Uint8Array(1), validSalt, validIterations, validKeyLength, 'sha256'); + assertPBKDF2Fails( + validPassword, new Uint8Array(1), validIterations, validKeyLength, + 'sha256'); +} + // Any TypedArray should work for password and salt. for (const SomeArray of [Uint8Array, Uint16Array, Uint32Array, Float32Array, Float64Array, ArrayBuffer, SharedArrayBuffer]) { - runPBKDF2(new SomeArray(10), 'salt', 8, 8, 'sha256'); - runPBKDF2('pass', new SomeArray(10), 8, 8, 'sha256'); + const length = fips4 ? 16 : 10; + const input = new SomeArray(length); + const bytes = ArrayBuffer.isView(input) ? + new Uint8Array(input.buffer, input.byteOffset, input.byteLength) : + new Uint8Array(input); + for (let index = 0; index < bytes.length; index++) + bytes[index] = index + 1; + runPBKDF2(input, validSalt, validIterations, validKeyLength, 'sha256'); + runPBKDF2(validPassword, input, validIterations, validKeyLength, 'sha256'); } assert.throws( - () => crypto.pbkdf2('pass', 'salt', 8, 8, 'md55', common.mustNotCall()), + () => crypto.pbkdf2( + validPassword, validSalt, validIterations, 8, 'md55', + common.mustNotCall()), { code: 'ERR_CRYPTO_INVALID_DIGEST', name: 'TypeError', @@ -241,7 +322,8 @@ assert.throws( ); assert.throws( - () => crypto.pbkdf2Sync('pass', 'salt', 8, 8, 'md55'), + () => crypto.pbkdf2Sync( + validPassword, validSalt, validIterations, 8, 'md55'), { code: 'ERR_CRYPTO_INVALID_DIGEST', name: 'TypeError', @@ -249,7 +331,7 @@ assert.throws( } ); -if (!hasOpenSSL3) { +if (!hasOpenSSL(3)) { const kNotPBKDF2Supported = ['shake128', 'shake256']; crypto.getHashes() .filter((hash) => !kNotPBKDF2Supported.includes(hash)) @@ -261,7 +343,8 @@ if (!hasOpenSSL3) { { // This should not crash. assert.throws( - () => crypto.pbkdf2Sync('1', '2', 1, 1, '%'), + () => crypto.pbkdf2Sync( + validPassword, validSalt, validIterations, 1, '%'), { code: 'ERR_CRYPTO_INVALID_DIGEST', name: 'TypeError', diff --git a/test/parallel/test-crypto-pqc-encrypted-pkcs8.js b/test/parallel/test-crypto-pqc-encrypted-pkcs8.js index b4a1b586d21d..8f2a10725c94 100644 --- a/test/parallel/test-crypto-pqc-encrypted-pkcs8.js +++ b/test/parallel/test-crypto-pqc-encrypted-pkcs8.js @@ -4,7 +4,7 @@ const common = require('../common'); if (!common.hasCrypto) common.skip('missing crypto'); -const { hasOpenSSL } = require('../common/crypto'); +const { hasOpenSSL, hasFIPS } = require('../common/crypto'); if (!hasOpenSSL(3, 5) && !process.features.openssl_is_boringssl) common.skip('requires OpenSSL >= 3.5 or BoringSSL'); @@ -30,19 +30,32 @@ if (process.features.openssl_is_boringssl) { // ciphers like RC2, the optional PBKDF2 keyLength INTEGER branch in // the EncryptedPrivateKeyInfo parser. const availableCiphers = new Set(getCiphers()); +const passphrase = 'top secret'; const ciphers = [ 'aes-128-cbc', 'aes-192-cbc', 'aes-256-cbc', 'des-ede3-cbc', 'rc2-cbc', -].filter((c) => availableCiphers.has(c)); +].filter((cipher) => availableCiphers.has(cipher) && + (!hasFIPS(3) || cipher !== 'rc2-cbc')); + +if (hasFIPS(3)) { + const { privateKey } = generateKeyPairSync('ml-dsa-44'); + assert.throws(() => privateKey.export({ + type: 'pkcs8', + format: 'der', + cipher: 'rc2-cbc', + passphrase, + }), { code: 'ERR_OSSL_EVP_UNSUPPORTED' }); +} -const passphrase = 'top secret'; +const wrongPassphrase = 'wrong password'; const wrongPassphraseError = /bad decrypt|DECRYPTION_FAILED|BAD_DECRYPT|bad password|DECODE[ _]ERROR/i; // A wrong passphrase usually fails during cipher finalization, but CBC output // can have valid padding by chance. OpenSSL then parses the bad plaintext as // PKCS#8 and may report ASN.1 or decoder errors from the same failed import. function assertWrongPassphrase(fn) { - assert.throws(fn, (err) => wrongPassphraseError.test(err.message) || + assert.throws(fn, (err) => err.code === 'ERR_OSSL_BAD_DECRYPT' || + wrongPassphraseError.test(err.message) || err.code?.startsWith('ERR_OSSL_ASN1_') || err.code === 'ERR_OSSL_UNSUPPORTED'); } @@ -79,7 +92,7 @@ for (const asymmetricKeyType of algorithms) { key: encrypted, format, type: 'pkcs8', - passphrase: 'wrong', + passphrase: wrongPassphrase, })); } } @@ -128,7 +141,7 @@ for (const { alg, jwkFile, encBase } of fixtureCases) { key: encryptedFixture, format, type: 'pkcs8', - passphrase: 'wrong', + passphrase: wrongPassphrase, })); } } diff --git a/test/parallel/test-crypto-private-decrypt-gh32240.js b/test/parallel/test-crypto-private-decrypt-gh32240.js index 1ff5b565d6d5..a38fcba6775e 100644 --- a/test/parallel/test-crypto-private-decrypt-gh32240.js +++ b/test/parallel/test-crypto-private-decrypt-gh32240.js @@ -14,29 +14,57 @@ const { privateDecrypt, } = require('crypto'); -const { hasOpenSSL3 } = require('../common/crypto'); +const { + hasOpenSSL, + hasFIPS, +} = require('../common/crypto'); -const pair = generateKeyPairSync('rsa', { modulusLength: 512 }); +const fips3 = hasFIPS(3); +const fips4 = hasFIPS(4); +const pair = generateKeyPairSync('rsa', { + modulusLength: fips3 ? 2048 : 512, +}); const expected = Buffer.from('shibboleth'); -const encrypted = publicEncrypt(pair.publicKey, expected); +const options = fips3 ? { oaepHash: 'sha256' } : {}; +const encrypted = publicEncrypt({ key: pair.publicKey, ...options }, expected); const pkey = pair.privateKey.export({ type: 'pkcs1', format: 'pem' }); -const pkeyEncrypted = - pair.privateKey.export({ +if (fips3) { + assert.throws(() => pair.privateKey.export({ type: 'pkcs1', format: 'pem', cipher: 'aes-128-cbc', passphrase: 'secret', + }), { + code: 'ERR_OSSL_EVP_UNSUPPORTED', + }); +} +if (fips4) { + assert.throws(() => pair.privateKey.export({ + type: 'pkcs8', + format: 'pem', + cipher: 'aes-256-cbc', + passphrase: 'secret', + }), { + code: 'ERR_OSSL_PASSWORD_STRENGTH_TOO_WEAK', + }); +} +const pkeyEncrypted = + pair.privateKey.export({ + type: fips3 ? 'pkcs8' : 'pkcs1', + format: 'pem', + cipher: fips3 ? 'aes-256-cbc' : 'aes-128-cbc', + passphrase: 'password', }); function decrypt(key) { - const decrypted = privateDecrypt(key, encrypted); + const decrypted = privateDecrypt({ key, ...options }, encrypted); assert.deepStrictEqual(decrypted, expected); } decrypt(pkey); -assert.throws(() => decrypt(pkeyEncrypted), hasOpenSSL3 ? +assert.throws(() => decrypt(pkeyEncrypted), hasOpenSSL(3) ? { message: 'error:07880109:common libcrypto routines::interrupted or ' + 'cancelled' } : { code: 'ERR_MISSING_PASSPHRASE' }); diff --git a/test/parallel/test-crypto-publicDecrypt-fails-first-time.js b/test/parallel/test-crypto-publicDecrypt-fails-first-time.js index 1d64e08920c6..21cc5f3ebce2 100644 --- a/test/parallel/test-crypto-publicDecrypt-fails-first-time.js +++ b/test/parallel/test-crypto-publicDecrypt-fails-first-time.js @@ -7,15 +7,17 @@ if (!common.hasCrypto) { common.skip('missing crypto'); } -const { hasOpenSSL3 } = require('../common/crypto'); +const { hasOpenSSL } = require('../common/crypto'); -if (!hasOpenSSL3) { +if (!hasOpenSSL(3)) { common.skip('only openssl3'); // https://github.com/nodejs/node/pull/42793#issuecomment-1107491901 } const assert = require('assert'); const crypto = require('crypto'); +const passphrase = 'password'; + const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { @@ -26,7 +28,7 @@ const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { type: 'pkcs8', format: 'pem', cipher: 'aes-128-ecb', - passphrase: 'abcdef' + passphrase } }); assert.notStrictEqual(privateKey.toString(), ''); @@ -35,7 +37,7 @@ const msg = 'The quick brown fox jumps over the lazy dog'; const encryptedString = crypto.privateEncrypt({ key: privateKey, - passphrase: 'abcdef' + passphrase }, Buffer.from(msg)).toString('base64'); const decryptedString = crypto.publicDecrypt(publicKey, Buffer.from(encryptedString, 'base64')).toString(); console.log(`Encrypted: ${encryptedString}`); diff --git a/test/parallel/test-crypto-rsa-dsa.js b/test/parallel/test-crypto-rsa-dsa.js index 35ad67302077..bda98652cf59 100644 --- a/test/parallel/test-crypto-rsa-dsa.js +++ b/test/parallel/test-crypto-rsa-dsa.js @@ -9,7 +9,16 @@ const crypto = require('crypto'); const constants = crypto.constants; const fixtures = require('../common/fixtures'); -const { hasOpenSSL, hasOpenSSL3 } = require('../common/crypto'); +const { + hasOpenSSL, + hasFIPS, +} = require('../common/crypto'); +const fips3 = hasFIPS(3); +const fips35 = hasFIPS(3, 5); +const fips30 = fips3 && !fips35; +const fips4 = hasFIPS(4); +const fipsDigestErrorCode = 'ERR_OSSL_DIGEST_NOT_ALLOWED'; +const wrongPassphrase = 'wrong-password'; // Test certificates const certPem = fixtures.readKey('rsa_cert.crt'); @@ -17,17 +26,42 @@ const keyPem = fixtures.readKey('rsa_private.pem'); const rsaKeySize = 2048; const rsaPubPem = fixtures.readKey('rsa_public.pem', 'ascii'); const rsaKeyPem = fixtures.readKey('rsa_private.pem', 'ascii'); -const rsaKeyPemEncrypted = fixtures.readKey('rsa_private_encrypted.pem', - 'ascii'); +const rsaKeyPemEncryptedLegacy = fixtures.readKey( + 'rsa_private_encrypted.pem', 'ascii'); +const rsaKeyPemEncrypted = fips3 ? + crypto.createPrivateKey(rsaKeyPem).export({ + type: 'pkcs8', + format: 'pem', + cipher: 'aes-256-cbc', + passphrase: 'password', + }) : rsaKeyPemEncryptedLegacy; const dsaPubPem = fixtures.readKey('dsa_public.pem', 'ascii'); const dsaKeyPem = fixtures.readKey('dsa_private.pem', 'ascii'); -const dsaKeyPemEncrypted = fixtures.readKey('dsa_private_encrypted.pem', - 'ascii'); +const dsaKeyPemEncryptedLegacy = fixtures.readKey( + 'dsa_private_encrypted.pem', 'ascii'); +const dsaKeyPemEncrypted = fips3 ? + crypto.createPrivateKey(dsaKeyPem).export({ + type: 'pkcs8', + format: 'pem', + cipher: 'aes-256-cbc', + passphrase: 'password', + }) : dsaKeyPemEncryptedLegacy; const rsaPkcs8KeyPem = fixtures.readKey('rsa_private_pkcs8.pem'); const dsaPkcs8KeyPem = fixtures.readKey('dsa_private_pkcs8.pem'); const ec = new TextEncoder(); +if (fips3) { + for (const key of [rsaKeyPemEncryptedLegacy, dsaKeyPemEncryptedLegacy]) { + assert.throws(() => crypto.createPrivateKey({ + key, + passphrase: 'password', + }), { + code: 'ERR_OSSL_EVP_UNSUPPORTED', + }); + } +} + const openssl1DecryptError = { message: 'error:06065064:digital envelope routines:EVP_DecryptFinal_ex:' + 'bad decrypt', @@ -37,18 +71,21 @@ const openssl1DecryptError = { library: 'digital envelope routines', }; -const decryptError = hasOpenSSL3 ? - { message: 'error:1C800064:Provider routines::bad decrypt' } : - process.features.openssl_is_boringssl ? { - message: 'error:1e000065:Cipher functions:OPENSSL_internal:BAD_DECRYPT', - code: 'ERR_OSSL_BAD_DECRYPT', - reason: 'BAD_DECRYPT', - function: 'OPENSSL_internal', - library: 'Cipher functions', - } : - openssl1DecryptError; - -const decryptPrivateKeyError = hasOpenSSL3 ? { +const decryptError = fips4 ? + { code: 'ERR_OSSL_BAD_DECRYPT' } : hasOpenSSL(3) ? + { message: 'error:1C800064:Provider routines::bad decrypt' } : + process.features.openssl_is_boringssl ? { + message: 'error:1e000065:Cipher functions:OPENSSL_internal:BAD_DECRYPT', + code: 'ERR_OSSL_BAD_DECRYPT', + reason: 'BAD_DECRYPT', + function: 'OPENSSL_internal', + library: 'Cipher functions', + } : + openssl1DecryptError; + +const decryptPrivateKeyError = fips4 ? { + code: 'ERR_OSSL_BAD_DECRYPT', +} : hasOpenSSL(3) ? { message: 'error:1C800064:Provider routines::bad decrypt', } : process.features.openssl_is_boringssl ? { message: 'error:1e000065:Cipher functions:OPENSSL_internal:BAD_DECRYPT', @@ -156,7 +193,7 @@ function getBufferCopy(buf) { // Now with RSA_NO_PADDING. Plaintext needs to match key size. // OpenSSL 3.x has a rsa_check_padding that will cause an error if // RSA_NO_PADDING is used. - if (!hasOpenSSL3) { + if (!hasOpenSSL(3)) { { const plaintext = 'x'.repeat(rsaKeySize / 8); encryptedBuffer = crypto.privateEncrypt({ @@ -192,14 +229,14 @@ function getBufferCopy(buf) { assert.throws(() => { crypto.privateDecrypt({ key: rsaKeyPemEncrypted, - passphrase: 'wrong' + passphrase: wrongPassphrase }, bufferToEncrypt); }, decryptError); assert.throws(() => { crypto.publicEncrypt({ key: rsaKeyPemEncrypted, - passphrase: 'wrong' + passphrase: wrongPassphrase }, encryptedBuffer); }, decryptError); @@ -211,7 +248,7 @@ function getBufferCopy(buf) { assert.throws(() => { crypto.publicDecrypt({ key: rsaKeyPemEncrypted, - passphrase: Buffer.from('wrong') + passphrase: Buffer.from(wrongPassphrase) }, encryptedBuffer); }, decryptError); } @@ -349,8 +386,12 @@ test_rsa('RSA_PKCS1_OAEP_PADDING', 'sha256', 'sha256'); test_rsa('RSA_PKCS1_OAEP_PADDING', 'sha512', 'sha512'); assert.throws(() => { test_rsa('RSA_PKCS1_OAEP_PADDING', 'sha256', 'sha512'); -}, { - code: 'ERR_OSSL_RSA_OAEP_DECODING_ERROR' +}, fips35 ? { + code: 'ERR_OSSL_EVP_PROVIDER_ASYM_CIPHER_FAILURE', +} : fips3 ? { + message: 'error:00000000:lib(0)::reason(0)', +} : { + code: 'ERR_OSSL_RSA_OAEP_DECODING_ERROR', }); // The following RSA-OAEP test cases were created using the WebCrypto API to @@ -416,8 +457,9 @@ for (const fn of [crypto.publicEncrypt, crypto.privateDecrypt]) { } // Test RSA key signing/verification -let rsaSign = crypto.createSign('SHA1'); -let rsaVerify = crypto.createVerify('SHA1'); +const rsaDigest = fips3 ? 'SHA256' : 'SHA1'; +let rsaSign = crypto.createSign(rsaDigest); +let rsaVerify = crypto.createVerify(rsaDigest); assert.ok(rsaSign); assert.ok(rsaVerify); @@ -428,36 +470,39 @@ const expectedSignature = fixtures.readKey( rsaSign.update(rsaPubPem); let rsaSignature = rsaSign.sign(rsaKeyPem, 'hex'); -assert.strictEqual(rsaSignature, expectedSignature); +if (!fips3) + assert.strictEqual(rsaSignature, expectedSignature); rsaVerify.update(rsaPubPem); assert.strictEqual(rsaVerify.verify(rsaPubPem, rsaSignature, 'hex'), true); // Test RSA PKCS#8 key signing/verification -rsaSign = crypto.createSign('SHA1'); +rsaSign = crypto.createSign(rsaDigest); rsaSign.update(rsaPubPem); rsaSignature = rsaSign.sign(rsaPkcs8KeyPem, 'hex'); -assert.strictEqual(rsaSignature, expectedSignature); +if (!fips3) + assert.strictEqual(rsaSignature, expectedSignature); -rsaVerify = crypto.createVerify('SHA1'); +rsaVerify = crypto.createVerify(rsaDigest); rsaVerify.update(rsaPubPem); assert.strictEqual(rsaVerify.verify(rsaPubPem, rsaSignature, 'hex'), true); // Test RSA key signing/verification with encrypted key -rsaSign = crypto.createSign('SHA1'); +rsaSign = crypto.createSign(rsaDigest); rsaSign.update(rsaPubPem); const signOptions = { key: rsaKeyPemEncrypted, passphrase: 'password' }; rsaSignature = rsaSign.sign(signOptions, 'hex'); -assert.strictEqual(rsaSignature, expectedSignature); +if (!fips3) + assert.strictEqual(rsaSignature, expectedSignature); -rsaVerify = crypto.createVerify('SHA1'); +rsaVerify = crypto.createVerify(rsaDigest); rsaVerify.update(rsaPubPem); assert.strictEqual(rsaVerify.verify(rsaPubPem, rsaSignature, 'hex'), true); -rsaSign = crypto.createSign('SHA1'); +rsaSign = crypto.createSign(rsaDigest); rsaSign.update(rsaPubPem); assert.throws(() => { - const signOptions = { key: rsaKeyPemEncrypted, passphrase: 'wrong' }; + const signOptions = { key: rsaKeyPemEncrypted, passphrase: wrongPassphrase }; rsaSign.sign(signOptions, 'hex'); }, decryptPrivateKeyError); @@ -508,11 +553,12 @@ if (!process.features.openssl_is_boringssl) { // DSA signatures vary across runs so there is no static string to verify // against. - const sign = crypto.createSign('SHA1'); + const dsaDigest = fips3 ? 'SHA256' : 'SHA1'; + const sign = crypto.createSign(dsaDigest); sign.update(input); const signature = sign.sign(dsaKeyPem, 'hex'); - const verify = crypto.createVerify('SHA1'); + const verify = crypto.createVerify(dsaDigest); verify.update(input); assert.strictEqual(verify.verify(dsaPubPem, signature, 'hex'), true); @@ -520,12 +566,18 @@ if (!process.features.openssl_is_boringssl) { // Test the legacy 'DSS1' name. const sign2 = crypto.createSign('DSS1'); sign2.update(input); - const signature2 = sign2.sign(dsaKeyPem, 'hex'); + if (fips30) { + assert.throws(() => sign2.sign(dsaKeyPem, 'hex'), { + code: fipsDigestErrorCode, + }); + } else { + const signature2 = sign2.sign(dsaKeyPem, 'hex'); - const verify2 = crypto.createVerify('DSS1'); - verify2.update(input); + const verify2 = crypto.createVerify('DSS1'); + verify2.update(input); - assert.strictEqual(verify2.verify(dsaPubPem, signature2, 'hex'), true); + assert.strictEqual(verify2.verify(dsaPubPem, signature2, 'hex'), true); + } } else { common.printSkipMessage('Skipping unsupported DSA test case'); } @@ -539,11 +591,12 @@ if (!process.features.openssl_is_boringssl) { // DSA signatures vary across runs so there is no static string to verify // against. - const sign = crypto.createSign('SHA1'); + const dsaDigest = fips3 ? 'SHA256' : 'SHA1'; + const sign = crypto.createSign(dsaDigest); sign.update(input); const signature = sign.sign(dsaPkcs8KeyPem, 'hex'); - const verify = crypto.createVerify('SHA1'); + const verify = crypto.createVerify(dsaDigest); verify.update(input); assert.strictEqual(verify.verify(dsaPubPem, signature, 'hex'), true); @@ -558,22 +611,23 @@ if (!process.features.openssl_is_boringssl) { const input = 'I AM THE WALRUS'; { - const sign = crypto.createSign('SHA1'); + const sign = crypto.createSign(fips3 ? 'SHA256' : 'SHA1'); sign.update(input); assert.throws(() => { - sign.sign({ key: dsaKeyPemEncrypted, passphrase: 'wrong' }, 'hex'); + sign.sign({ key: dsaKeyPemEncrypted, passphrase: wrongPassphrase }, 'hex'); }, decryptPrivateKeyError); } if (!process.features.openssl_is_boringssl) { // DSA signatures vary across runs so there is no static string to verify // against. - const sign = crypto.createSign('SHA1'); + const dsaDigest = fips3 ? 'SHA256' : 'SHA1'; + const sign = crypto.createSign(dsaDigest); sign.update(input); const signOptions = { key: dsaKeyPemEncrypted, passphrase: 'password' }; const signature = sign.sign(signOptions, 'hex'); - const verify = crypto.createVerify('SHA1'); + const verify = crypto.createVerify(dsaDigest); verify.update(input); assert.strictEqual(verify.verify(dsaPubPem, signature, 'hex'), true); diff --git a/test/parallel/test-crypto-scrypt.js b/test/parallel/test-crypto-scrypt.js index 421ee4ce8f34..3012a8fa55d5 100644 --- a/test/parallel/test-crypto-scrypt.js +++ b/test/parallel/test-crypto-scrypt.js @@ -5,6 +5,8 @@ if (!common.hasCrypto) const assert = require('assert'); const crypto = require('crypto'); +const { hasFIPS } = require('../common/crypto'); +const isFips = hasFIPS(3); if (typeof crypto.scrypt !== 'function' || typeof crypto.scryptSync !== 'function') common.skip('no scrypt support'); @@ -159,13 +161,20 @@ const badargs = [ }, ]; -for (const options of good) { - const { pass, salt, keylen, expected } = options; - const actual = crypto.scryptSync(pass, salt, keylen, options); - assert.strictEqual(actual.toString('hex'), expected); - crypto.scrypt(pass, salt, keylen, options, common.mustSucceed((actual) => { +if (isFips) { + const expected = { code: 'ERR_CRYPTO_INVALID_SCRYPT_PARAMS' }; + assert.throws(() => crypto.scryptSync('pass', 'salt', 1), expected); + assert.throws( + () => crypto.scrypt('pass', 'salt', 1, () => {}), expected); +} else { + for (const options of good) { + const { pass, salt, keylen, expected } = options; + const actual = crypto.scryptSync(pass, salt, keylen, options); assert.strictEqual(actual.toString('hex'), expected); - })); + crypto.scrypt(pass, salt, keylen, options, common.mustSucceed((actual) => { + assert.strictEqual(actual.toString('hex'), expected); + })); + } } for (const options of bad) { @@ -191,7 +200,9 @@ for (const options of incompatibleOptions) { } for (const options of toobig) { - const expected = { + const expected = isFips ? { + code: 'ERR_CRYPTO_INVALID_SCRYPT_PARAMS', + } : { message: process.features.openssl_is_boringssl ? /Invalid scrypt params:.*(INVALID_PARAMETERS|MEMORY_LIMIT_EXCEEDED)/ : /Invalid scrypt params:.*memory limit exceeded/, @@ -203,7 +214,7 @@ for (const options of toobig) { expected); } -{ +if (!isFips) { const defaults = { N: 16384, p: 1, r: 8 }; const expected = crypto.scryptSync('pass', 'salt', 1, defaults); const actual = crypto.scryptSync('pass', 'salt', 1); @@ -229,10 +240,12 @@ for (const { args, expected } of badargs) { { // Values for maxmem that do not fit in 32 bits but that are still safe // integers should be allowed. - crypto.scrypt('', '', 4, { maxmem: 2 ** 52 }, - common.mustSucceed((actual) => { - assert.strictEqual(actual.toString('hex'), 'd72c87d0'); - })); + if (!isFips) { + crypto.scrypt('', '', 4, { maxmem: 2 ** 52 }, + common.mustSucceed((actual) => { + assert.strictEqual(actual.toString('hex'), 'd72c87d0'); + })); + } // Values that exceed Number.isSafeInteger should not be allowed. assert.throws(() => crypto.scryptSync('', '', 0, { maxmem: 2 ** 53 }), { @@ -240,7 +253,7 @@ for (const { args, expected } of badargs) { }); } -{ +if (!isFips) { // Regression test for https://github.com/nodejs/node/issues/28836. function testParameter(name, value) { @@ -299,5 +312,11 @@ for (const { args, expected } of badargs) { assert.deepStrictEqual(negResult, posResult); } - crypto.scrypt('', '', -0, common.mustCall()); + if (isFips) { + assert.throws( + () => crypto.scrypt('', '', -0, () => {}), + { code: 'ERR_CRYPTO_INVALID_SCRYPT_PARAMS' }); + } else { + crypto.scrypt('', '', -0, common.mustCall()); + } } diff --git a/test/parallel/test-crypto-secure-heap.js b/test/parallel/test-crypto-secure-heap.js index 3845f49a4748..8bd93c5281da 100644 --- a/test/parallel/test-crypto-secure-heap.js +++ b/test/parallel/test-crypto-secure-heap.js @@ -20,7 +20,7 @@ if (process.features.openssl_is_boringssl) { const assert = require('assert'); const { fork } = require('child_process'); const fixtures = require('../common/fixtures'); -const { hasOpenSSL3 } = require('../common/crypto'); +const { hasOpenSSL, hasFIPS } = require('../common/crypto'); const { secureHeapUsed, createDiffieHellman, @@ -38,7 +38,8 @@ if (process.argv[2] === 'child') { assert.strictEqual(a.used, 0); { - const size = getFips() || hasOpenSSL3 ? 1024 : 256; + const size = hasFIPS(3) ? + 2048 : (getFips() === 1 || hasOpenSSL(3) ? 1024 : 256); const dh1 = createDiffieHellman(size); const p1 = dh1.getPrime('buffer'); const dh2 = createDiffieHellman(p1, 'buffer'); diff --git a/test/parallel/test-crypto-sign-verify.js b/test/parallel/test-crypto-sign-verify.js index 527c39b30786..e8398c21bafd 100644 --- a/test/parallel/test-crypto-sign-verify.js +++ b/test/parallel/test-crypto-sign-verify.js @@ -10,6 +10,7 @@ const crypto = require('crypto'); const fixtures = require('../common/fixtures'); const { hasOpenSSL, + hasFIPS, opensslCli, } = require('../common/crypto'); @@ -17,6 +18,17 @@ const { const certPem = fixtures.readKey('rsa_cert.crt'); const keyPem = fixtures.readKey('rsa_private.pem'); const keySize = 2048; +const fips3 = hasFIPS(3); +const fips35 = hasFIPS(3, 5); +const fips30 = fips3 && !fips35; +const fipsDigestErrorCode = 'ERR_OSSL_DIGEST_NOT_ALLOWED'; +const signingHash = fips3 ? 'SHA256' : 'SHA1'; + +if (fips30) { + assert.throws( + () => crypto.createSign('SHA1').update('Test123').sign(keyPem), + { code: fipsDigestErrorCode }); +} { const Sign = crypto.Sign; @@ -60,7 +72,7 @@ const keySize = 2048; Object.defineProperty(Object.prototype, 'opensslErrorStack', errorStack); assert.throws(() => { - crypto.createSign('SHA1') + crypto.createSign('SHA256') .update('Test123') .sign({ key: keyPem, @@ -99,15 +111,15 @@ assert.throws( // Test signing and verifying { - const s1 = crypto.createSign('SHA1') + const s1 = crypto.createSign(signingHash) .update('Test123') .sign(keyPem, 'base64'); - let s1stream = crypto.createSign('SHA1'); + let s1stream = crypto.createSign(signingHash); s1stream.end('Test123'); s1stream = s1stream.sign(keyPem, 'base64'); assert.strictEqual(s1, s1stream, `${s1} should equal ${s1stream}`); - const verified = crypto.createVerify('SHA1') + const verified = crypto.createVerify(signingHash) .update('Test') .update('123') .verify(certPem, s1, 'base64'); @@ -138,16 +150,16 @@ assert.throws( } { - const s3 = crypto.createSign('SHA1') + const s3 = crypto.createSign(signingHash) .update('Test123') .sign(keyPem, 'buffer'); - let verified = crypto.createVerify('SHA1') + let verified = crypto.createVerify(signingHash) .update('Test') .update('123') .verify(certPem, s3); assert.strictEqual(verified, true); - const verStream = crypto.createVerify('SHA1'); + const verStream = crypto.createVerify(signingHash); verStream.write('Tes'); verStream.write('t12'); verStream.end('3'); @@ -190,6 +202,17 @@ assert.throws( const data = Buffer.from('Test123'); + if (fips30) { + const streamOptions = { + key: keyPem, + padding: crypto.constants.RSA_PKCS1_PSS_PADDING, + saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST, + }; + assert.throws( + () => crypto.createSign(algo).update(data).sign(streamOptions), + { code: fipsDigestErrorCode }); + } + signSaltLengths.forEach((signSaltLength) => { if (signSaltLength > max) { // If the salt length is too big, an Error should be thrown @@ -211,20 +234,23 @@ assert.throws( }, errMessage); } else { // Otherwise, a valid signature should be generated - const s4 = crypto.createSign(algo) - .update(data) - .sign({ - key: keyPem, - padding: crypto.constants.RSA_PKCS1_PSS_PADDING, - saltLength: signSaltLength - }); const s4_2 = crypto.sign(algo, data, { key: keyPem, padding: crypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: signSaltLength }); + const signatures = [s4_2]; + if (!fips30) { + signatures.unshift(crypto.createSign(algo) + .update(data) + .sign({ + key: keyPem, + padding: crypto.constants.RSA_PKCS1_PSS_PADDING, + saltLength: signSaltLength + })); + } - [s4, s4_2].forEach((sig) => { + signatures.forEach((sig) => { let verified; verifySaltLengths.forEach((verifySaltLength) => { // Verification should succeed if and only if the salt length is @@ -281,7 +307,8 @@ assert.throws( }); } - testPSS('SHA1', 20); + if (!fips30) + testPSS('SHA1', 20); testPSS('SHA256', 32); } @@ -340,7 +367,7 @@ assert.throws( }); assert.throws(() => { - crypto.createSign('SHA1') + crypto.createSign('SHA256') .update('Test123') .sign({ key: keyPem, @@ -365,7 +392,7 @@ assert.throws( // Test throws exception when key options is null { assert.throws(() => { - crypto.createSign('SHA1').update('Test123').sign(null, 'base64'); + crypto.createSign('SHA256').update('Test123').sign(null, 'base64'); }, { code: 'ERR_CRYPTO_SIGN_KEY_REQUIRED', name: 'Error' @@ -373,8 +400,8 @@ assert.throws( } { - const sign = crypto.createSign('SHA1'); - const verify = crypto.createVerify('SHA1'); + const sign = crypto.createSign('SHA256'); + const verify = crypto.createVerify('SHA256'); [1, [], {}, undefined, null, true, Infinity].forEach((input) => { const errObj = { @@ -441,7 +468,7 @@ for (const pair of [ { private: fixtures.readKey('rsa_private_2048.pem', 'ascii'), public: fixtures.readKey('rsa_public_2048.pem', 'ascii'), skip: false, - algo: 'sha1', + algo: signingHash, sigLen: 256, raw: false }, ]) { @@ -450,6 +477,7 @@ for (const pair of [ continue; } const algo = pair.algo; + const keyType = crypto.createPrivateKey(pair.private).asymmetricKeyType; { const data = Buffer.from('Hello world'); @@ -521,15 +549,34 @@ for (const pair of [ const sig = crypto.sign(algo, data, { key: pair.private, context }); assert.strictEqual(crypto.verify(algo, data, { key: pair.public }, sig), true); assert.strictEqual(crypto.verify(algo, data, { key: pair.public, context }, sig), true); - assert.strictEqual(crypto.verify(algo, data, { key: pair.public, context: crypto.randomBytes(30) }, sig), false); + const mismatchedContext = { key: pair.public, context: crypto.randomBytes(30) }; + if (fips35 && keyType === 'ed25519') { + assert.throws(() => crypto.verify(algo, data, mismatchedContext, sig), { + code: 'ERR_OSSL_INVALID_EDDSA_INSTANCE_FOR_ATTEMPTED_OPERATION', + }); + } else { + assert.strictEqual( + crypto.verify(algo, data, mismatchedContext, sig), false); + } } { const context = new Uint8Array(32); - const sig = crypto.sign(algo, data, { key: pair.private, context }); - assert.strictEqual(crypto.verify(algo, data, { key: pair.public }, sig), false); - assert.strictEqual(crypto.verify(algo, data, { key: pair.public, context }, sig), true); - assert.strictEqual(crypto.verify(algo, data, { key: pair.public, context: crypto.randomBytes(30) }, sig), false); + if (fips35 && keyType === 'ed25519') { + assert.throws( + () => crypto.sign(algo, data, { key: pair.private, context }), + { code: 'ERR_OSSL_INVALID_EDDSA_INSTANCE_FOR_ATTEMPTED_OPERATION' }); + } else { + const sig = crypto.sign(algo, data, { key: pair.private, context }); + assert.strictEqual( + crypto.verify(algo, data, { key: pair.public }, sig), false); + assert.strictEqual( + crypto.verify(algo, data, { key: pair.public, context }, sig), true); + assert.strictEqual(crypto.verify(algo, data, { + key: pair.public, + context: crypto.randomBytes(30), + }, sig), false); + } } assert.throws(() => crypto.sign(algo, data, { key: pair.private, context: new Uint8Array(256) }), { @@ -572,20 +619,27 @@ if (hasOpenSSL(3, 2)) { { const context = Buffer.from('my context'); - const sig = crypto.sign(null, data, { key: privKey, context }); - assert.strictEqual(sig.length, 64); + if (fips35) { + assert.throws(() => crypto.sign(null, data, { key: privKey, context }), { + code: 'ERR_OSSL_INVALID_EDDSA_INSTANCE_FOR_ATTEMPTED_OPERATION', + }); + } else { + const sig = crypto.sign(null, data, { key: privKey, context }); + assert.strictEqual(sig.length, 64); - // Verify with matching context succeeds - assert.strictEqual(crypto.verify(null, data, { key: pubKey, context }, sig), true); + // Verify with matching context succeeds + assert.strictEqual( + crypto.verify(null, data, { key: pubKey, context }, sig), true); - // Verify without context fails (Ed25519ctx !== Ed25519 pure) - assert.strictEqual(crypto.verify(null, data, { key: pubKey }, sig), false); + // Verify without context fails (Ed25519ctx !== Ed25519 pure) + assert.strictEqual(crypto.verify(null, data, { key: pubKey }, sig), false); - // Verify with wrong context fails - assert.strictEqual(crypto.verify(null, data, { - key: pubKey, - context: Buffer.from('wrong'), - }, sig), false); + // Verify with wrong context fails + assert.strictEqual(crypto.verify(null, data, { + key: pubKey, + context: Buffer.from('wrong'), + }, sig), false); + } } { @@ -662,7 +716,9 @@ MFkwEwYHKoZIzj0CAQYIKoEcz1UBgi0DQgAE1fiOx1BhdoAvpolZdyX46aGWlNoa { const data = Buffer.from('Hello world'); - const keys = [['ec-key.pem', 64], ['dsa_private_1025.pem', 40]]; + const dsaKey = fips3 ? + ['dsa_private.pem', 64] : ['dsa_private_1025.pem', 40]; + const keys = [['ec-key.pem', 64], dsaKey]; for (const [file, length] of keys) { if (process.features.openssl_is_boringssl && file.startsWith('dsa_')) { @@ -670,28 +726,29 @@ MFkwEwYHKoZIzj0CAQYIKoEcz1UBgi0DQgAE1fiOx1BhdoAvpolZdyX46aGWlNoa continue; } const privKey = fixtures.readKey(file); + const digest = fips3 ? 'sha256' : 'sha1'; [ - crypto.createSign('sha1').update(data).sign(privKey), - crypto.sign('sha1', data, privKey), - crypto.sign('sha1', data, { key: privKey, dsaEncoding: 'der' }), + crypto.createSign(digest).update(data).sign(privKey), + crypto.sign(digest, data, privKey), + crypto.sign(digest, data, { key: privKey, dsaEncoding: 'der' }), ].forEach((sig) => { // Signature length variability due to DER encoding assert(sig.length >= length + 4 && sig.length <= length + 8); assert.strictEqual( - crypto.createVerify('sha1').update(data).verify(privKey, sig), + crypto.createVerify(digest).update(data).verify(privKey, sig), true ); - assert.strictEqual(crypto.verify('sha1', data, privKey, sig), true); + assert.strictEqual(crypto.verify(digest, data, privKey, sig), true); }); // Test (EC)DSA signature conversion. const opts = { key: privKey, dsaEncoding: 'ieee-p1363' }; - let sig = crypto.sign('sha1', data, opts); + let sig = crypto.sign(digest, data, opts); // Unlike DER signatures, IEEE P1363 signatures have a predictable length. assert.strictEqual(sig.length, length); - assert.strictEqual(crypto.verify('sha1', data, opts, sig), true); - assert.strictEqual(crypto.createVerify('sha1') + assert.strictEqual(crypto.verify(digest, data, opts, sig), true); + assert.strictEqual(crypto.createVerify(digest) .update(data) .verify(opts, sig), true); @@ -700,7 +757,7 @@ MFkwEwYHKoZIzj0CAQYIKoEcz1UBgi0DQgAE1fiOx1BhdoAvpolZdyX46aGWlNoa sig = crypto.randomBytes(length + i); let result; try { - result = crypto.verify('sha1', data, opts, sig); + result = crypto.verify(digest, data, opts, sig); } catch (err) { assert.match(err.message, /asn1 encoding/); assert.strictEqual(err.library, 'asn1 encoding routines'); @@ -735,20 +792,20 @@ MFkwEwYHKoZIzj0CAQYIKoEcz1UBgi0DQgAE1fiOx1BhdoAvpolZdyX46aGWlNoa } // Non-(EC)DSA keys should ignore the option. - const sig = crypto.sign('sha1', data, { + const sig = crypto.sign(signingHash, data, { key: keyPem, dsaEncoding: 'ieee-p1363' }); - assert.strictEqual(crypto.verify('sha1', data, certPem, sig), true); + assert.strictEqual(crypto.verify(signingHash, data, certPem, sig), true); assert.strictEqual( - crypto.verify('sha1', data, { + crypto.verify(signingHash, data, { key: certPem, dsaEncoding: 'ieee-p1363' }, sig), true ); assert.strictEqual( - crypto.verify('sha1', data, { + crypto.verify(signingHash, data, { key: certPem, dsaEncoding: 'der' }, sig), @@ -757,7 +814,7 @@ MFkwEwYHKoZIzj0CAQYIKoEcz1UBgi0DQgAE1fiOx1BhdoAvpolZdyX46aGWlNoa for (const dsaEncoding of ['foo', null, {}, 5, true, NaN]) { assert.throws(() => { - crypto.sign('sha1', data, { + crypto.sign(signingHash, data, { key: certPem, dsaEncoding }); @@ -776,12 +833,13 @@ if (!opensslCli) { const privkey = fixtures.readKey('rsa_private_2048.pem'); const msg = 'Test123'; - const s5 = crypto.createSign('SHA256') - .update(msg) - .sign({ - key: privkey, - padding: crypto.constants.RSA_PKCS1_PSS_PADDING - }); + const options = { + key: privkey, + padding: crypto.constants.RSA_PKCS1_PSS_PADDING, + }; + const s5 = fips30 ? + crypto.sign('SHA256', Buffer.from(msg), options) : + crypto.createSign('SHA256').update(msg).sign(options); const tmpdir = require('../common/tmpdir'); tmpdir.refresh(); @@ -811,8 +869,14 @@ if (!process.features.openssl_is_boringssl) { const privateKey = crypto.createPrivateKey(privatePem); for (const key of [privatePem, privateKey]) { - // Any algorithm should work. - for (const algo of ['sha1', 'sha256']) { + if (fips30) { + assert.throws(() => crypto.sign('sha1', 'foo', key), { + code: fipsDigestErrorCode, + }); + } + // Any algorithm should work unless SHA-1 signing is unavailable. + const algorithms = fips30 ? ['sha256'] : ['sha1', 'sha256']; + for (const algo of algorithms) { // Any salt length should work. for (const saltLength of [undefined, 8, 10, 12, 16, 18, 20]) { const signature = crypto.sign(algo, 'foo', { key, saltLength }); @@ -847,7 +911,9 @@ if (!process.features.openssl_is_boringssl) { // Signing with anything other than sha256 should fail. assert.throws(() => { crypto.sign('sha1', 'foo', key); - }, /digest not allowed/); + }, fips30 ? { + code: fipsDigestErrorCode, + } : /digest not allowed/); // Signing with salt lengths less than 16 bytes should fail. for (const saltLength of [8, 10, 12]) { @@ -895,7 +961,9 @@ if (!process.features.openssl_is_boringssl) { for (const algo of ['sha1', 'sha256']) { assert.throws(() => { crypto.sign(algo, 'foo', key); - }, /digest not allowed/); + }, fips30 && algo === 'sha1' ? { + code: fipsDigestErrorCode, + } : /digest not allowed/); } // sha512 should produce a valid signature. @@ -915,15 +983,21 @@ if (!process.features.openssl_is_boringssl) { // The sign function should not swallow OpenSSL errors. // Regression test for https://github.com/nodejs/node/issues/40794. { - assert.throws(() => { - const { privateKey } = crypto.generateKeyPairSync('rsa', { - modulusLength: 512 + if (fips3) { + assert.throws(() => crypto.generateKeyPairSync('rsa', { + modulusLength: 512, + }), { code: 'ERR_OSSL_RSA_INVALID_MODULUS' }); + } else { + assert.throws(() => { + const { privateKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 512 + }); + crypto.sign('sha512', 'message', privateKey); + }, { + code: 'ERR_OSSL_RSA_DIGEST_TOO_BIG_FOR_RSA_KEY', + message: /digest too big for rsa key|DIGEST_TOO_BIG_FOR_RSA_KEY/ }); - crypto.sign('sha512', 'message', privateKey); - }, { - code: 'ERR_OSSL_RSA_DIGEST_TOO_BIG_FOR_RSA_KEY', - message: /digest too big for rsa key|DIGEST_TOO_BIG_FOR_RSA_KEY/ - }); + } } { diff --git a/test/parallel/test-crypto-worker-thread.js b/test/parallel/test-crypto-worker-thread.js index d9030d5cfc11..42027f3f2b44 100644 --- a/test/parallel/test-crypto-worker-thread.js +++ b/test/parallel/test-crypto-worker-thread.js @@ -9,6 +9,7 @@ const { generateKeySync, generateKeyPairSync, } = require('crypto'); +const { hasFIPS } = require('../common/crypto'); const { subtle } = globalThis.crypto; const assert = require('assert'); @@ -19,7 +20,7 @@ if (isMainThread) { (async () => { const secretKey = generateKeySync('aes', { length: 128 }); const { publicKey, privateKey } = generateKeyPairSync('rsa', { - modulusLength: 1024 + modulusLength: hasFIPS(3) ? 2048 : 1024 }); const cryptoKey = await subtle.generateKey( { name: 'AES-CBC', length: 128 }, false, ['encrypt']); diff --git a/test/parallel/test-crypto.js b/test/parallel/test-crypto.js index 46f4571b33df..2379ea692cac 100644 --- a/test/parallel/test-crypto.js +++ b/test/parallel/test-crypto.js @@ -29,7 +29,8 @@ const assert = require('assert'); const crypto = require('crypto'); const tls = require('tls'); const fixtures = require('../common/fixtures'); -const { hasOpenSSL3 } = require('../common/crypto'); +const { hasOpenSSL, hasFIPS } = require('../common/crypto'); +const isFips = hasFIPS(3); // Test Certificates const certPfx = fixtures.readKey('rsa_cert.pfx'); @@ -54,27 +55,35 @@ assert.throws(() => { }); // PFX tests -tls.createSecureContext({ pfx: certPfx, passphrase: 'sample' }); - -assert.throws(() => { - tls.createSecureContext({ pfx: certPfx }); -}, (err) => { - // Throws general Error, so there is no opensslErrorStack property. - return err instanceof Error && - err.name === 'Error' && - /^Error: (mac verify failure|INCORRECT_PASSWORD)$/.test(err) && - !('opensslErrorStack' in err); -}); +if (isFips) { + for (const passphrase of ['sample', undefined, 'test']) { + assert.throws( + () => tls.createSecureContext({ pfx: certPfx, passphrase }), + { code: 'ERR_CRYPTO_UNSUPPORTED_OPERATION' }); + } +} else { + tls.createSecureContext({ pfx: certPfx, passphrase: 'sample' }); + + assert.throws(() => { + tls.createSecureContext({ pfx: certPfx }); + }, (err) => { + // Throws general Error, so there is no opensslErrorStack property. + return err instanceof Error && + err.name === 'Error' && + /^Error: (mac verify failure|INCORRECT_PASSWORD)$/.test(err) && + !('opensslErrorStack' in err); + }); -assert.throws(() => { - tls.createSecureContext({ pfx: certPfx, passphrase: 'test' }); -}, (err) => { - // Throws general Error, so there is no opensslErrorStack property. - return err instanceof Error && - err.name === 'Error' && - /^Error: (mac verify failure|INCORRECT_PASSWORD)$/.test(err) && - !('opensslErrorStack' in err); -}); + assert.throws(() => { + tls.createSecureContext({ pfx: certPfx, passphrase: 'test' }); + }, (err) => { + // Throws general Error, so there is no opensslErrorStack property. + return err instanceof Error && + err.name === 'Error' && + /^Error: (mac verify failure|INCORRECT_PASSWORD)$/.test(err) && + !('opensslErrorStack' in err); + }); +} assert.throws(() => { tls.createSecureContext({ pfx: 'sample', passphrase: 'test' }); @@ -191,7 +200,7 @@ assert.throws( ); assert.throws( - () => crypto.createHmac('sha256', 'a secret').update('0', 'hex'), + () => crypto.createHmac('sha256', '0123456789abcdef').update('0', 'hex'), (error) => { assert.ok(!('opensslErrorStack' in error)); assert.throws(() => { throw error; }, encodingError); @@ -211,7 +220,11 @@ assert.throws(() => { ].join('\n'); crypto.createSign('SHA256').update('test').sign(priv); }, (err) => { - if (process.features.openssl_is_boringssl) { + if (isFips) { + assert.throws(() => { throw err; }, { + code: 'ERR_OSSL_INVALID_KEY_LENGTH', + }); + } else if (process.features.openssl_is_boringssl) { // BoringSSL rejects the tiny RSA key while decoding it, before signing. assert.throws(() => { throw err; }, { name: 'Error', @@ -225,9 +238,9 @@ assert.throws(() => { assert(Array.isArray(err.opensslErrorStack)); assert(err.opensslErrorStack.length > 0); } else { - if (!hasOpenSSL3) + if (!hasOpenSSL(3)) assert.ok(!('opensslErrorStack' in err)); - assert.throws(() => { throw err; }, hasOpenSSL3 ? { + assert.throws(() => { throw err; }, hasOpenSSL(3) ? { name: 'Error', message: 'error:02000070:rsa routines::digest too big for rsa key', library: 'rsa routines', @@ -243,7 +256,7 @@ assert.throws(() => { return true; }); -if (!hasOpenSSL3) { +if (!hasOpenSSL(3)) { // The correct header inside `rsa_private_pkcs8_bad.pem` should have been // -----BEGIN PRIVATE KEY----- and -----END PRIVATE KEY----- // instead of diff --git a/test/parallel/test-https-agent-additional-options.js b/test/parallel/test-https-agent-additional-options.js index 000cb9d3d0c2..3707855ed5c8 100644 --- a/test/parallel/test-https-agent-additional-options.js +++ b/test/parallel/test-https-agent-additional-options.js @@ -6,17 +6,20 @@ if (!common.hasCrypto) const assert = require('assert'); const crypto = require('crypto'); const https = require('https'); +const { hasFIPS } = require('../common/crypto'); const fixtures = require('../common/fixtures'); +const fips3 = hasFIPS(3); const options = { key: fixtures.readKey('agent1-key.pem'), cert: fixtures.readKey('agent1-cert.pem'), ca: fixtures.readKey('ca1-cert.pem'), - minVersion: 'TLSv1.1', + minVersion: fips3 ? 'TLSv1.2' : 'TLSv1.1', }; if (!process.features.openssl_is_boringssl) { - options.ciphers = 'ALL@SECLEVEL=0'; + options.ciphers = fips3 ? + 'ECDHE-RSA-AES256-GCM-SHA384' : 'ALL@SECLEVEL=0'; } const server = https.Server(options, (req, res) => { @@ -34,7 +37,8 @@ function getBaseOptions(port) { }; if (!process.features.openssl_is_boringssl) { - baseOptions.ciphers = 'ALL@SECLEVEL=0'; + baseOptions.ciphers = fips3 ? + 'ECDHE-RSA-AES256-GCM-SHA384' : 'ALL@SECLEVEL=0'; } return baseOptions; @@ -44,10 +48,11 @@ const updatedValues = new Map([ ['dhparam', fixtures.readKey('dh2048.pem')], ['ecdhCurve', 'secp384r1'], ['honorCipherOrder', true], - ['minVersion', 'TLSv1.1'], + ['minVersion', fips3 ? 'TLSv1.2' : 'TLSv1.1'], ['maxVersion', 'TLSv1.3'], ['secureOptions', crypto.constants.SSL_OP_CIPHER_SERVER_PREFERENCE], - ['secureProtocol', 'TLSv1_1_method'], + ['secureProtocol', fips3 ? + 'TLSv1_2_method' : 'TLSv1_1_method'], ['sessionIdContext', 'sessionIdContext'], ]); diff --git a/test/parallel/test-https-agent-pfx-object-array-reuse.js b/test/parallel/test-https-agent-pfx-object-array-reuse.js index 95134855e971..758c32e54008 100644 --- a/test/parallel/test-https-agent-pfx-object-array-reuse.js +++ b/test/parallel/test-https-agent-pfx-object-array-reuse.js @@ -6,21 +6,57 @@ if (!common.hasCrypto) const assert = require('assert'); const https = require('https'); +const { hasFIPS } = require('../common/crypto'); const fixtures = require('../common/fixtures'); +const fips3 = hasFIPS(3); +const fips35 = hasFIPS(3, 5); + +const onRequest = (req, res) => { + res.end(req.socket.getPeerCertificate().subject.CN); +}; +const requestHandler = fips3 && !fips35 ? + common.mustNotCall() : + common.mustCall(onRequest, fips3 ? 1 : 2); const server = https.createServer({ key: fixtures.readKey('agent2-key.pem'), cert: fixtures.readKey('agent2-cert.pem'), requestCert: true, rejectUnauthorized: false, -}, common.mustCall((req, res) => { - res.end(req.socket.getPeerCertificate().subject.CN); -}, 2)); +}, requestHandler); server.listen(0, common.mustCall(async () => { const agent = new https.Agent({ keepAlive: true, maxSockets: 1 }); const port = server.address().port; + if (fips3) { + await assert.rejects(request({ + agent, + port, + pfx: [{ buf: fixtures.readKey('agent1.pfx'), passphrase: 'sample' }], + }, false), { code: 'ERR_CRYPTO_UNSUPPORTED_OPERATION' }); + + if (!fips35) { + agent.destroy(); + server.close(); + return; + } + + const result = await request({ + agent, + port, + pfx: [{ + buf: fixtures.readKey('agent1-fips.pfx'), + passphrase: 'password', + }], + }); + assert.strictEqual(result.body, 'agent1'); + assert.strictEqual(result.reusedSocket, false); + agent.destroy(); + server.close(); + return; + } + const first = await request({ agent, port, @@ -41,19 +77,20 @@ server.listen(0, common.mustCall(async () => { server.close(); })); -function request(options) { +function request(options, expectResponse = true) { return new Promise((resolve, reject) => { - const req = https.get({ - ...options, - rejectUnauthorized: false, - }, common.mustCall((res) => { + const onResponse = expectResponse ? common.mustCall((res) => { let body = ''; res.setEncoding('utf8'); res.on('data', (chunk) => body += chunk); res.on('end', common.mustCall(() => { resolve({ body, reusedSocket: req.reusedSocket }); })); - })); + }) : common.mustNotCall(); + const req = https.get({ + ...options, + rejectUnauthorized: false, + }, onResponse); req.on('error', reject); }); } diff --git a/test/parallel/test-https-agent-session-eviction.js b/test/parallel/test-https-agent-session-eviction.js index de2790e0d0a3..971a8f359a6f 100644 --- a/test/parallel/test-https-agent-session-eviction.js +++ b/test/parallel/test-https-agent-session-eviction.js @@ -8,10 +8,11 @@ if (!common.hasCrypto) { } const fixtures = require('../common/fixtures'); -const { hasOpenSSL } = require('../common/crypto'); +const { hasOpenSSL, hasFIPS } = require('../common/crypto'); const https = require('https'); -const { SSL_OP_NO_TICKET } = require('crypto').constants; +const { constants: { SSL_OP_NO_TICKET } } = require('crypto'); +const fips3 = hasFIPS(3); const options = { key: fixtures.readKey('agent1-key.pem'), @@ -19,11 +20,17 @@ const options = { secureOptions: SSL_OP_NO_TICKET, }; +if (fips3) { + options.minVersion = 'TLSv1.3'; + options.maxVersion = 'TLSv1.3'; +} + if (!process.features.openssl_is_boringssl) { - options.ciphers = 'RSA@SECLEVEL=0'; + options.ciphers = fips3 ? + 'ECDHE-RSA-AES256-GCM-SHA384' : 'RSA@SECLEVEL=0'; } -// Create TLS1.2 server +// Create the initial server and cache a session from it. https.createServer(options, function(req, res) { res.writeHead(200, { 'Connection': 'close' }); res.end('ohai'); @@ -47,9 +54,14 @@ function first(server) { req.end(); } -// Create TLS1 server +// Create a server constrained to a different TLS version. function faultyServer(port) { - options.secureProtocol = 'TLSv1_method'; + if (fips3) { + options.minVersion = 'TLSv1.2'; + options.maxVersion = 'TLSv1.2'; + } else { + options.secureProtocol = 'TLSv1_method'; + } https.createServer(options, function(req, res) { res.writeHead(200, { 'Connection': 'close' }); res.end('hello faulty'); @@ -62,14 +74,15 @@ function faultyServer(port) { function second(server, session) { const req = https.request({ port: server.address().port, - ciphers: (hasOpenSSL(3, 1) ? 'DEFAULT:@SECLEVEL=0' : 'DEFAULT'), + ciphers: fips3 ? 'ECDHE-RSA-AES256-GCM-SHA384' : + (hasOpenSSL(3, 1) ? 'DEFAULT:@SECLEVEL=0' : 'DEFAULT'), rejectUnauthorized: false }, function(res) { res.resume(); }); - // Although we have a TLS 1.2 session to offer to the TLS 1.0 server, - // connection to the TLS 1.0 server should work. + // Offering the cached session to a server using another TLS version should + // not prevent a fresh connection. req.on('response', common.mustCall(function(res) { // The test is now complete for OpenSSL 1.1.0. server.close(); diff --git a/test/parallel/test-https-pfx.js b/test/parallel/test-https-pfx.js index 3c0aa82b3dbe..2be91887207d 100644 --- a/test/parallel/test-https-pfx.js +++ b/test/parallel/test-https-pfx.js @@ -29,6 +29,8 @@ const fixtures = require('../common/fixtures'); const assert = require('assert'); const https = require('https'); +const { hasFIPS } = require('../common/crypto'); +const fips3 = hasFIPS(3); const pfx = fixtures.readKey('rsa_cert.pfx'); @@ -42,10 +44,23 @@ const options = { rejectUnauthorized: false }; +if (fips3) { + assert.throws(() => https.createServer(options), { + code: 'ERR_CRYPTO_UNSUPPORTED_OPERATION', + }); + + if (!hasFIPS(3, 5)) { + return; + } + + options.pfx = fixtures.readKey('agent1-fips.pfx'); + options.passphrase = 'password'; +} + const server = https.createServer(options, common.mustCallAtLeast((req, res) => { - assert.strictEqual(req.socket.authorized, false); // not a client cert - assert.strictEqual(req.socket.authorizationError, - 'DEPTH_ZERO_SELF_SIGNED_CERT'); + assert.strictEqual(req.socket.authorized, fips3); + assert.strictEqual(req.socket.authorizationError, fips3 ? + null : 'DEPTH_ZERO_SELF_SIGNED_CERT'); res.writeHead(200); res.end('OK'); })); diff --git a/test/parallel/test-https-selfsigned-no-keycertsign-no-crash.js b/test/parallel/test-https-selfsigned-no-keycertsign-no-crash.js index 667be03d58ce..ec1b8dda8ca1 100644 --- a/test/parallel/test-https-selfsigned-no-keycertsign-no-crash.js +++ b/test/parallel/test-https-selfsigned-no-keycertsign-no-crash.js @@ -13,9 +13,10 @@ if (!common.hasCrypto) common.skip('missing crypto'); const crypto = require('crypto'); +const { hasOpenSSL } = require('../common/crypto'); // See #37990 for details on why this is problematic with FIPS. -if (process.config.variables.openssl_is_fips) +if (crypto.getFips() === 1 && !hasOpenSSL(3)) common.skip('Skipping as test uses non-fips compliant EC curve'); // This test will fail for OpenSSL < 1.1.1h diff --git a/test/parallel/test-tls-alert.js b/test/parallel/test-tls-alert.js index 64b7080e39ba..f20bf42a2dcd 100644 --- a/test/parallel/test-tls-alert.js +++ b/test/parallel/test-tls-alert.js @@ -27,6 +27,7 @@ if (!common.hasCrypto) { const { hasOpenSSL, + hasFIPS, opensslCli, } = require('../common/crypto'); @@ -43,11 +44,18 @@ function loadPEM(n) { return fixtures.readKey(`${n}.pem`); } -const server = tls.Server({ - secureProtocol: 'TLSv1_2_server_method', +const serverOptions = { key: loadPEM('agent2-key'), - cert: loadPEM('agent2-cert') -}, null).listen(0, common.mustCall(() => { + cert: loadPEM('agent2-cert'), +}; +if (hasFIPS(3)) { + serverOptions.minVersion = 'TLSv1.3'; + serverOptions.maxVersion = 'TLSv1.3'; +} else { + serverOptions.secureProtocol = 'TLSv1_2_server_method'; +} + +const server = tls.Server(serverOptions, null).listen(0, common.mustCall(() => { if (process.features.openssl_is_boringssl) { let gotClientError = false; let gotServerError = false; @@ -75,8 +83,9 @@ const server = tls.Server({ return; } - const args = ['s_client', '-quiet', '-tls1_1', - '-cipher', (hasOpenSSL(3, 1) ? 'DEFAULT:@SECLEVEL=0' : 'DEFAULT'), + const args = ['s_client', '-quiet', hasFIPS(3) ? '-tls1_2' : '-tls1_1', + '-cipher', hasFIPS(3) ? 'DEFAULT' : + (hasOpenSSL(3, 1) ? 'DEFAULT:@SECLEVEL=0' : 'DEFAULT'), '-connect', `127.0.0.1:${server.address().port}`]; execFile(opensslCli, args, common.mustCall((err, _, stderr) => { diff --git a/test/parallel/test-tls-client-getephemeralkeyinfo.js b/test/parallel/test-tls-client-getephemeralkeyinfo.js index ea6dec7bdc46..db41fdf6a098 100644 --- a/test/parallel/test-tls-client-getephemeralkeyinfo.js +++ b/test/parallel/test-tls-client-getephemeralkeyinfo.js @@ -9,7 +9,7 @@ if (process.features.openssl_is_boringssl) { } const fixtures = require('../common/fixtures'); -const { hasOpenSSL } = require('../common/crypto'); +const { hasOpenSSL, hasFIPS } = require('../common/crypto'); const assert = require('assert'); const { X509Certificate } = require('crypto'); @@ -17,12 +17,14 @@ const tls = require('tls'); const key = fixtures.readKey('agent2-key.pem'); const cert = fixtures.readKey('agent2-cert.pem'); +const fips3 = hasFIPS(3); +const rejectsXCurves = hasFIPS(3, 5); function loadDHParam(n) { return fixtures.readKey(`dh${n}.pem`); } -function test(size, type, name, cipher) { +function test(size, type, name, cipher, expectError = false) { assert(cipher); const options = { @@ -49,39 +51,62 @@ function test(size, type, name, cipher) { } } - const server = tls.createServer(options, common.mustCall((conn) => { - assert.strictEqual(conn.getEphemeralKeyInfo(), null); - conn.end(); - })); + if (rejectsXCurves && (name === 'X25519' || name === 'X448')) { + assert.throws(() => tls.createServer(options), { + code: 'ERR_CRYPTO_OPERATION_FAILED', + }); + return; + } + + const onConnection = expectError ? common.mustNotCall() : + common.mustCall((conn) => { + assert.strictEqual(conn.getEphemeralKeyInfo(), null); + conn.end(); + }); + const server = tls.createServer(options, onConnection); server.on('close', common.mustSucceed()); server.listen(0, common.mustCall(() => { + const onSecureConnect = expectError ? common.mustNotCall() : + common.mustCall(function() { + const ekeyinfo = client.getEphemeralKeyInfo(); + assert.strictEqual(ekeyinfo.type, type); + assert.strictEqual(ekeyinfo.size, size); + assert.strictEqual(ekeyinfo.name, name); + server.close(); + }); const client = tls.connect({ port: server.address().port, rejectUnauthorized: false - }, common.mustCall(function() { - const ekeyinfo = client.getEphemeralKeyInfo(); - assert.strictEqual(ekeyinfo.type, type); - assert.strictEqual(ekeyinfo.size, size); - assert.strictEqual(ekeyinfo.name, name); - server.close(); - })); - client.on('secureConnect', common.mustCall()); + }, onSecureConnect); + if (expectError) { + client.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'ERR_SSL_BAD_DH_VALUE'); + server.close(); + })); + } else { + client.on('secureConnect', common.mustCall()); + } })); } -test(undefined, undefined, undefined, 'AES256-SHA256'); +if (!fips3) + test(undefined, undefined, undefined, 'AES256-SHA256'); test('auto', 'DH', undefined, 'DHE-RSA-AES256-GCM-SHA384'); -if (hasOpenSSL(4, 0)) { - // OpenSSL 4.0 implements RFC 7919 FFDHE negotiation for TLS 1.2 and - // always selects FFDHE-2048 regardless of the server-supplied dhparam. -} else if (!hasOpenSSL(3, 2)) { - test(1024, 'DH', undefined, 'DHE-RSA-AES256-GCM-SHA384'); +if (fips3 && !hasOpenSSL(4)) { + test(2048, 'DH', undefined, 'DHE-RSA-AES256-GCM-SHA384', true); } else { - test(3072, 'DH', undefined, 'DHE-RSA-AES256-GCM-SHA384'); + if (hasOpenSSL(4, 0)) { + // OpenSSL 4.0 implements RFC 7919 FFDHE negotiation for TLS 1.2 and + // always selects FFDHE-2048 regardless of the server-supplied dhparam. + } else if (!hasOpenSSL(3, 2)) { + test(1024, 'DH', undefined, 'DHE-RSA-AES256-GCM-SHA384'); + } else { + test(3072, 'DH', undefined, 'DHE-RSA-AES256-GCM-SHA384'); + } + test(2048, 'DH', undefined, 'DHE-RSA-AES256-GCM-SHA384'); } -test(2048, 'DH', undefined, 'DHE-RSA-AES256-GCM-SHA384'); test(256, 'ECDH', 'prime256v1', 'ECDHE-RSA-AES256-GCM-SHA384'); test(521, 'ECDH', 'secp521r1', 'ECDHE-RSA-AES256-GCM-SHA384'); test(253, 'ECDH', 'X25519', 'ECDHE-RSA-AES256-GCM-SHA384'); @@ -96,6 +121,17 @@ function testTLS13Group(size, type, name) { maxVersion: 'TLSv1.3', }; + const unsupportedFipsGroup = + (rejectsXCurves && name === 'X25519') || + (hasFIPS(4) && + (name === 'curveSM2' || name === 'curveSM2MLKEM768')); + if (unsupportedFipsGroup) { + assert.throws(() => tls.createServer(options), { + code: 'ERR_CRYPTO_OPERATION_FAILED', + }); + return; + } + const server = tls.createServer(options, common.mustCall((conn) => { assert.strictEqual(conn.getEphemeralKeyInfo(), null); conn.end(); @@ -121,6 +157,8 @@ function testTLS13Group(size, type, name) { })); } +if (fips3) + testTLS13Group(256, 'ECDH', 'prime256v1'); testTLS13Group(253, 'ECDH', 'X25519'); if (hasOpenSSL(3, 5)) { diff --git a/test/parallel/test-tls-client-mindhsize.js b/test/parallel/test-tls-client-mindhsize.js index d777a9bfa97f..8f3b2eafbb8a 100644 --- a/test/parallel/test-tls-client-mindhsize.js +++ b/test/parallel/test-tls-client-mindhsize.js @@ -13,7 +13,9 @@ const secLevel = require('internal/crypto/util').getOpenSSLSecLevel(); const assert = require('assert'); const tls = require('tls'); const fixtures = require('../common/fixtures'); -const { hasOpenSSL } = require('../common/crypto'); +const { hasOpenSSL, hasFIPS } = require('../common/crypto'); +const fips3 = hasFIPS(3); +const fips4 = hasFIPS(4); const key = fixtures.readKey('agent2-key.pem'); const cert = fixtures.readKey('agent2-cert.pem'); @@ -29,7 +31,7 @@ function test(size, err, next, minDHSizeOverride) { const options = { key: key, cert: cert, - dhparam: loadDHParam(size), + dhparam: size === 'auto' ? 'auto' : loadDHParam(size), ciphers: 'DHE-RSA-AES128-GCM-SHA256' }; @@ -60,7 +62,8 @@ function test(size, err, next, minDHSizeOverride) { if (err) { client.on('error', common.mustCall((e) => { nerror++; - assert.strictEqual(e.code, 'ERR_TLS_DH_PARAM_SIZE'); + assert.strictEqual(e.code, fips3 && !fips4 ? + 'ERR_SSL_BAD_DH_VALUE' : 'ERR_TLS_DH_PARAM_SIZE'); server.close(); })); } @@ -86,7 +89,11 @@ function testDHE3072() { } if (!process.features.openssl_is_boringssl) { - if (hasOpenSSL(4, 0)) { + if (fips3 && !fips4) { + // The FIPS provider rejects explicit DH parameters without a validated + // subgroup, while OpenSSL's built-in FFDHE group remains available. + testDHE2048(true, () => test('auto', false, null, 2048)); + } else if (hasOpenSSL(4, 0)) { // OpenSSL 4.0 implements RFC 7919 FFDHE negotiation for TLS 1.2 and // ignores the server-supplied dhparam in favor of FFDHE-2048. The 3072 // success case is therefore replaced by a 2048 success case. diff --git a/test/parallel/test-tls-dhe.js b/test/parallel/test-tls-dhe.js index 83af6daccbd0..65f3dc6867c4 100644 --- a/test/parallel/test-tls-dhe.js +++ b/test/parallel/test-tls-dhe.js @@ -34,6 +34,7 @@ if (process.features.openssl_is_boringssl) { const { opensslCli, hasOpenSSL, + hasFIPS, } = require('../common/crypto'); // OpenSSL has a set of security levels which affect what algorithms @@ -62,7 +63,7 @@ const dheCipher = 'DHE-RSA-AES128-SHA256'; const ecdheCipher = 'ECDHE-RSA-AES128-SHA256'; const ciphers = `${dheCipher}:${ecdheCipher}`; -if (secLevel < 2) { +if (secLevel < 2 && !hasFIPS(3)) { // Test will emit a warning because the DH parameter size is < 2048 bits // when the test is run on versions lower than OpenSSL32 common.expectWarning('SecurityWarning', @@ -74,7 +75,7 @@ function loadDHParam(n) { return fixtures.readKey(keyname); } -function test(dhparam, keylen, expectedCipher) { +function test(dhparam, keylen, expectedCipher, expectedError) { const options = { key, cert, @@ -84,12 +85,29 @@ function test(dhparam, keylen, expectedCipher) { }; const server = tls.createServer(options, (conn) => conn.end()); + if (typeof expectedError === 'string' || Array.isArray(expectedError)) { + server.once('tlsClientError', common.mustCall((err) => { + if (Array.isArray(expectedError)) { + assert.ok(expectedError.includes(err.code), err); + } else { + assert.strictEqual(err.code, expectedError); + } + })); + } server.listen(0, '127.0.0.1', common.mustCall(() => { const args = ['s_client', '-connect', `127.0.0.1:${server.address().port}`, '-cipher', `${ciphers}:@SECLEVEL=1`]; - execFile(opensslCli, args, common.mustSucceed((stdout) => { + execFile(opensslCli, args, common.mustCall((err, stdout, stderr) => { + if (expectedError) { + assert.strictEqual(err?.code, 1); + if (expectedError instanceof RegExp) assert.match(stderr, expectedError); + server.close(); + return; + } + + assert.ifError(err); assert(keylen === null || // s_client < OpenSSL 3.5 stdout.includes(`Server Temp Key: DH, ${keylen} bits`) || @@ -103,10 +121,10 @@ function test(dhparam, keylen, expectedCipher) { return once(server, 'close'); } -function testCustomParam(keylen, expectedCipher) { +function testCustomParam(keylen, expectedCipher, expectedError) { const dhparam = loadDHParam(keylen); if (keylen === 'error') keylen = null; - return test(dhparam, keylen, expectedCipher); + return test(dhparam, keylen, expectedCipher, expectedError); } (async () => { @@ -140,14 +158,29 @@ function testCustomParam(keylen, expectedCipher) { // OpenSSL 4.0 implements RFC 7919 FFDHE negotiation for TLS 1.2 and // ignores the server-supplied dhparam in favor of FFDHE-2048, so the // negotiated key length is always 2048. - if (secLevel < 2) { - await testCustomParam(1024, dheCipher); - } else if (hasOpenSSL(4, 0)) { - await test(loadDHParam(3072), 2048, dheCipher); + if (hasFIPS(3)) { + if (hasFIPS(4)) { + await test(loadDHParam(3072), 2048, dheCipher); + await testCustomParam(2048, dheCipher); + } else { + const errorCode = hasFIPS(3, 5) ? + [ + 'ERR_SSL_INVALID_KEY_LENGTH', + 'ERR_SSL_SSL/TLS_ALERT_ILLEGAL_PARAMETER', + ] : 'ERR_SSL_INTERNAL_ERROR'; + await testCustomParam(3072, null, errorCode); + await testCustomParam(2048, null, errorCode); + } } else { - await testCustomParam(3072, dheCipher); + if (secLevel < 2) { + await testCustomParam(1024, dheCipher); + } else if (hasOpenSSL(4, 0)) { + await test(loadDHParam(3072), 2048, dheCipher); + } else { + await testCustomParam(3072, dheCipher); + } + await testCustomParam(2048, dheCipher); } - await testCustomParam(2048, dheCipher); // Invalid DHE parameters are discarded. Prior to OpenSSL 4.0 this // disabled DHE and ECDHE was negotiated; since 4.0, FFDHE-2048 is used. diff --git a/test/parallel/test-tls-ecdh-multiple.js b/test/parallel/test-tls-ecdh-multiple.js index ed60044197d7..b01051d5b457 100644 --- a/test/parallel/test-tls-ecdh-multiple.js +++ b/test/parallel/test-tls-ecdh-multiple.js @@ -8,7 +8,11 @@ if (!common.hasCrypto) { common.skip('missing crypto'); } -const { opensslCli, hasOpenSSL } = require('../common/crypto'); +const { + opensslCli, + hasOpenSSL, + hasFIPS, +} = require('../common/crypto'); const crypto = require('crypto'); if (!opensslCli) { @@ -19,14 +23,16 @@ const assert = require('assert'); const tls = require('tls'); const { execFile } = require('child_process'); const fixtures = require('../common/fixtures'); +const fips3 = hasFIPS(3); function loadPEM(n) { return fixtures.readKey(`${n}.pem`); } -// OpenSSL 4.0 disables support for deprecated elliptic curves from RFC 8422 -// (including secp256k1) by default. -const ecdhCurve = process.features.openssl_is_boringssl || hasOpenSSL(4, 0) ? +// The FIPS provider and OpenSSL 4.0 disable support for deprecated elliptic +// curves from RFC 8422 (including secp256k1) by default. +const ecdhCurve = process.features.openssl_is_boringssl || + hasOpenSSL(4, 0) || hasFIPS(3) ? 'prime256v1:secp521r1' : 'secp256k1:prime256v1:secp521r1'; @@ -61,8 +67,17 @@ const server = tls.createServer(options, (conn) => { 'prime192v3', ]; - // Brainpool is not supported in FIPS mode. - if (crypto.getFips()) { + // Setting a Brainpool group on a TLS context is deferred by OpenSSL, so + // exercise the prohibited key operation directly under FIPS properties. + if (fips3) { + if (hasFIPS(3, 5)) { + assert.throws( + () => crypto.createECDH('brainpoolP256r1').generateKeys(), + { code: 'ERR_CRYPTO_OPERATION_FAILED' }); + } else { + unsupportedCurves.push('brainpoolP256r1'); + } + } else if (crypto.getFips() === 1) { unsupportedCurves.push('brainpoolP256r1'); } diff --git a/test/parallel/test-tls-env-extra-ca-with-options.js b/test/parallel/test-tls-env-extra-ca-with-options.js index 8f04decf670c..5775284eea44 100644 --- a/test/parallel/test-tls-env-extra-ca-with-options.js +++ b/test/parallel/test-tls-env-extra-ca-with-options.js @@ -8,7 +8,9 @@ if (!common.hasCrypto) const assert = require('node:assert'); const tls = require('node:tls'); const { fork } = require('node:child_process'); +const { hasFIPS } = require('../common/crypto'); const fixtures = require('../common/fixtures'); +const fips3 = hasFIPS(3); const tests = [ { @@ -28,13 +30,26 @@ const tests = [ crl: fixtures.readKey('ca2-crl.pem') } }, - { +]; + +if (fips3) { + assert.throws(() => tls.createSecureContext({ + pfx: fixtures.readKey('agent1.pfx'), + passphrase: 'sample', + }), { + code: 'ERR_CRYPTO_UNSUPPORTED_OPERATION', + }); +} + +if (!fips3 || hasFIPS(3, 5)) { + tests.push({ clientOptions: { - pfx: fixtures.readKey('agent1.pfx'), - passphrase: 'sample' + pfx: fixtures.readKey(fips3 ? + 'agent1-fips.pfx' : 'agent1.pfx'), + passphrase: fips3 ? 'password' : 'sample' } - }, -]; + }); +} if (process.argv[2]) { const testNumber = parseInt(process.argv[2], 10); diff --git a/test/parallel/test-tls-getprotocol.js b/test/parallel/test-tls-getprotocol.js index 2945ff99b5a2..2fe971444271 100644 --- a/test/parallel/test-tls-getprotocol.js +++ b/test/parallel/test-tls-getprotocol.js @@ -3,7 +3,7 @@ const common = require('../common'); if (!common.hasCrypto) common.skip('missing crypto'); -const { hasOpenSSL } = require('../common/crypto'); +const { hasOpenSSL, hasFIPS } = require('../common/crypto'); // This test ensures that `getProtocol` returns the right protocol // from a TLS connection @@ -11,6 +11,7 @@ const { hasOpenSSL } = require('../common/crypto'); const assert = require('assert'); const tls = require('tls'); const fixtures = require('../common/fixtures'); +const fips3 = hasFIPS(3); let clientConfigs = [ { @@ -42,27 +43,50 @@ const serverConfig = { }; if (!process.features.openssl_is_boringssl) { - serverConfig.ciphers = 'RSA@SECLEVEL=0'; + serverConfig.ciphers = fips3 ? + 'ECDHE-RSA-AES256-GCM-SHA384' : 'RSA@SECLEVEL=0'; } -const server = tls.createServer(serverConfig, common.mustCall(clientConfigs.length)) +const expectedConnections = fips3 ? 1 : clientConfigs.length; +const server = tls.createServer(serverConfig, common.mustCall(expectedConnections)); + +if (fips3) { + server.on('tlsClientError', common.mustCall((err) => { + assert.ok([ + 'ERR_SSL_NO_SUITABLE_DIGEST_ALGORITHM', + 'ERR_SSL_UNEXPECTED_MESSAGE', + ].includes(err.code), err); + }, 2)); +} + +server .listen(0, common.localhostIPv4, common.mustCall(function() { - let connected = 0; + let completed = 0; + function done() { + if (++completed === clientConfigs.length) + server.close(); + } + for (const v of clientConfigs) { - tls.connect({ + const shouldConnect = !fips3 || v.version === 'TLSv1.2'; + const client = tls.connect({ host: common.localhostIPv4, port: server.address().port, ciphers: v.ciphers, rejectUnauthorized: false, secureProtocol: v.secureProtocol - }, common.mustCall(function() { + }, shouldConnect ? common.mustCall(function() { assert.strictEqual(this.getProtocol(), v.version); this.on('end', common.mustCall()); this.on('close', common.mustCall(function() { assert.strictEqual(this.getProtocol(), null); + done(); })).end(); - if (++connected === clientConfigs.length) - server.close(); - })); + }) : common.mustNotCall()); + + if (!shouldConnect) { + client.on('error', common.mustCall((err) => assert(err.code))); + client.on('close', common.mustCall(done)); + } } })); diff --git a/test/parallel/test-tls-honorcipherorder.js b/test/parallel/test-tls-honorcipherorder.js index d86a59aa4cdc..fbee483d83b9 100644 --- a/test/parallel/test-tls-honorcipherorder.js +++ b/test/parallel/test-tls-honorcipherorder.js @@ -8,6 +8,7 @@ if (!common.hasCrypto) common.skip('missing crypto'); const assert = require('assert'); +const { hasFIPS } = require('../common/crypto'); const mustCall = common.mustCall; const tls = require('tls'); const util = require('util'); @@ -16,7 +17,7 @@ const util = require('util'); // default method is updated in the future const SSL_Method = 'TLSv1_2_method'; const localhost = '127.0.0.1'; -const config = process.features.openssl_is_boringssl ? { +const config = process.features.openssl_is_boringssl || hasFIPS(3) ? { serverCiphers: 'ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256', clientPreferenceCiphers: diff --git a/test/parallel/test-tls-invalid-pfx.js b/test/parallel/test-tls-invalid-pfx.js index c16858f0f788..749d9e8b9f76 100644 --- a/test/parallel/test-tls-invalid-pfx.js +++ b/test/parallel/test-tls-invalid-pfx.js @@ -7,6 +7,7 @@ const fixtures = require('../common/fixtures'); const { assert, connect, keys } = require(fixtures.path('tls-connect')); +const { hasFIPS } = require('../common/crypto'); const invalidPfx = fixtures.readKey('cert-without-key.pfx'); @@ -18,6 +19,10 @@ connect({ }, server: keys.agent1 }, common.mustCall((e, pair, cleanup) => { - assert.strictEqual(e.message, 'Unable to load private key from PFX data'); + if (hasFIPS(3)) { + assert.strictEqual(e.code, 'ERR_CRYPTO_UNSUPPORTED_OPERATION'); + } else { + assert.strictEqual(e.message, 'Unable to load private key from PFX data'); + } cleanup(); })); diff --git a/test/parallel/test-tls-min-max-version.js b/test/parallel/test-tls-min-max-version.js index 88dce9f4b8e6..83797238cf4b 100644 --- a/test/parallel/test-tls-min-max-version.js +++ b/test/parallel/test-tls-min-max-version.js @@ -12,7 +12,7 @@ if (process.features.openssl_is_boringssl) { const { hasOpenSSL, - hasOpenSSL3, + hasFIPS, } = require('../common/crypto'); const fixtures = require('../common/fixtures'); const { inspect } = require('util'); @@ -29,8 +29,24 @@ const DEFAULT_MAX_VERSION = tls.DEFAULT_MAX_VERSION; function test(cmin, cmax, cprot, smin, smax, sprot, proto, cerr, serr) { assert(proto || cerr || serr, 'test missing any expectations'); + const legacyProtocols = new Set([ + 'TLSv1', + 'TLSv1.1', + 'TLSv1_method', + 'TLSv1_1_method', + ]); + const expectedLegacyProtocol = proto === 'TLSv1' || proto === 'TLSv1.1'; + const legacyOnlyConfiguration = [cprot, sprot, cmax, smax] + .some((value) => legacyProtocols.has(value)); + const fipsLegacyFailure = hasFIPS(3) && + (expectedLegacyProtocol || (!proto && legacyOnlyConfiguration)); + + if (hasFIPS(3) && expectedLegacyProtocol) { + proto = undefined; + } + let ciphers; - if (hasOpenSSL3 && (proto === 'TLSv1' || proto === 'TLSv1.1' || + if (hasOpenSSL(3) && (proto === 'TLSv1' || proto === 'TLSv1.1' || proto === 'TLSv1_1_method' || proto === 'TLSv1_method' || sprot === 'TLSv1_1_method' || sprot === 'TLSv1_method')) { if (serr !== 'ERR_SSL_UNSUPPORTED_PROTOCOL') @@ -65,6 +81,27 @@ function test(cmin, cmax, cprot, smin, smax, sprot, proto, cerr, serr) { console.log('test:', u(cmin), u(cmax), u(cprot), u(smin), u(smax), u(sprot), u(ciphers), 'expect', u(proto), u(cerr), u(serr)); console.log(' ', where); + if (fipsLegacyFailure) { + const errors = [pair.client.err, pair.server.err].filter(Boolean); + assert(errors.length > 0); + const expectedCodes = new Set([ + 'ERR_SSL_NO_PROTOCOLS_AVAILABLE', + 'ERR_SSL_NO_SUITABLE_DIGEST_ALGORITHM', + 'ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE', + 'ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE', + 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION', + 'ERR_SSL_UNEXPECTED_MESSAGE', + 'ERR_SSL_UNSUPPORTED_PROTOCOL', + 'ERR_SSL_VERSION_TOO_LOW', + 'ERR_SSL_WRONG_VERSION_NUMBER', + ]); + if (hasFIPS(4)) + expectedCodes.add('ERR_SSL_TLS_ALERT_HANDSHAKE_FAILURE'); + for (const error of errors) + assert(expectedCodes.has(error.code), error); + return cleanup(); + } + if (!proto) { console.log('client', pair.client.err ? pair.client.err.code : undefined); console.log('server', pair.server.err ? pair.server.err.code : undefined); @@ -139,9 +176,9 @@ test(U, U, 'TLS_method', U, U, 'TLSv1_method', 'TLSv1'); // OpenSSL 1.1.1 and 3.0 use a different error code and alert (sent to the // client) when no protocols are enabled on the server. -const NO_PROTOCOLS_AVAILABLE_SERVER = hasOpenSSL3 ? +const NO_PROTOCOLS_AVAILABLE_SERVER = hasOpenSSL(3) ? 'ERR_SSL_NO_PROTOCOLS_AVAILABLE' : 'ERR_SSL_INTERNAL_ERROR'; -const NO_PROTOCOLS_AVAILABLE_SERVER_ALERT = hasOpenSSL3 ? +const NO_PROTOCOLS_AVAILABLE_SERVER_ALERT = hasOpenSSL(3) ? 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION' : 'ERR_SSL_TLSV1_ALERT_INTERNAL_ERROR'; // SSLv23 also means "any supported protocol" greater than the default diff --git a/test/parallel/test-tls-multi-key.js b/test/parallel/test-tls-multi-key.js index 0a9c6f108bf6..239cb8aec725 100644 --- a/test/parallel/test-tls-multi-key.js +++ b/test/parallel/test-tls-multi-key.js @@ -35,6 +35,7 @@ if (process.features.openssl_is_boringssl) { const fixtures = require('../common/fixtures'); const assert = require('assert'); const tls = require('tls'); +const { hasFIPS } = require('../common/crypto'); // Key is ordered as ec, rsa, cert is ordered as rsa, ec. test({ @@ -143,6 +144,17 @@ test({ }); function test(options) { + if (hasFIPS(3) && options.pfx) { + const serverOptions = { ...options }; + delete serverOptions.rsaCN; + delete serverOptions.eccCN; + delete serverOptions.client; + assert.throws(() => tls.createServer(serverOptions), { + code: 'ERR_CRYPTO_UNSUPPORTED_OPERATION', + }); + return; + } + const rsaCN = options.rsaCN || 'agent1'; const eccCN = options.eccCN || 'agent2'; const clientTrustRoots = options.client.ca; diff --git a/test/parallel/test-tls-multi-pfx.js b/test/parallel/test-tls-multi-pfx.js index fec697cd3b70..b5bbb6decc12 100644 --- a/test/parallel/test-tls-multi-pfx.js +++ b/test/parallel/test-tls-multi-pfx.js @@ -10,9 +10,12 @@ if (process.features.openssl_is_boringssl) { const assert = require('assert'); const tls = require('tls'); +const { hasFIPS } = require('../common/crypto'); const fixtures = require('../common/fixtures'); +const fips3 = hasFIPS(3); +const fips4 = hasFIPS(4); -const options = { +const legacyOptions = { pfx: [ { buf: fixtures.readKey('agent1.pfx'), @@ -22,6 +25,40 @@ const options = { ] }; +if (fips3) { + assert.throws(() => tls.createServer(legacyOptions), { + code: 'ERR_CRYPTO_UNSUPPORTED_OPERATION', + }); + + if (!hasFIPS(3, 5)) { + return; + } +} + +const fipsPfx = [ + { + buf: fixtures.readKey('agent1-fips.pfx'), + passphrase: 'password', + }, + { + buf: fixtures.readKey('ec-fips.pfx'), + passphrase: 'password', + }, +]; + +if (fips4) { + for (const { buf } of fipsPfx) { + assert.throws(() => tls.createServer({ + pfx: buf, + passphrase: 'sample', + }), { + message: 'password strength too weak', + }); + } +} + +const options = fips3 ? { pfx: fipsPfx } : legacyOptions; + const ciphers = []; const server = tls.createServer(options, function(conn) { diff --git a/test/parallel/test-tls-passphrase.js b/test/parallel/test-tls-passphrase.js index 4372da249bb5..1fe2c1ec11cf 100644 --- a/test/parallel/test-tls-passphrase.js +++ b/test/parallel/test-tls-passphrase.js @@ -26,6 +26,7 @@ if (!common.hasCrypto) const assert = require('assert'); const tls = require('tls'); +const { hasFIPS } = require('../common/crypto'); const fixtures = require('../common/fixtures'); const passKey = fixtures.readKey('rsa_private_encrypted.pem'); @@ -37,6 +38,33 @@ assert(Buffer.isBuffer(cert)); assert.strictEqual(typeof passKey.toString(), 'string'); assert.strictEqual(typeof cert.toString(), 'string'); +if (hasFIPS(3)) { + const encryptedKeyOptions = { + key: passKey, + passphrase: 'password', + cert, + }; + assert.throws(() => tls.Server(encryptedKeyOptions), { + code: 'ERR_OSSL_EVP_UNSUPPORTED', + }); + assert.throws(() => tls.connect(encryptedKeyOptions), { + code: 'ERR_OSSL_EVP_UNSUPPORTED', + }); + + const server = tls.Server({ key: rawKey, passphrase: 'ignored', cert }); + server.listen(0, common.mustCall(function() { + const client = tls.connect({ + port: this.address().port, + key: rawKey, + passphrase: 'ignored', + cert, + rejectUnauthorized: false, + }, common.mustCall(() => client.end())); + client.on('close', common.mustCall(() => server.close())); + })); + return; +} + function onSecureConnect() { return common.mustCall(function() { this.end(); }); } diff --git a/test/parallel/test-tls-pfx-authorizationerror.js b/test/parallel/test-tls-pfx-authorizationerror.js index 53fcc0b16b5b..e115eea80fea 100644 --- a/test/parallel/test-tls-pfx-authorizationerror.js +++ b/test/parallel/test-tls-pfx-authorizationerror.js @@ -10,14 +10,38 @@ const fixtures = require('../common/fixtures'); const assert = require('assert'); const tls = require('tls'); +const { hasFIPS } = require('../common/crypto'); -const pfx = fixtures.readKey('agent1.pfx'); +const fips3 = hasFIPS(3); +const fips35 = hasFIPS(3, 5); +const fips4 = hasFIPS(4); +const pfx = fixtures.readKey(fips35 ? 'agent1-fips.pfx' : 'agent1.pfx'); +const passphrase = fips35 ? 'password' : 'sample'; + +if (fips3) { + assert.throws(() => tls.createServer({ + pfx: fixtures.readKey('agent1.pfx'), + passphrase: 'sample', + }), { + code: 'ERR_CRYPTO_UNSUPPORTED_OPERATION', + }); + + if (!fips35) { + return; + } + + if (fips4) { + assert.throws(() => tls.createServer({ pfx, passphrase: 'sample' }), { + message: 'password strength too weak', + }); + } +} const server = tls .createServer( { pfx: pfx, - passphrase: 'sample', + passphrase, requestCert: true, rejectUnauthorized: false }, @@ -33,7 +57,7 @@ const server = tls { port: this.address().port, pfx: pfx, - passphrase: 'sample', + passphrase, rejectUnauthorized: false }, common.mustCall(() => { diff --git a/test/parallel/test-tls-session-cache.js b/test/parallel/test-tls-session-cache.js index ae560e567980..0789879e1dce 100644 --- a/test/parallel/test-tls-session-cache.js +++ b/test/parallel/test-tls-session-cache.js @@ -26,6 +26,7 @@ if (!common.hasCrypto) { } const { hasOpenSSL, + hasFIPS, opensslCli, } = require('../common/crypto'); @@ -59,7 +60,8 @@ function doTest(testOptions, callback) { secureProtocol: 'TLS_method', // BoringSSL supports the RSA cipher selector, but not OpenSSL's // cipher-string policy command syntax. - ciphers: isBoringSSL ? 'RSA' : 'RSA@SECLEVEL=0' + ciphers: hasFIPS(3) ? 'ECDHE-RSA-AES256-GCM-SHA384' : + (isBoringSSL ? 'RSA' : 'RSA@SECLEVEL=0') }; let requestCount = 0; let resumeCount = 0; @@ -108,8 +110,9 @@ function doTest(testOptions, callback) { server.listen(0, common.mustCall(function() { const args = [ 's_client', - isBoringSSL ? '-tls1_2' : '-tls1', - '-cipher', (hasOpenSSL(3, 1) ? 'DEFAULT:@SECLEVEL=0' : 'DEFAULT'), + isBoringSSL || hasFIPS(3) ? '-tls1_2' : '-tls1', + '-cipher', hasFIPS(3) ? 'ECDHE-RSA-AES256-GCM-SHA384' : + (hasOpenSSL(3, 1) ? 'DEFAULT:@SECLEVEL=0' : 'DEFAULT'), '-connect', `localhost:${this.address().port}`, '-servername', 'ohgod', '-key', fixtures.path('keys/rsa_private.pem'), diff --git a/test/parallel/test-tls-set-ciphers.js b/test/parallel/test-tls-set-ciphers.js index 82a19bb9e90f..57fb35991121 100644 --- a/test/parallel/test-tls-set-ciphers.js +++ b/test/parallel/test-tls-set-ciphers.js @@ -6,10 +6,10 @@ if (!common.hasCrypto) { const { hasOpenSSL, - hasOpenSSL3, + hasFIPS, } = require('../common/crypto'); -if (!hasOpenSSL3) { +if (!hasOpenSSL(3)) { common.skip('missing crypto, or OpenSSL version lower than 3'); } @@ -96,57 +96,83 @@ if (hasOpenSSL(4, 0)) { expectedTLSAlertError = 'ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE'; } -// Have shared ciphers. -test(U, 'AES256-SHA', 'AES256-SHA'); -test('AES256-SHA', U, 'AES256-SHA'); - -test(U, 'TLS_AES_256_GCM_SHA384', 'TLS_AES_256_GCM_SHA384'); -test('TLS_AES_256_GCM_SHA384', U, 'TLS_AES_256_GCM_SHA384'); -test('TLS_AES_256_GCM_SHA384:!TLS_CHACHA20_POLY1305_SHA256', U, 'TLS_AES_256_GCM_SHA384'); - -// Do not have shared ciphers. -test('TLS_AES_256_GCM_SHA384', 'TLS_CHACHA20_POLY1305_SHA256', - U, expectedTLSAlertError, 'ERR_SSL_NO_SHARED_CIPHER'); - -test('AES256-SHA', 'AES256-SHA256', U, expectedTLSAlertError, - 'ERR_SSL_NO_SHARED_CIPHER'); -test('AES256-SHA:TLS_AES_256_GCM_SHA384', - 'TLS_CHACHA20_POLY1305_SHA256:AES256-SHA256', - U, expectedTLSAlertError, 'ERR_SSL_NO_SHARED_CIPHER'); - -// Cipher order ignored, TLS1.3 chosen before TLS1.2. -test('AES256-SHA:TLS_AES_256_GCM_SHA384', U, 'TLS_AES_256_GCM_SHA384'); -test(U, 'AES256-SHA:TLS_AES_256_GCM_SHA384', 'TLS_AES_256_GCM_SHA384'); - -// Cipher order ignored, TLS1.3 before TLS1.2 and -// cipher suites are not disabled if TLS ciphers are set only -// TODO: maybe these tests should be reworked so maxVersion clamping -// is done explicitly and not implicitly in the test() function -test('AES256-SHA', U, 'TLS_AES_256_GCM_SHA384', U, U, { maxVersion: 'TLSv1.3' }); -test(U, 'AES256-SHA', 'TLS_AES_256_GCM_SHA384', U, U, { maxVersion: 'TLSv1.3' }); - -// TLS_AES_128_CCM_8_SHA256 & TLS_AES_128_CCM_SHA256 are not enabled by -// default, but work. -// However, for OpenSSL32 AES_128 is not enabled due to the -// default security level -if (!hasOpenSSL(3, 2)) { - test('TLS_AES_128_CCM_8_SHA256', U, - U, 'ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE', 'ERR_SSL_NO_SHARED_CIPHER'); - - test('TLS_AES_128_CCM_8_SHA256', 'TLS_AES_128_CCM_8_SHA256', - 'TLS_AES_128_CCM_8_SHA256'); +if (hasFIPS(3)) { + const tls12Cipher = 'ECDHE-RSA-AES256-GCM-SHA384'; + + // FIPS-approved TLS 1.2 and TLS 1.3 cipher suites work. + test(U, tls12Cipher, tls12Cipher); + test(tls12Cipher, U, tls12Cipher); + test(U, 'TLS_AES_256_GCM_SHA384', 'TLS_AES_256_GCM_SHA384'); + test('TLS_AES_256_GCM_SHA384', U, 'TLS_AES_256_GCM_SHA384'); + + // The FIPS provider rejects ChaCha20-Poly1305. + test('TLS_AES_256_GCM_SHA384', 'TLS_CHACHA20_POLY1305_SHA256', + U, expectedTLSAlertError, 'ERR_SSL_NO_CIPHERS_AVAILABLE'); + + // Invalid cipher values are still validated before provider selection. + test(9, tls12Cipher, U, 'ERR_INVALID_ARG_TYPE', U); + test(tls12Cipher, 9, U, U, 'ERR_INVALID_ARG_TYPE'); + test(':', tls12Cipher, U, 'ERR_INVALID_ARG_VALUE', U); + test(tls12Cipher, ':', U, U, 'ERR_INVALID_ARG_VALUE'); + + // Empty and null values continue to select the defaults. + test('TLS_AES_256_GCM_SHA384', '', 'TLS_AES_256_GCM_SHA384'); + test('', 'TLS_AES_256_GCM_SHA384', 'TLS_AES_256_GCM_SHA384'); + test(null, 'TLS_AES_256_GCM_SHA384', 'TLS_AES_256_GCM_SHA384'); + test('TLS_AES_256_GCM_SHA384', null, 'TLS_AES_256_GCM_SHA384'); +} else { + // Have shared ciphers. + test(U, 'AES256-SHA', 'AES256-SHA'); + test('AES256-SHA', U, 'AES256-SHA'); + + test(U, 'TLS_AES_256_GCM_SHA384', 'TLS_AES_256_GCM_SHA384'); + test('TLS_AES_256_GCM_SHA384', U, 'TLS_AES_256_GCM_SHA384'); + test('TLS_AES_256_GCM_SHA384:!TLS_CHACHA20_POLY1305_SHA256', U, 'TLS_AES_256_GCM_SHA384'); + + // Do not have shared ciphers. + test('TLS_AES_256_GCM_SHA384', 'TLS_CHACHA20_POLY1305_SHA256', + U, expectedTLSAlertError, 'ERR_SSL_NO_SHARED_CIPHER'); + + test('AES256-SHA', 'AES256-SHA256', U, expectedTLSAlertError, + 'ERR_SSL_NO_SHARED_CIPHER'); + test('AES256-SHA:TLS_AES_256_GCM_SHA384', + 'TLS_CHACHA20_POLY1305_SHA256:AES256-SHA256', + U, expectedTLSAlertError, 'ERR_SSL_NO_SHARED_CIPHER'); + + // Cipher order ignored, TLS1.3 chosen before TLS1.2. + test('AES256-SHA:TLS_AES_256_GCM_SHA384', U, 'TLS_AES_256_GCM_SHA384'); + test(U, 'AES256-SHA:TLS_AES_256_GCM_SHA384', 'TLS_AES_256_GCM_SHA384'); + + // Cipher order ignored, TLS1.3 before TLS1.2 and + // cipher suites are not disabled if TLS ciphers are set only + // TODO: maybe these tests should be reworked so maxVersion clamping + // is done explicitly and not implicitly in the test() function + test('AES256-SHA', U, 'TLS_AES_256_GCM_SHA384', U, U, { maxVersion: 'TLSv1.3' }); + test(U, 'AES256-SHA', 'TLS_AES_256_GCM_SHA384', U, U, { maxVersion: 'TLSv1.3' }); + + // TLS_AES_128_CCM_8_SHA256 & TLS_AES_128_CCM_SHA256 are not enabled by + // default, but work. + // However, for OpenSSL32 AES_128 is not enabled due to the + // default security level + if (!hasOpenSSL(3, 2)) { + test('TLS_AES_128_CCM_8_SHA256', U, + U, 'ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE', 'ERR_SSL_NO_SHARED_CIPHER'); + + test('TLS_AES_128_CCM_8_SHA256', 'TLS_AES_128_CCM_8_SHA256', + 'TLS_AES_128_CCM_8_SHA256'); + } + + // Invalid cipher values + test(9, 'AES256-SHA', U, 'ERR_INVALID_ARG_TYPE', U); + test('AES256-SHA', 9, U, U, 'ERR_INVALID_ARG_TYPE'); + test(':', 'AES256-SHA', U, 'ERR_INVALID_ARG_VALUE', U); + test('AES256-SHA', ':', U, U, 'ERR_INVALID_ARG_VALUE'); + + // Using '' is synonymous for "use default ciphers" + test('TLS_AES_256_GCM_SHA384', '', 'TLS_AES_256_GCM_SHA384'); + test('', 'TLS_AES_256_GCM_SHA384', 'TLS_AES_256_GCM_SHA384'); + + // Using null should be treated the same as undefined. + test(null, 'AES256-SHA', 'AES256-SHA'); + test('AES256-SHA', null, 'AES256-SHA'); } - -// Invalid cipher values -test(9, 'AES256-SHA', U, 'ERR_INVALID_ARG_TYPE', U); -test('AES256-SHA', 9, U, U, 'ERR_INVALID_ARG_TYPE'); -test(':', 'AES256-SHA', U, 'ERR_INVALID_ARG_VALUE', U); -test('AES256-SHA', ':', U, U, 'ERR_INVALID_ARG_VALUE'); - -// Using '' is synonymous for "use default ciphers" -test('TLS_AES_256_GCM_SHA384', '', 'TLS_AES_256_GCM_SHA384'); -test('', 'TLS_AES_256_GCM_SHA384', 'TLS_AES_256_GCM_SHA384'); - -// Using null should be treated the same as undefined. -test(null, 'AES256-SHA', 'AES256-SHA'); -test('AES256-SHA', null, 'AES256-SHA'); diff --git a/test/parallel/test-tls-write-error.js b/test/parallel/test-tls-write-error.js index 8a8d820a09cc..f6ec7b9bd245 100644 --- a/test/parallel/test-tls-write-error.js +++ b/test/parallel/test-tls-write-error.js @@ -5,7 +5,9 @@ if (!common.hasCrypto) const { TestTLSSocket, ccs } = require('../common/tls'); const fixtures = require('../common/fixtures'); +const assert = require('assert'); const https = require('https'); +const { hasFIPS } = require('../common/crypto'); // Regression test for an use-after-free bug in the TLS implementation that // would occur when `SSL_write()` failed. @@ -18,6 +20,7 @@ const opts = { key: server_key, cert: server_cert, }; +const rejectsClientHello = hasFIPS(3) && !hasFIPS(3, 5); if (!process.features.openssl_is_boringssl) { opts.ciphers = 'ALL@SECLEVEL=0'; @@ -25,7 +28,15 @@ if (!process.features.openssl_is_boringssl) { const server = https.createServer(opts, (req, res) => { res.write('hello'); -}).listen(0, common.mustCall(() => { +}); + +if (rejectsClientHello) { + server.once('tlsClientError', common.mustCall((err) => { + assert.strictEqual(err.code, 'ERR_SSL_WRONG_SIGNATURE_TYPE'); + })); +} + +server.listen(0, common.mustCall(() => { const client = new TestTLSSocket(server_cert); client.connect({ @@ -37,6 +48,12 @@ const server = https.createServer(opts, (req, res) => { })); client.once('data', common.mustCall((buf) => { + if (rejectsClientHello) { + client.end(); + server.close(); + return; + } + let remaining = buf; do { remaining = client.parseTLSFrame(remaining); diff --git a/test/parallel/test-webcrypto-aead-decrypt-detached-buffer.js b/test/parallel/test-webcrypto-aead-decrypt-detached-buffer.js index 316d706e7b79..5960c46f8aa8 100644 --- a/test/parallel/test-webcrypto-aead-decrypt-detached-buffer.js +++ b/test/parallel/test-webcrypto-aead-decrypt-detached-buffer.js @@ -6,10 +6,17 @@ if (!common.hasCrypto) common.skip('missing crypto'); const assert = require('assert'); -const { hasOpenSSL } = require('../common/crypto'); +const { hasOpenSSL, hasFIPS } = require('../common/crypto'); const { subtle } = globalThis.crypto; +const fips3 = hasFIPS(3); -async function test(algorithmName, keyLength, ivLength, format = 'raw') { +async function test( + algorithmName, + keyLength, + ivLength, + format = 'raw', + causeCode, +) { const key = await subtle.importKey( format, new Uint8Array(keyLength), @@ -21,19 +28,40 @@ async function test(algorithmName, keyLength, ivLength, format = 'raw') { const data = new Uint8Array(32); data.buffer.transfer(); + const expected = causeCode === undefined ? + { name: 'OperationError' } : + (err) => err.name === 'OperationError' && + err.cause?.code === causeCode; await assert.rejects( subtle.decrypt({ name: algorithmName, iv: new Uint8Array(ivLength) }, key, data), - { name: 'OperationError' }, + expected, ); } const tests = [ test('AES-GCM', 32, 12), - test('ChaCha20-Poly1305', 32, 12, 'raw-secret'), ]; +if (fips3) { + tests.push(assert.rejects( + subtle.importKey( + 'raw-secret', + new Uint8Array(32), + 'ChaCha20-Poly1305', + false, + ['encrypt', 'decrypt']), + { name: 'NotSupportedError' })); +} else { + tests.push(test('ChaCha20-Poly1305', 32, 12, 'raw-secret')); +} + if (hasOpenSSL(3)) { - tests.push(test('AES-OCB', 32, 12, 'raw-secret')); + tests.push(test( + 'AES-OCB', + 32, + 12, + 'raw-secret', + fips3 ? 'ERR_OSSL_EVP_UNSUPPORTED' : undefined)); } Promise.all(tests).then(common.mustCall()); diff --git a/test/parallel/test-webcrypto-constructors.js b/test/parallel/test-webcrypto-constructors.js index 782265edc294..3d13b6c92bba 100644 --- a/test/parallel/test-webcrypto-constructors.js +++ b/test/parallel/test-webcrypto-constructors.js @@ -6,7 +6,9 @@ if (!common.hasCrypto) common.skip('missing crypto'); const assert = require('assert'); +const { hasFIPS } = require('../common/crypto'); const { subtle } = globalThis.crypto; +const fips4 = hasFIPS(4); // Test CryptoKey constructor { @@ -152,19 +154,41 @@ const notSubtle = Reflect.construct(function() {}, [], SubtleCrypto); } { - subtle.importKey( - 'raw', - globalThis.crypto.getRandomValues(new Uint8Array(4)), - 'PBKDF2', - false, - ['deriveKey'], - ).then((key) => { + const keyData = globalThis.crypto.getRandomValues( + new Uint8Array(fips4 ? 8 : 4)); + const importedKeys = [ + subtle.importKey('raw', keyData, 'PBKDF2', false, ['deriveKey']), + ]; + if (fips4) { + importedKeys.push( + subtle.importKey( + 'raw', + globalThis.crypto.getRandomValues(new Uint8Array(4)), + 'PBKDF2', + false, + ['deriveKey'])); + } + + Promise.all(importedKeys).then(async ([key, weakKey]) => { subtle.importKey = common.mustNotCall(); - return subtle.deriveKey({ + if (fips4) { + await assert.rejects(subtle.deriveKey({ + name: 'PBKDF2', + hash: 'SHA-512', + salt: new Uint8Array(), + iterations: 5, + }, weakKey, { + name: 'AES-GCM', + length: 256, + }, true, ['encrypt', 'decrypt']), { name: 'OperationError' }); + } + + await subtle.deriveKey({ name: 'PBKDF2', hash: 'SHA-512', - salt: globalThis.crypto.getRandomValues(new Uint8Array()), - iterations: 5, + salt: globalThis.crypto.getRandomValues( + new Uint8Array(fips4 ? 16 : 0)), + iterations: fips4 ? 1000 : 5, }, key, { name: 'AES-GCM', length: 256 diff --git a/test/parallel/test-webcrypto-cryptokey-hidden-slots.js b/test/parallel/test-webcrypto-cryptokey-hidden-slots.js index 792a1a59c4c5..75b977073dab 100644 --- a/test/parallel/test-webcrypto-cryptokey-hidden-slots.js +++ b/test/parallel/test-webcrypto-cryptokey-hidden-slots.js @@ -22,6 +22,7 @@ if (!common.hasCrypto) const assert = require('node:assert'); const { createHmac, + getFips, KeyObject, sign: cryptoSign, verify: cryptoVerify, @@ -50,7 +51,7 @@ common.expectWarning({ const { publicKey: rsaPublicKey } = await subtle.generateKey( { name: 'RSA-PSS', - modulusLength: 1024, + modulusLength: getFips() === 1 ? 2048 : 1024, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256', }, diff --git a/test/parallel/test-webcrypto-deduplicate-usages.js b/test/parallel/test-webcrypto-deduplicate-usages.js index 70b35f6cfa38..d27675d873ce 100644 --- a/test/parallel/test-webcrypto-deduplicate-usages.js +++ b/test/parallel/test-webcrypto-deduplicate-usages.js @@ -13,7 +13,7 @@ if (!common.hasCrypto) const assert = require('assert'); const { createSecretKey } = require('crypto'); -const { hasOpenSSL } = require('../common/crypto'); +const { hasOpenSSL, hasFIPS } = require('../common/crypto'); const { subtle } = globalThis.crypto; function assertSameSet(actual, expected, msg) { @@ -50,6 +50,10 @@ function assertSameSet(actual, expected, msg) { expected: ['encrypt', 'decrypt', 'wrapKey', 'unwrapKey'] }, ]; + if (hasFIPS(3)) + symmetric.splice(symmetric.findIndex(({ algorithm }) => + algorithm.name === 'ChaCha20-Poly1305'), 1); + if (hasOpenSSL(3)) { symmetric.push({ algorithm: { name: 'AES-OCB', length: 128 }, @@ -107,6 +111,10 @@ function assertSameSet(actual, expected, msg) { privateExpected: ['deriveKey', 'deriveBits'] }, ]; + if (hasFIPS(3)) + asymmetric.splice(asymmetric.findIndex(({ algorithm }) => + algorithm.name === 'X25519'), 1); + if (hasOpenSSL(3, 5) || process.features.openssl_is_boringssl) { asymmetric.push({ algorithm: { name: 'ML-DSA-65' }, @@ -336,12 +344,17 @@ function assertSameSet(actual, expected, msg) { // ChaCha20-Poly1305 raw-secret import. tests.push((async () => { - const key = await subtle.importKey( + const imported = subtle.importKey( 'raw-secret', new Uint8Array(32), { name: 'ChaCha20-Poly1305' }, true, ['decrypt', 'encrypt', 'decrypt', 'encrypt']); + if (hasFIPS(3)) { + await assert.rejects(imported, { name: 'NotSupportedError' }); + return; + } + const key = await imported; assertSameSet(key.usages, ['encrypt', 'decrypt']); assert.strictEqual(key.usages.length, 2); })()); @@ -491,6 +504,10 @@ function assertSameSet(actual, expected, msg) { privateExpected: ['deriveKey', 'deriveBits'] }, ]; + if (hasFIPS(3)) + jwkPairVectors.splice(jwkPairVectors.findIndex(({ algorithm }) => + algorithm.name === 'X25519'), 1); + if (hasOpenSSL(3, 5) || process.features.openssl_is_boringssl) { jwkPairVectors.push({ algorithm: { name: 'ML-DSA-65' }, diff --git a/test/parallel/test-webcrypto-derivebits-argon2.js b/test/parallel/test-webcrypto-derivebits-argon2.js index e2b465ab206b..129b716c5919 100644 --- a/test/parallel/test-webcrypto-derivebits-argon2.js +++ b/test/parallel/test-webcrypto-derivebits-argon2.js @@ -5,10 +5,12 @@ const common = require('../common'); if (!common.hasCrypto) common.skip('missing crypto'); -const { hasOpenSSL } = require('../common/crypto'); +const { hasFIPS, hasOpenSSL } = require('../common/crypto'); if (!hasOpenSSL(3, 2)) common.skip('requires OpenSSL >= 3.2'); +if (hasFIPS(3)) + common.skip('Argon2 is not available in FIPS mode'); const assert = require('assert'); const { createSecretKey } = require('crypto'); diff --git a/test/parallel/test-webcrypto-derivebits-cfrg.js b/test/parallel/test-webcrypto-derivebits-cfrg.js index 757c81272536..fae39ac89c5e 100644 --- a/test/parallel/test-webcrypto-derivebits-cfrg.js +++ b/test/parallel/test-webcrypto-derivebits-cfrg.js @@ -6,7 +6,9 @@ if (!common.hasCrypto) common.skip('missing crypto'); const assert = require('assert'); +const { hasFIPS } = require('../common/crypto'); const { subtle } = globalThis.crypto; +const rejectsXCurves = hasFIPS(3, 5); const kTests = [ { @@ -77,6 +79,15 @@ async function prepareKeys() { Object.keys(keys).map(async (name) => { const { size, result, privateKey, publicKey } = keys[name]; + if (rejectsXCurves) { + await assert.rejects( + subtle.deriveBits({ name, public: publicKey }, privateKey, 8 * size), + (err) => err.name === 'OperationError' && + err.cause?.code === + 'ERR_OSSL_EVP_OPERATION_NOT_SUPPORTED_FOR_THIS_KEYTYPE'); + return; + } + { // Good parameters const bits = await subtle.deriveBits({ diff --git a/test/parallel/test-webcrypto-derivebits.js b/test/parallel/test-webcrypto-derivebits.js index 50892be7400e..6ef2227ab2d2 100644 --- a/test/parallel/test-webcrypto-derivebits.js +++ b/test/parallel/test-webcrypto-derivebits.js @@ -7,7 +7,11 @@ if (!common.hasCrypto) common.skip('missing crypto'); const assert = require('assert'); +const { hasFIPS } = require('../common/crypto'); const { subtle } = globalThis.crypto; +const requiresLongHkdfInputs = hasFIPS(3) && !hasFIPS(3, 5); +const rejectsWeakPbkdf2Inputs = hasFIPS(4); +const rejectsXCurves = hasFIPS(3, 5); // This is only a partial test. The WebCrypto Web Platform Tests // will provide much greater coverage. @@ -56,12 +60,20 @@ const { subtle } = globalThis.crypto; } const kTests = [ - ['hello', 'there', 'my friend', 'SHA-256', 512, - '14d93b0ccd99d4f2cbd9fbfe9c830b5b8a43e3e45e329' + - '41ef21bdeb0fa87b6b6bfa5c54466aa5bf76cdc2685fb' + - 'a4408ea5b94c049fe035649b46f92fdc519374'], - ['hello', 'there', 'my friend', 'SHA-384', 128, - 'e36cf2cf943d8f3a88adb80f478745c3'], + [requiresLongHkdfInputs ? 'hello hello hello' : 'hello', + 'there', requiresLongHkdfInputs ? 'my friend indeed' : 'my friend', + 'SHA-256', 512, + requiresLongHkdfInputs ? + 'bc2b7841512a6f4563f723c317909ac305ddbfbdec1daf0055d0587b5db8d635' + + 'a22f97b0dfbcc12dcd2d096123385227b16e95e5bccc0d6751491f38c5e48428' : + '14d93b0ccd99d4f2cbd9fbfe9c830b5b8a43e3e45e329' + + '41ef21bdeb0fa87b6b6bfa5c54466aa5bf76cdc2685fb' + + 'a4408ea5b94c049fe035649b46f92fdc519374'], + [requiresLongHkdfInputs ? 'hello hello hello' : 'hello', + 'there', requiresLongHkdfInputs ? 'my friend indeed' : 'my friend', + 'SHA-384', 128, + requiresLongHkdfInputs ? 'ee2d1d7dc759c26f2ab8ee6d7cfa0c23' : + 'e36cf2cf943d8f3a88adb80f478745c3'], ]; const tests = Promise.all(kTests.map((args) => test(...args))); @@ -88,17 +100,24 @@ const { subtle } = globalThis.crypto; } const kTests = [ - ['hello', 'there', 10, 'SHA-256', 512, - 'f72d1cf4853fffbd16a42751765d11f8dc7939498ee7b7' + - 'ce7678b4cb16fad88098110a83e71f4483ce73203f7a64' + - '719d293280f780f9fafdcf46925c5c0588b3'], - ['hello', 'there', 5, 'SHA-384', 128, - '201509b012c9cd2fbe7ea938f0c509b3'], + ['password', 'there there here', 1000, 'SHA-256', 512, + '8802c34ee684a523f9304a6335394c0a5f02350d51383d' + + '17d3cf89fa0808591ddede3c832fe4691c7f361ade53b9' + + '36bf94347055bcf86fd662abe038fb945d17'], + ['password', 'there there here', 2000, 'SHA-384', 128, + '7c650b88798cea1a390802a6f97e05b0'], ]; const tests = Promise.all(kTests.map((args) => test(...args))); tests.then(common.mustCall()); + + if (rejectsWeakPbkdf2Inputs) { + assert.rejects( + test('hello', 'there', 10, 'SHA-256', 512), + { name: 'OperationError' }) + .then(common.mustCall()); + } } // Test X25519 and X448 bit derivation @@ -123,10 +142,20 @@ const { subtle } = globalThis.crypto; assert.deepStrictEqual(secret1, secret2); } - test('X25519').then(common.mustCall()); - if (!process.features.openssl_is_boringssl) { - test('X448').then(common.mustCall()); + if (rejectsXCurves) { + for (const name of ['X25519', 'X448']) { + assert.rejects( + test(name), + (err) => err.name === 'OperationError' && + err.cause?.code === 'ERR_OSSL_EVP_UNSUPPORTED') + .then(common.mustCall()); + } } else { - common.printSkipMessage('Skipping unsupported X448 test case'); + test('X25519').then(common.mustCall()); + if (!process.features.openssl_is_boringssl) { + test('X448').then(common.mustCall()); + } else { + common.printSkipMessage('Skipping unsupported X448 test case'); + } } } diff --git a/test/parallel/test-webcrypto-derivekey-cfrg.js b/test/parallel/test-webcrypto-derivekey-cfrg.js index c5a5b1f3518f..13c4e56ce5b0 100644 --- a/test/parallel/test-webcrypto-derivekey-cfrg.js +++ b/test/parallel/test-webcrypto-derivekey-cfrg.js @@ -6,7 +6,9 @@ if (!common.hasCrypto) common.skip('missing crypto'); const assert = require('assert'); +const { hasFIPS } = require('../common/crypto'); const { subtle } = globalThis.crypto; +const rejectsXCurves = hasFIPS(3, 5); const kTests = [ { @@ -80,6 +82,15 @@ async function prepareKeys() { Object.keys(keys).map(async (name) => { const { result, privateKey, publicKey } = keys[name]; + if (rejectsXCurves) { + await assert.rejects( + subtle.deriveKey({ name, public: publicKey }, privateKey, ...otherArgs), + (err) => err.name === 'OperationError' && + err.cause?.code === + 'ERR_OSSL_EVP_OPERATION_NOT_SUPPORTED_FOR_THIS_KEYTYPE'); + return; + } + { // Good parameters const key = await subtle.deriveKey({ diff --git a/test/parallel/test-webcrypto-derivekey.js b/test/parallel/test-webcrypto-derivekey.js index f9323bca2caf..75f43bf8a9ae 100644 --- a/test/parallel/test-webcrypto-derivekey.js +++ b/test/parallel/test-webcrypto-derivekey.js @@ -5,11 +5,13 @@ const common = require('../common'); if (!common.hasCrypto) common.skip('missing crypto'); -const { hasOpenSSL } = require('../common/crypto'); +const { hasOpenSSL, hasFIPS } = require('../common/crypto'); const assert = require('assert'); const { subtle } = globalThis.crypto; const { KeyObject } = require('crypto'); +const rejectsXCurves = hasFIPS(3, 5); +const fips4 = hasFIPS(4); // This is only a partial test. The WebCrypto Web Platform Tests // will provide much greater coverage. @@ -74,24 +76,24 @@ const { KeyObject } = require('crypto'); } const kTests = [ - ['hello', 'there', 'my friend', 'SHA-1', - '365ca5d3f42d050c74302e420c83975327950f1913a151eecd00526bf52614a0'], - ['hello', 'there', 'my friend', 'SHA-256', - '14d93b0ccd99d4f2cbd9fbfe9c830b5b8a43e3e45e32941ef21bdeb0fa87b6b6'], - ['hello', 'there', 'my friend', 'SHA-384', - 'e36cf2cf943d8f3a88adb80f478745c336ac811b1a86d03a7d10eb0b6b52295c'], - ['hello', 'there', 'my friend', 'SHA-512', - '1e42d43fcacba361716f65853bd5f3c479f679612f0180eab3c51ed6c9d2b47d'], + ['hello hello hello', 'there', 'my friend indeed', 'SHA-1', + 'aac1ecdc73147af6a418393da6875bff5f566c0a473e25d54b4dfc3cb7cb2ace'], + ['hello hello hello', 'there', 'my friend indeed', 'SHA-256', + 'bc2b7841512a6f4563f723c317909ac305ddbfbdec1daf0055d0587b5db8d635'], + ['hello hello hello', 'there', 'my friend indeed', 'SHA-384', + 'ee2d1d7dc759c26f2ab8ee6d7cfa0c2313e82650a4514673c867063dc1849040'], + ['hello hello hello', 'there', 'my friend indeed', 'SHA-512', + 'a7abd704d0be364c6d4a530b6f93fcaff95474a2eee5a127ff86c5d095a2a812'], ]; if (!process.features.openssl_is_boringssl) { kTests.push( - ['hello', 'there', 'my friend', 'SHA3-256', - '2a49a3b6fb219117af9e251c6c65f16600cbca13bd0be6e70d96b0b9fa4cf3fd'], - ['hello', 'there', 'my friend', 'SHA3-384', - '0437bb59b95f2db2c7684c0b439028cb0fdd6f0f5d03b9f489066a87ae147221'], - ['hello', 'there', 'my friend', 'SHA3-512', - '3bbc469d38214371921e52c6f147e96cb7eb370421a81f53dea8b4851dfb8bce'], + ['hello hello hello', 'there', 'my friend indeed', 'SHA3-256', + '89b3751df2ada85322a57ec82f7d0a5c233c6def91c92e681bc5118bd5768dca'], + ['hello hello hello', 'there', 'my friend indeed', 'SHA3-384', + 'b4fa7b9929a595bbaa370eb959b194c1232d5a329abd02a5fa166a1424962fcf'], + ['hello hello hello', 'there', 'my friend indeed', 'SHA3-512', + 'ac5d90a6bc848961e78a491887539b29c532a9c0d0b39cec464df071a63e0061'], ); } else { common.printSkipMessage('Skipping unsupported SHA-3 test cases'); @@ -127,30 +129,36 @@ const { KeyObject } = require('crypto'); } const kTests = [ - ['hello', 'there', 5, 'SHA-1', - 'f8f65a5fd92c9b74916083a7e9b0001c46bc89e2a14c48014cf1e0e1dbabf635'], - ['hello', 'there', 5, 'SHA-256', - '2e575eae24267db32106c7dba01615e5417557e8c5cf33ba15a311cb0c2907ee'], - ['hello', 'there', 5, 'SHA-384', - '201509b012c9cd2fbe7ea938f0c509b36ecb140f38bf9130e96923f55f46756d'], - ['hello', 'there', 5, 'SHA-512', - '2e8d981741f98193e0af9c79870af0e985089341221edad9a130d297eae1984b'], + ['hello hello hello', 'my friend indeed', 1000, 'SHA-1', + 'b747604ca226287ccae90d8d8c119645a80d1154625a56b2debb3f9b172eb134'], + ['hello hello hello', 'my friend indeed', 1000, 'SHA-256', + '3cc64f6cfcbdb9c42b63b471016f17d1966b70934b4719a12ce95382940252f2'], + ['hello hello hello', 'my friend indeed', 1000, 'SHA-384', + '5ce64241beef3a3931dbfac6eef7303b5bdbea13449d4eeb4f89c3e9f9357c65'], + ['hello hello hello', 'my friend indeed', 1000, 'SHA-512', + '12790ce09027db067d680670f4dc704715b5120d139e8fde810afc34fb66f9f1'], ]; if (!process.features.openssl_is_boringssl) { kTests.push( - ['hello', 'there', 5, 'SHA3-256', - '0aed29b61b3ca3978aea34a9793276574ea997b69e8d03727438199f90571649'], - ['hello', 'there', 5, 'SHA3-384', - '7aa4a274aa19b4623c5d3091c4b06355de85ff6f25e53a83e3126cbb86ae68df'], - ['hello', 'there', 5, 'SHA3-512', - '4d909c47a81c625f866d1f9406248e6bc3c7ea89225fbccf1f08820254c9ef56'] + ['hello hello hello', 'my friend indeed', 1000, 'SHA3-256', + '0f69b46660cba27b95215d5676492c64ed6abf6d426669a4a02b0ca3a1c36c11'], + ['hello hello hello', 'my friend indeed', 1000, 'SHA3-384', + 'a2e86a2d4cdf9844d70ae37f71302356ce2b9a899f5d778fc9af64d32e351d70'], + ['hello hello hello', 'my friend indeed', 1000, 'SHA3-512', + '03431052c37d626ae3fc1df582ff2a4d610642fc27e1b8130ca5980c0b0756ac'] ); } else { common.printSkipMessage('Skipping unsupported SHA-3 test cases'); } - const tests = Promise.all(kTests.map((args) => test(...args))); + const promises = kTests.map((args) => test(...args)); + if (fips4) { + promises.push(assert.rejects( + test('hello', 'there', 5, 'SHA-256', ''), + { name: 'OperationError' })); + } + const tests = Promise.all(promises); tests.then(common.mustCall()); } @@ -254,8 +262,18 @@ const { KeyObject } = require('crypto'); (async () => { for (const [derivedKeyAlgorithm, usage, expected] of vectors) { const derived = await subtle.deriveKey( - { name: 'PBKDF2', salt: new Uint8Array([]), hash: 'SHA-256', iterations: 20 }, - await subtle.importKey('raw', new Uint8Array([]), { name: 'PBKDF2' }, false, ['deriveKey']), + { + name: 'PBKDF2', + salt: new Uint8Array(16), + hash: 'SHA-256', + iterations: 1000, + }, + await subtle.importKey( + 'raw', + new Uint8Array(8), + { name: 'PBKDF2' }, + false, + ['deriveKey']), derivedKeyAlgorithm, false, [usage]); @@ -271,17 +289,27 @@ if (hasOpenSSL(3)) { const usages = ['sign']; for (const [algorithm, baseKeyAlgorithm] of [ [ - { name: 'HKDF', salt: new Uint8Array(), info: new Uint8Array(), hash: 'SHA-256' }, + { + name: 'HKDF', + salt: new Uint8Array(16), + info: new Uint8Array(), + hash: 'SHA-256', + }, { name: 'HKDF' }, ], [ - { name: 'PBKDF2', salt: new Uint8Array(), hash: 'SHA-256', iterations: 20 }, + { + name: 'PBKDF2', + salt: new Uint8Array(16), + hash: 'SHA-256', + iterations: 1000, + }, { name: 'PBKDF2' }, ], ]) { const baseKey = await subtle.importKey( 'raw', - new Uint8Array(), + new Uint8Array(baseKeyAlgorithm.name === 'HKDF' ? 16 : 8), baseKeyAlgorithm, false, ['deriveKey']); @@ -293,11 +321,15 @@ if (hasOpenSSL(3)) { usages); assert.strictEqual(derived.algorithm.length, 0); - const signature = await subtle.sign({ + const signature = subtle.sign({ name: 'KMAC128', outputLength: 256, }, derived, new Uint8Array()); - assert.strictEqual(signature.byteLength, 32); + if (fips4) { + await assert.rejects(signature, { name: 'OperationError' }); + } else { + assert.strictEqual((await signature).byteLength, 32); + } } })().then(common.mustCall()); } @@ -333,10 +365,20 @@ if (hasOpenSSL(3)) { assert.deepStrictEqual(raw1, raw2); } - test('X25519').then(common.mustCall()); - if (!process.features.openssl_is_boringssl) { - test('X448').then(common.mustCall()); + if (rejectsXCurves) { + for (const name of ['X25519', 'X448']) { + assert.rejects( + test(name), + (err) => err.name === 'OperationError' && + err.cause?.code === 'ERR_OSSL_EVP_UNSUPPORTED') + .then(common.mustCall()); + } } else { - common.printSkipMessage('Skipping unsupported X448 test case'); + test('X25519').then(common.mustCall()); + if (!process.features.openssl_is_boringssl) { + test('X448').then(common.mustCall()); + } else { + common.printSkipMessage('Skipping unsupported X448 test case'); + } } } diff --git a/test/parallel/test-webcrypto-digest.js b/test/parallel/test-webcrypto-digest.js index 8e1b6797ee86..447948212bc1 100644 --- a/test/parallel/test-webcrypto-digest.js +++ b/test/parallel/test-webcrypto-digest.js @@ -9,7 +9,8 @@ const assert = require('assert'); const { Buffer } = require('buffer'); const { subtle } = globalThis.crypto; const { createHash, getHashes } = require('crypto'); -const { hasOpenSSL } = require('../common/crypto'); +const { hasOpenSSL, hasFIPS } = require('../common/crypto'); +const fips4 = hasFIPS(4); const kTests = [ ['SHA-1', ['sha1'], 160], @@ -397,18 +398,35 @@ if (getHashes().includes('shake128')) { 'ca6f88db415829', }, ]) { - assert.strictEqual( - Buffer.from(await subtle.digest(algorithm, data)).toString('hex'), - expected); + const digest = subtle.digest(algorithm, data); + if (fips4) { + await assert.rejects( + digest, + (err) => err.name === 'OperationError' && + err.cause?.code === 'ERR_OSSL_EVP_UNSUPPORTED'); + } else { + assert.strictEqual( + Buffer.from(await digest).toString('hex'), + expected); + } } - const truncated = Buffer.from(await subtle.digest( + const truncatedDigest = subtle.digest( { ...nistCShakeSample1.algorithm, outputLength: 255 }, - nistCShakeSample1.data)); - const expected = Buffer.from(nistCShakeSample1.expected, 'hex'); - assert.strictEqual(truncated.byteLength, expected.byteLength); - assert.deepStrictEqual(truncated.subarray(0, 31), expected.subarray(0, 31)); - assert.strictEqual(truncated[31] & 0b00000001, 0); - assert.strictEqual(truncated[31] | 0b00000001, expected[31]); + nistCShakeSample1.data); + if (fips4) { + await assert.rejects( + truncatedDigest, + (err) => err.name === 'OperationError' && + err.cause?.code === 'ERR_OSSL_EVP_UNSUPPORTED'); + } else { + const truncated = Buffer.from(await truncatedDigest); + const expected = Buffer.from(nistCShakeSample1.expected, 'hex'); + assert.strictEqual(truncated.byteLength, expected.byteLength); + assert.deepStrictEqual( + truncated.subarray(0, 31), expected.subarray(0, 31)); + assert.strictEqual(truncated[31] & 0b00000001, 0); + assert.strictEqual(truncated[31] | 0b00000001, expected[31]); + } })().then(common.mustCall()); } diff --git a/test/parallel/test-webcrypto-encrypt-decrypt-aes.js b/test/parallel/test-webcrypto-encrypt-decrypt-aes.js index d7a7dca6584c..00c294839bfe 100644 --- a/test/parallel/test-webcrypto-encrypt-decrypt-aes.js +++ b/test/parallel/test-webcrypto-encrypt-decrypt-aes.js @@ -8,6 +8,7 @@ if (!common.hasCrypto) const { hasOpenSSL } = require('../common/crypto'); const assert = require('assert'); +const { getFips } = require('crypto'); const { subtle } = globalThis.crypto; async function testEncrypt({ keyBuffer, algorithm, plaintext, result }) { @@ -237,6 +238,14 @@ if (hasOpenSSL(3)) { } = require('../fixtures/crypto/aes_ocb')(); (async function() { + if (getFips() === 1) { + await assert.rejects( + testEncrypt(passing[0]), + (err) => err.name === 'OperationError' && + err.cause?.code === 'ERR_OSSL_EVP_UNSUPPORTED'); + return; + } + const variations = []; passing.forEach((vector) => { diff --git a/test/parallel/test-webcrypto-encrypt-decrypt-chacha20-poly1305.js b/test/parallel/test-webcrypto-encrypt-decrypt-chacha20-poly1305.js index 723fd26ea570..45225115fd52 100644 --- a/test/parallel/test-webcrypto-encrypt-decrypt-chacha20-poly1305.js +++ b/test/parallel/test-webcrypto-encrypt-decrypt-chacha20-poly1305.js @@ -6,8 +6,19 @@ if (!common.hasCrypto) common.skip('missing crypto'); const assert = require('assert'); +const { hasFIPS } = require('../common/crypto'); const { subtle } = globalThis.crypto; +if (hasFIPS(3)) { + assert.rejects( + subtle.generateKey( + { name: 'ChaCha20-Poly1305' }, + false, + ['encrypt', 'decrypt']), + { name: 'NotSupportedError' }).then(common.mustCall()); + return; +} + async function testEncrypt({ keyBuffer, algorithm, plaintext, result }) { // Using a copy of plaintext to prevent tampering of the original plaintext = Buffer.from(plaintext); diff --git a/test/parallel/test-webcrypto-encrypt-decrypt.js b/test/parallel/test-webcrypto-encrypt-decrypt.js index c4ca52862fe0..1015752a8b0a 100644 --- a/test/parallel/test-webcrypto-encrypt-decrypt.js +++ b/test/parallel/test-webcrypto-encrypt-decrypt.js @@ -7,6 +7,7 @@ if (!common.hasCrypto) const assert = require('assert'); const { hasOpenSSL } = require('../common/crypto'); +const { getFips } = require('crypto'); const { subtle } = globalThis.crypto; // This is only a partial test. The WebCrypto Web Platform Tests @@ -207,7 +208,15 @@ if (hasOpenSSL(3)) { Buffer.from(buf).toString('hex')); } - test().then(common.mustCall()); + if (getFips() === 1) { + assert.rejects( + test(), + (err) => err.name === 'OperationError' && + err.cause?.code === 'ERR_OSSL_EVP_UNSUPPORTED') + .then(common.mustCall()); + } else { + test().then(common.mustCall()); + } } else { common.printSkipMessage('Skipping unsupported AES-OCB test cases'); } diff --git a/test/parallel/test-webcrypto-export-import-cfrg.js b/test/parallel/test-webcrypto-export-import-cfrg.js index 14f475fc1779..0ff7c61bdc68 100644 --- a/test/parallel/test-webcrypto-export-import-cfrg.js +++ b/test/parallel/test-webcrypto-export-import-cfrg.js @@ -8,7 +8,9 @@ if (!common.hasCrypto) const assert = require('assert'); const crypto = require('crypto'); +const { hasFIPS } = require('../common/crypto'); const { subtle } = globalThis.crypto; +const rejectsXCurves = hasFIPS(3, 5); const keyData = { 'Ed25519': { @@ -413,9 +415,19 @@ async function testImportRaw({ name, publicUsages }) { for (const extractable of [true, false]) { tests.push(testImportSpki(vector, extractable)); tests.push(testImportPkcs8(vector, extractable)); - tests.push(testImportJwk(vector, extractable)); + if (rejectsXCurves && vector.name.startsWith('X')) { + tests.push(assert.rejects( + testImportJwk(vector, extractable), + { name: 'DataError' })); + } else { + tests.push(testImportJwk(vector, extractable)); + } + } + if (rejectsXCurves && vector.name.startsWith('X')) { + tests.push(assert.rejects(testImportRaw(vector), { name: 'DataError' })); + } else { + tests.push(testImportRaw(vector)); } - tests.push(testImportRaw(vector)); } await Promise.all(tests); })().then(common.mustCall()); diff --git a/test/parallel/test-webcrypto-export-import.js b/test/parallel/test-webcrypto-export-import.js index c7399c69d9c9..9f6b2125d8ed 100644 --- a/test/parallel/test-webcrypto-export-import.js +++ b/test/parallel/test-webcrypto-export-import.js @@ -10,7 +10,12 @@ const { hasOpenSSL } = require('../common/crypto'); const assert = require('assert'); const { subtle } = globalThis.crypto; -const { createPrivateKey, createPublicKey, createSecretKey } = require('crypto'); +const { + createPrivateKey, + createPublicKey, + createSecretKey, + getFips, +} = require('crypto'); { async function test() { @@ -397,7 +402,7 @@ if (hasOpenSSL(3)) { async function test() { const { publicKey, privateKey } = await subtle.generateKey({ name: 'RSA-PSS', - modulusLength: 1024, + modulusLength: getFips() === 1 ? 2048 : 1024, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-384' }, true, ['sign', 'verify']); diff --git a/test/parallel/test-webcrypto-get-public-key.mjs b/test/parallel/test-webcrypto-get-public-key.mjs index 622ec4adca68..65e3cb5334d9 100644 --- a/test/parallel/test-webcrypto-get-public-key.mjs +++ b/test/parallel/test-webcrypto-get-public-key.mjs @@ -9,8 +9,10 @@ import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const { kSupportedAlgorithms } = require('internal/crypto/util'); +const { hasFIPS } = require('../common/crypto'); const { SubtleCrypto } = globalThis; const { subtle } = globalThis.crypto; +const rejectsXCurves = hasFIPS(3, 5); const RSA_KEY_GEN = { modulusLength: 2048, @@ -80,6 +82,15 @@ for (const name of Object.keys(kSupportedAlgorithms.exportKey)) { assert.strictEqual(SubtleCrypto.supports('getPublicKey', name), true); + if (rejectsXCurves && + (name === 'X25519' || name === 'X448')) { + await assert.rejects( + subtle.generateKey(test.algorithm, false, test.privateUsages), + (err) => err.name === 'OperationError' && + err.cause?.code === 'ERR_OSSL_EVP_UNSUPPORTED'); + continue; + } + const { privateKey } = await subtle.generateKey( test.algorithm, false, test.privateUsages); const usages = test.publicUsages; diff --git a/test/parallel/test-webcrypto-keygen.js b/test/parallel/test-webcrypto-keygen.js index 989fdbb47616..6ea2579ae041 100644 --- a/test/parallel/test-webcrypto-keygen.js +++ b/test/parallel/test-webcrypto-keygen.js @@ -6,15 +6,18 @@ const common = require('../common'); if (!common.hasCrypto) common.skip('missing crypto'); -const { hasOpenSSL } = require('../common/crypto'); +const { hasOpenSSL, hasFIPS } = require('../common/crypto'); const assert = require('assert'); const { types: { isCryptoKey } } = require('util'); const { createSecretKey, + getFips, KeyObject, } = require('crypto'); const { subtle } = globalThis.crypto; +const fips3 = hasFIPS(3); +const fips35 = hasFIPS(3, 5); const { bigIntArrayToUnsignedBigInt } = require('internal/crypto/util'); @@ -69,7 +72,7 @@ const vectors = { }, 'RSASSA-PKCS1-v1_5': { algorithm: { - modulusLength: 1024, + modulusLength: getFips() === 1 ? 2048 : 1024, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, @@ -81,7 +84,7 @@ const vectors = { }, 'RSA-PSS': { algorithm: { - modulusLength: 1024, + modulusLength: getFips() === 1 ? 2048 : 1024, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, @@ -93,7 +96,7 @@ const vectors = { }, 'RSA-OAEP': { algorithm: { - modulusLength: 1024, + modulusLength: getFips() === 1 ? 2048 : 1024, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, @@ -247,6 +250,21 @@ if (hasOpenSSL(3, 5) || process.features.openssl_is_boringssl) { // Test bad usages { async function test(name) { + if (fips3 && name === 'ChaCha20-Poly1305') { + await assert.rejects( + subtle.generateKey({ name }, true, []), + { name: 'NotSupportedError' }); + return; + } + + if (fips35 && (name === 'X25519' || name === 'X448')) { + await assert.rejects( + subtle.generateKey({ name }, true, ['deriveBits']), + (err) => err.name === 'OperationError' && + err.cause?.code === 'ERR_OSSL_EVP_UNSUPPORTED'); + return; + } + await assert.rejects( subtle.generateKey( { @@ -453,7 +471,7 @@ if (hasOpenSSL(3, 5) || process.features.openssl_is_boringssl) { const kTests = [ [ 'RSASSA-PKCS1-v1_5', - 1024, + getFips() === 1 ? 2048 : 1024, Buffer.from([1, 0, 1]), 'SHA-1', ['sign'], @@ -461,7 +479,7 @@ if (hasOpenSSL(3, 5) || process.features.openssl_is_boringssl) { ], [ 'RSA-PSS', - 1024, + getFips() === 1 ? 2048 : 1024, Buffer.from([1, 0, 1]), 'SHA-256', ['sign'], @@ -470,22 +488,37 @@ if (hasOpenSSL(3, 5) || process.features.openssl_is_boringssl) { ]; + let fipsExponentTest; if (!process.features.openssl_is_boringssl) { - kTests.push( - [ - 'RSA-OAEP', - 1024, - Buffer.from([3]), - 'SHA3-256', - ['decrypt', 'unwrapKey'], - ['encrypt', 'wrapKey'], - ], - ); + if (fips3) { + fipsExponentTest = assert.rejects( + subtle.generateKey({ + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: Buffer.from([3]), + hash: 'SHA3-256', + }, true, ['decrypt', 'unwrapKey', 'encrypt', 'wrapKey']), + (err) => err.name === 'OperationError' && + err.cause?.code === 'ERR_OSSL_RSA_PUB_EXPONENT_OUT_OF_RANGE'); + } else { + kTests.push( + [ + 'RSA-OAEP', + 1024, + Buffer.from([3]), + 'SHA3-256', + ['decrypt', 'unwrapKey'], + ['encrypt', 'wrapKey'], + ], + ); + } } else { common.printSkipMessage('Skipping unsupported SHA-3 test case'); } const tests = kTests.map((args) => test(...args)); + if (fipsExponentTest !== undefined) + tests.push(fipsExponentTest); Promise.all(tests).then(common.mustCall()); } @@ -706,6 +739,13 @@ assert.throws(() => new CryptoKey(), { code: 'ERR_ILLEGAL_CONSTRUCTOR' }); // Test OKP Key Generation { + async function testFipsUnsupported(name) { + await assert.rejects( + subtle.generateKey({ name }, true, ['deriveKey', 'deriveBits']), + (err) => err.name === 'OperationError' && + err.cause?.code === 'ERR_OSSL_EVP_UNSUPPORTED'); + } + async function test( name, privateUsages, @@ -770,7 +810,12 @@ assert.throws(() => new CryptoKey(), { code: 'ERR_ILLEGAL_CONSTRUCTOR' }); common.printSkipMessage('Skipping unsupported Curve448 test cases'); } - const tests = kTests.map((args) => test(...args)); + const tests = kTests.map((args) => { + const [name] = args; + if (fips35 && (name === 'X25519' || name === 'X448')) + return testFipsUnsupported(name); + return test(...args); + }); Promise.all(tests).then(common.mustCall()); } diff --git a/test/parallel/test-webcrypto-promise-prototype-pollution.mjs b/test/parallel/test-webcrypto-promise-prototype-pollution.mjs index 5c13561dc260..6d8a3fa3df9f 100644 --- a/test/parallel/test-webcrypto-promise-prototype-pollution.mjs +++ b/test/parallel/test-webcrypto-promise-prototype-pollution.mjs @@ -24,7 +24,21 @@ if (!common.hasCrypto) common.skip('missing crypto'); const require = createRequire(import.meta.url); const { kSupportedAlgorithms } = require('internal/crypto/util'); +const { getFips } = require('node:crypto'); +const { hasFIPS } = require('../common/crypto'); const { subtle } = globalThis.crypto; +const fips3 = hasFIPS(3); +const fips35 = hasFIPS(3, 5); +const fips4 = hasFIPS(4); +const fips35UnavailableKeyGeneration = new Set([ + 'X25519', + 'X448', +]); +const fips3UnavailableDerivation = new Set([ + 'Argon2d', + 'Argon2i', + 'Argon2id', +]); Promise.prototype.then = common.mustNotCall('Promise.prototype.then'); @@ -333,7 +347,7 @@ function algorithm(name, params = {}) { function rsaAlgorithm(name) { return algorithm(name, { - modulusLength: 1024, + modulusLength: getFips() === 1 ? 2048 : 1024, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256', }); @@ -877,8 +891,8 @@ for (const name of ['HKDF', 'PBKDF2']) { }) : algorithm(name, { hash: 'SHA-256', - salt: new Uint8Array(8), - iterations: 1, + salt: new Uint8Array(fips4 ? 16 : 8), + iterations: fips4 ? 1000 : 1, }), })); } @@ -1029,6 +1043,24 @@ for (const [name, operations] of supportedAlgorithms) { assert(fixture, `missing prototype pollution fixture for ${name}`); const ctx = { __proto__: null }; + if (fips3 && fips3UnavailableDerivation.has(name)) { + await fixture.importKey(ctx); + await assert.rejects( + fixture.deriveBits(ctx), + (err) => err.name === 'OperationError' && + err.cause?.code === 'ERR_OSSL_EVP_UNSUPPORTED'); + continue; + } + if ((fips3 && name === 'ChaCha20-Poly1305') || + (fips35 && fips35UnavailableKeyGeneration.has(name))) { + const expected = name === 'ChaCha20-Poly1305' ? + { name: 'NotSupportedError' } : + (err) => err.name === 'OperationError' && + err.cause?.code === 'ERR_OSSL_EVP_UNSUPPORTED'; + await assert.rejects(fixture.generateKey(ctx), expected); + continue; + } + for (const operation of operationOrder) { if (!operations.has(operation)) continue; @@ -1036,7 +1068,17 @@ for (const [name, operations] of supportedAlgorithms) { typeof fixture[operation], 'function', `missing prototype pollution coverage for ${name} ${operation}`); - await fixture[operation](ctx); + if (fips3 && name === 'AES-OCB' && + (operation === 'encrypt' || operation === 'decrypt')) { + if (operation === 'decrypt') + ctx.ciphertext = new Uint8Array(); + await assert.rejects( + fixture[operation](ctx), + (err) => err.name === 'OperationError' && + err.cause?.code === 'ERR_OSSL_EVP_UNSUPPORTED'); + } else { + await fixture[operation](ctx); + } } if (typeof fixture.getPublicKey === 'function' && @@ -1074,17 +1116,22 @@ for (const name of getKeyLengthAlgorithms) { continue; } - await assertCryptoKeyResult(`get key length ${name}`, () => + const deriveKey = () => assertCryptoKeyResult(`get key length ${name}`, () => subtle.deriveKey( algorithm('PBKDF2', { hash: 'SHA-256', - salt: new Uint8Array(8), - iterations: 1, + salt: new Uint8Array(fips4 ? 16 : 8), + iterations: fips4 ? 1000 : 1, }), pbkdf2Key, target.algorithm, true, target.usages)); + if (fips3 && name === 'ChaCha20-Poly1305') { + await assert.rejects(deriveKey(), { name: 'NotSupportedError' }); + } else { + await deriveKey(); + } } // Keep one explicit unwrapKey('jwk') negative case: the parsed object must not diff --git a/test/parallel/test-webcrypto-raw-format-aliases.js b/test/parallel/test-webcrypto-raw-format-aliases.js index 94e9474fde13..c9a0e8a4cf48 100644 --- a/test/parallel/test-webcrypto-raw-format-aliases.js +++ b/test/parallel/test-webcrypto-raw-format-aliases.js @@ -6,7 +6,9 @@ if (!common.hasCrypto) common.skip('missing crypto'); const assert = require('assert'); +const { hasFIPS } = require('../common/crypto'); const { subtle } = globalThis.crypto; +const rejectsXCurves = hasFIPS(3, 5); function getAlgorithmName(algorithm) { return typeof algorithm === 'string' ? algorithm : algorithm.name; @@ -50,7 +52,7 @@ async function assertPublicKeyDoesNotAcceptRawSecret( importUsages); } -Promise.all([ +const tests = [ assertSecretKeyDoesNotAcceptRawPublic('HKDF'), assertSecretKeyDoesNotAcceptRawPublic('PBKDF2'), assertPublicKeyDoesNotAcceptRawSecret( @@ -65,8 +67,18 @@ Promise.all([ 'Ed25519', ['sign', 'verify'], ['verify']), - assertPublicKeyDoesNotAcceptRawSecret( +]; + +if (rejectsXCurves) { + tests.push(assert.rejects( + subtle.generateKey('X25519', true, ['deriveBits']), + (err) => err.name === 'OperationError' && + err.cause?.code === 'ERR_OSSL_EVP_UNSUPPORTED')); +} else { + tests.push(assertPublicKeyDoesNotAcceptRawSecret( 'X25519', ['deriveBits'], - []), -]).then(common.mustCall()); + [])); +} + +Promise.all(tests).then(common.mustCall()); diff --git a/test/parallel/test-webcrypto-sign-verify-ecdsa.js b/test/parallel/test-webcrypto-sign-verify-ecdsa.js index eb7814efa556..94e3eff02eb9 100644 --- a/test/parallel/test-webcrypto-sign-verify-ecdsa.js +++ b/test/parallel/test-webcrypto-sign-verify-ecdsa.js @@ -6,7 +6,10 @@ if (!common.hasCrypto) common.skip('missing crypto'); const assert = require('assert'); +const { getFips } = require('crypto'); +const { hasFIPS } = require('../common/crypto'); const { subtle } = globalThis.crypto; +const rejectsSha1Signing = hasFIPS(3) && !hasFIPS(3, 5); const vectors = require('../fixtures/crypto/ecdsa')(); @@ -50,7 +53,7 @@ async function testVerify({ name, subtle.generateKey( { name: 'RSA-PSS', - modulusLength: 1024, + modulusLength: getFips() === 1 ? 2048 : 1024, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256', }, @@ -173,7 +176,7 @@ async function testSign({ name, subtle.generateKey( { name: 'RSA-PSS', - modulusLength: 1024, + modulusLength: getFips() === 1 ? 2048 : 1024, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256', }, @@ -229,6 +232,32 @@ async function testSign({ name, for (let i = 0; i < vectors.length; ++i) { const vector = vectors[i]; + if (rejectsSha1Signing && vector.hash === 'SHA-1') { + const publicKey = await subtle.importKey( + 'spki', + vector.publicKeyBuffer, + { name: vector.name, namedCurve: vector.namedCurve }, + false, + ['verify']); + const privateKey = await subtle.importKey( + 'pkcs8', + vector.privateKeyBuffer, + { name: vector.name, namedCurve: vector.namedCurve }, + false, + ['sign']); + assert(await subtle.verify( + { name: vector.name, hash: vector.hash }, + publicKey, + vector.signature, + vector.plaintext)); + await assert.rejects( + subtle.sign( + { name: vector.name, hash: vector.hash }, + privateKey, + vector.plaintext), + { name: 'OperationError' }); + continue; + } variations.push(testVerify(vector)); variations.push(testSign(vector)); } diff --git a/test/parallel/test-webcrypto-sign-verify-eddsa.js b/test/parallel/test-webcrypto-sign-verify-eddsa.js index 3c40139754be..ad587a1220e0 100644 --- a/test/parallel/test-webcrypto-sign-verify-eddsa.js +++ b/test/parallel/test-webcrypto-sign-verify-eddsa.js @@ -92,7 +92,7 @@ async function testVerify({ name, subtle.generateKey( { name: 'RSA-PSS', - modulusLength: 1024, + modulusLength: crypto.getFips() === 1 ? 2048 : 1024, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256', }, @@ -219,7 +219,7 @@ async function testSign({ name, subtle.generateKey( { name: 'RSA-PSS', - modulusLength: 1024, + modulusLength: crypto.getFips() === 1 ? 2048 : 1024, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256', }, diff --git a/test/parallel/test-webcrypto-sign-verify-hmac.js b/test/parallel/test-webcrypto-sign-verify-hmac.js index ac3841fad79a..bbe5dd498109 100644 --- a/test/parallel/test-webcrypto-sign-verify-hmac.js +++ b/test/parallel/test-webcrypto-sign-verify-hmac.js @@ -6,6 +6,7 @@ if (!common.hasCrypto) common.skip('missing crypto'); const assert = require('assert'); +const { getFips } = require('crypto'); const { subtle } = globalThis.crypto; const vectors = require('../fixtures/crypto/hmac')(); @@ -35,7 +36,7 @@ async function testVerify({ hash, subtle.generateKey( { name: 'RSA-PSS', - modulusLength: 1024, + modulusLength: getFips() === 1 ? 2048 : 1024, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256', }, @@ -126,7 +127,7 @@ async function testSign({ hash, subtle.generateKey( { name: 'RSA-PSS', - modulusLength: 1024, + modulusLength: getFips() === 1 ? 2048 : 1024, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256', }, diff --git a/test/parallel/test-webcrypto-sign-verify-kmac.js b/test/parallel/test-webcrypto-sign-verify-kmac.js index f93fc293b2a4..160067b9b760 100644 --- a/test/parallel/test-webcrypto-sign-verify-kmac.js +++ b/test/parallel/test-webcrypto-sign-verify-kmac.js @@ -5,16 +5,29 @@ const common = require('../common'); if (!common.hasCrypto) common.skip('missing crypto'); -const { hasOpenSSL } = require('../common/crypto'); +const { hasFIPS, hasOpenSSL } = require('../common/crypto'); if (!hasOpenSSL(3)) common.skip('requires OpenSSL >= 3'); const assert = require('assert'); const { subtle } = globalThis.crypto; +const fips4 = hasFIPS(4); const vectors = require('../fixtures/crypto/kmac')(); +function isFipsUnsupported(err) { + return err.name === 'OperationError' && + err.cause?.code === 'ERR_OSSL_EVP_UNSUPPORTED'; +} + +function isFips4Incompatible({ key, keyLength, outputLength }) { + const keyLengthInBits = keyLength ?? key.byteLength * 8; + return keyLengthInBits < 128 || + keyLengthInBits % 8 !== 0 || + outputLength % 8 !== 0; +} + async function testVerify({ algorithm, key, keyLength, @@ -193,8 +206,13 @@ async function testSign({ algorithm, const variations = []; for (const vector of vectors) { - variations.push(testVerify(vector)); - variations.push(testSign(vector)); + if (fips4 && isFips4Incompatible(vector)) { + variations.push(assert.rejects(testVerify(vector), isFipsUnsupported)); + variations.push(assert.rejects(testSign(vector), isFipsUnsupported)); + } else { + variations.push(testVerify(vector)); + variations.push(testSign(vector)); + } } await Promise.all(variations); @@ -209,25 +227,47 @@ async function testSign({ algorithm, ['sign', 'verify']); const algorithm = { name: 'KMAC128', - outputLength: 9, + outputLength: fips4 ? 16 : 9, customization: new Uint8Array(), }; const data = new Uint8Array([1, 2, 3]); + if (fips4) { + await assert.rejects( + subtle.sign({ ...algorithm, outputLength: 9 }, key, data), + isFipsUnsupported); + } + const signature = await subtle.sign(algorithm, key, data); assert.strictEqual(signature.byteLength, 2); - assert.strictEqual(new Uint8Array(signature)[1] & 0b01111111, 0); + if (!fips4) + assert.strictEqual(new Uint8Array(signature)[1] & 0b01111111, 0); assert(await subtle.verify(algorithm, key, signature, data)); - const signature16 = new Uint8Array(await subtle.sign({ - ...algorithm, - outputLength: 16, - }, key, data)); - signature16[1] &= 0b10000000; - assert.notDeepStrictEqual(new Uint8Array(signature), signature16); + if (fips4) { + const signature128 = await subtle.sign({ + ...algorithm, + outputLength: 128, + }, key, data); + assert.strictEqual(signature128.byteLength, 16); + assert(await subtle.verify({ + ...algorithm, + outputLength: 128, + }, key, signature128, data)); + } else { + const signature16 = new Uint8Array(await subtle.sign({ + ...algorithm, + outputLength: 16, + }, key, data)); + signature16[1] &= 0b10000000; + assert.notDeepStrictEqual(new Uint8Array(signature), signature16); + } const invalidSignature = new Uint8Array(signature); - invalidSignature[1] |= 0b00000001; + if (fips4) + invalidSignature[0] ^= 0b00000001; + else + invalidSignature[1] |= 0b00000001; assert(!(await subtle.verify(algorithm, key, invalidSignature, data))); const nonByteKey = await subtle.importKey( @@ -236,15 +276,20 @@ async function testSign({ algorithm, { name: 'KMAC128', length: 25 }, false, ['sign', 'verify']); - const nonByteKeySignature = await subtle.sign({ + const nonByteKeySignature = subtle.sign({ ...algorithm, outputLength: 16, }, nonByteKey, data); - assert.strictEqual(nonByteKeySignature.byteLength, 2); - assert(await subtle.verify({ - ...algorithm, - outputLength: 16, - }, nonByteKey, nonByteKeySignature, data)); + if (fips4) { + await assert.rejects(nonByteKeySignature, isFipsUnsupported); + } else { + const result = await nonByteKeySignature; + assert.strictEqual(result.byteLength, 2); + assert(await subtle.verify({ + ...algorithm, + outputLength: 16, + }, nonByteKey, result, data)); + } })().then(common.mustCall()); (async function() { @@ -265,9 +310,14 @@ async function testSign({ algorithm, assert.strictEqual(key.algorithm.length, keyData.byteLength * 8); const algorithm = { name, outputLength: 256 }; - const signature = await subtle.sign(algorithm, key, data); - assert.strictEqual(signature.byteLength, 32); - assert(await subtle.verify(algorithm, key, signature, data)); + const signature = subtle.sign(algorithm, key, data); + if (fips4) { + await assert.rejects(signature, isFipsUnsupported); + } else { + const result = await signature; + assert.strictEqual(result.byteLength, 32); + assert(await subtle.verify(algorithm, key, result, data)); + } } } })().then(common.mustCall()); diff --git a/test/parallel/test-webcrypto-sign-verify-ml-dsa.js b/test/parallel/test-webcrypto-sign-verify-ml-dsa.js index b11e65ade791..67c85f92e4e2 100644 --- a/test/parallel/test-webcrypto-sign-verify-ml-dsa.js +++ b/test/parallel/test-webcrypto-sign-verify-ml-dsa.js @@ -43,7 +43,7 @@ async function testVerify({ name, subtle.generateKey( { name: 'RSA-PSS', - modulusLength: 1024, + modulusLength: crypto.getFips() === 1 ? 2048 : 1024, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256', }, @@ -156,7 +156,7 @@ async function testSign({ name, subtle.generateKey( { name: 'RSA-PSS', - modulusLength: 1024, + modulusLength: crypto.getFips() === 1 ? 2048 : 1024, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256', }, diff --git a/test/parallel/test-webcrypto-sign-verify-rsa.js b/test/parallel/test-webcrypto-sign-verify-rsa.js index 0ccbf431f147..3e706941595d 100644 --- a/test/parallel/test-webcrypto-sign-verify-rsa.js +++ b/test/parallel/test-webcrypto-sign-verify-rsa.js @@ -6,7 +6,10 @@ if (!common.hasCrypto) common.skip('missing crypto'); const assert = require('assert'); +const { hasFIPS } = require('../common/crypto'); const { subtle } = globalThis.crypto; +const fips3 = hasFIPS(3); +const rejectsSha1Signing = hasFIPS(3) && !hasFIPS(3, 5); const rsa_pkcs = require('../fixtures/crypto/rsa_pkcs'); const rsa_pss = require('../fixtures/crypto/rsa_pss'); @@ -194,6 +197,23 @@ async function testSign({ }); } +async function testFipsSignRejected({ + algorithm, + hash, + privateKeyBuffer, + plaintext, +}) { + const privateKey = await subtle.importKey( + 'pkcs8', + privateKeyBuffer, + { name: algorithm.name, hash }, + false, + ['sign']); + await assert.rejects( + subtle.sign(algorithm, privateKey, plaintext), + { name: 'OperationError' }); +} + async function testSaltLength(keyLength, hash, hLen) { const { publicKey, privateKey } = await subtle.generateKey({ name: 'RSA-PSS', @@ -205,7 +225,8 @@ async function testSaltLength(keyLength, hash, hLen) { const data = Buffer.from('Hello, world!'); const max = keyLength / 8 - hLen - 2; - const signature = await subtle.sign({ name: 'RSA-PSS', saltLength: max }, privateKey, data); + const signature = await subtle.sign( + { name: 'RSA-PSS', saltLength: max }, privateKey, data); await assert.rejects( subtle.sign({ name: 'RSA-PSS', saltLength: max + 1 }, privateKey, data), (err) => { assert.strictEqual(err.name, 'OperationError'); @@ -213,7 +234,8 @@ async function testSaltLength(keyLength, hash, hLen) { assert.strictEqual(err.cause?.message, `The value of "algorithm.saltLength" is out of range. It must be >= 0 && <= ${max}. Received ${max + 1}`); return true; }); - await subtle.verify({ name: 'RSA-PSS', saltLength: max }, publicKey, signature, data); + await subtle.verify( + { name: 'RSA-PSS', saltLength: max }, publicKey, signature, data); await assert.rejects( subtle.verify({ name: 'RSA-PSS', saltLength: max + 1 }, publicKey, signature, data), (err) => { assert.strictEqual(err.name, 'OperationError'); @@ -228,14 +250,28 @@ async function testSaltLength(keyLength, hash, hLen) { rsa_pkcs().forEach((vector) => { variations.push(testVerify(vector)); - variations.push(testSign(vector)); + variations.push(rejectsSha1Signing && vector.hash === 'SHA-1' ? + testFipsSignRejected(vector) : testSign(vector)); }); rsa_pss().forEach((vector) => { variations.push(testVerify(vector)); - variations.push(testSign(vector)); + variations.push(rejectsSha1Signing && vector.hash === 'SHA-1' ? + testFipsSignRejected(vector) : testSign(vector)); }); - for (const keyLength of [1024, 2048]) { + if (fips3) { + variations.push(assert.rejects( + subtle.generateKey({ + name: 'RSA-PSS', + modulusLength: 1024, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, false, ['sign', 'verify']), + (err) => err.name === 'OperationError' && + err.cause?.code === 'ERR_OSSL_RSA_INVALID_MODULUS')); + } + + for (const keyLength of fips3 ? [2048] : [1024, 2048]) { for (const [hash, hLen] of [ ['SHA-1', 20], ['SHA-256', 32], @@ -247,6 +283,8 @@ async function testSaltLength(keyLength, hash, hLen) { ['SHA3-512', 64], ] : []), ]) { + if (rejectsSha1Signing && hash === 'SHA-1') + continue; variations.push(testSaltLength(keyLength, hash, hLen)); } } diff --git a/test/parallel/test-webcrypto-sign-verify.js b/test/parallel/test-webcrypto-sign-verify.js index 0a6f5cffe7b9..db6c9e093872 100644 --- a/test/parallel/test-webcrypto-sign-verify.js +++ b/test/parallel/test-webcrypto-sign-verify.js @@ -8,6 +8,7 @@ if (!common.hasCrypto) const { hasOpenSSL } = require('../common/crypto'); const assert = require('assert'); +const { getFips } = require('crypto'); const { subtle } = globalThis.crypto; // This is only a partial test. The WebCrypto Web Platform Tests @@ -19,7 +20,7 @@ const { subtle } = globalThis.crypto; const ec = new TextEncoder(); const { publicKey, privateKey } = await subtle.generateKey({ name: 'RSASSA-PKCS1-v1_5', - modulusLength: 1024, + modulusLength: getFips() === 1 ? 2048 : 1024, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, true, ['sign', 'verify']); diff --git a/test/parallel/test-webcrypto-supports.mjs b/test/parallel/test-webcrypto-supports.mjs index 43d3ee03c8bc..2bc9d589b478 100644 --- a/test/parallel/test-webcrypto-supports.mjs +++ b/test/parallel/test-webcrypto-supports.mjs @@ -4,6 +4,11 @@ if (!common.hasCrypto) common.skip('missing crypto'); import * as assert from 'node:assert'; +import { hasFIPS } from '../common/crypto.js'; + +if (hasFIPS(3)) + common.skip('SubtleCrypto.supports() does not reflect FIPS provider availability'); + const { SubtleCrypto } = globalThis; const sources = [ diff --git a/test/parallel/test-webcrypto-wrap-unwrap.js b/test/parallel/test-webcrypto-wrap-unwrap.js index 18ba6dc37963..342eae0859e4 100644 --- a/test/parallel/test-webcrypto-wrap-unwrap.js +++ b/test/parallel/test-webcrypto-wrap-unwrap.js @@ -5,10 +5,13 @@ const common = require('../common'); if (!common.hasCrypto) common.skip('missing crypto'); -const { hasOpenSSL } = require('../common/crypto'); +const { hasOpenSSL, hasFIPS } = require('../common/crypto'); const assert = require('assert'); +const { getFips } = require('crypto'); const { subtle } = globalThis.crypto; +const fips3 = hasFIPS(3); +const fips35 = hasFIPS(3, 5); const kWrappingData = { 'RSA-OAEP': { @@ -35,7 +38,7 @@ const kWrappingData = { wrap: { iv: new Uint8Array(16), additionalData: new Uint8Array(16), - tagLength: 64 + tagLength: fips3 ? 128 : 64 }, pair: false }, @@ -54,7 +57,10 @@ const kWrappingData = { } }; -if (hasOpenSSL(3)) { +if (fips3) + delete kWrappingData['ChaCha20-Poly1305']; + +if (hasOpenSSL(3) && !fips3) { kWrappingData['AES-OCB'] = { generate: { length: 128 }, wrap: { @@ -87,7 +93,7 @@ async function generateKeysToWrap() { { algorithm: { name: 'RSASSA-PKCS1-v1_5', - modulusLength: 1024, + modulusLength: getFips() === 1 ? 2048 : 1024, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, @@ -98,7 +104,7 @@ async function generateKeysToWrap() { { algorithm: { name: 'RSA-PSS', - modulusLength: 1024, + modulusLength: getFips() === 1 ? 2048 : 1024, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, @@ -109,7 +115,7 @@ async function generateKeysToWrap() { { algorithm: { name: 'RSA-OAEP', - modulusLength: 1024, + modulusLength: getFips() === 1 ? 2048 : 1024, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, @@ -234,6 +240,20 @@ async function generateKeysToWrap() { common.printSkipMessage('Skipping unsupported Curve test cases'); } + if (fips3) { + const unsupported = new Set([ + 'ChaCha20-Poly1305', + ]); + if (fips35) { + unsupported.add('X25519'); + unsupported.add('X448'); + } + for (let i = parameters.length - 1; i >= 0; --i) { + if (unsupported.has(parameters[i].algorithm.name)) + parameters.splice(i, 1); + } + } + const allkeys = await Promise.all(parameters.map(async (params) => { const usages = 'usages' in params ? params.usages : @@ -360,6 +380,37 @@ function testWrapping(name, keys) { } (async function() { + if (fips3) { + await assert.rejects( + subtle.generateKey( + { name: 'ChaCha20-Poly1305' }, true, ['wrapKey']), + { name: 'NotSupportedError' }); + + if (fips35) { + for (const name of ['X25519', 'X448']) { + await assert.rejects( + subtle.generateKey({ name }, true, ['deriveBits']), + (err) => err.name === 'OperationError' && + err.cause?.code === 'ERR_OSSL_EVP_UNSUPPORTED'); + } + } + + const wrappingKey = await subtle.generateKey( + { name: 'AES-OCB', length: 128 }, true, ['wrapKey']); + const key = await subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256', length: 256 }, + true, + ['sign']); + await assert.rejects( + subtle.wrapKey( + 'raw', + key, + wrappingKey, + { name: 'AES-OCB', iv: new Uint8Array(15), tagLength: 128 }), + (err) => err.name === 'OperationError' && + err.cause?.code === 'ERR_OSSL_EVP_UNSUPPORTED'); + } + await generateWrappingKeys(); const keys = await generateKeysToWrap(); const variations = []; diff --git a/test/pummel/test-crypto-argon2-nonblocking-constructor.js b/test/pummel/test-crypto-argon2-nonblocking-constructor.js index 37cb5363d43c..4e0ff50c3fa3 100644 --- a/test/pummel/test-crypto-argon2-nonblocking-constructor.js +++ b/test/pummel/test-crypto-argon2-nonblocking-constructor.js @@ -4,10 +4,12 @@ const common = require('../common'); if (!common.hasCrypto) common.skip('missing crypto'); -const { hasOpenSSL } = require('../common/crypto'); +const { hasFIPS, hasOpenSSL } = require('../common/crypto'); if (!hasOpenSSL(3, 2)) common.skip('requires OpenSSL >= 3.2'); +if (hasFIPS(3)) + common.skip('Argon2 is not available in FIPS mode'); // Regression test for https://github.com/nodejs/node/issues/62861. // `AdditionalConfig` used to invoke the full Argon2 KDF synchronously inside diff --git a/test/pummel/test-crypto-dh-keys.js b/test/pummel/test-crypto-dh-keys.js index 8aa1e30e354f..824628732a55 100644 --- a/test/pummel/test-crypto-dh-keys.js +++ b/test/pummel/test-crypto-dh-keys.js @@ -32,11 +32,21 @@ if (common.isPi()) { const assert = require('assert'); const crypto = require('crypto'); +const { hasFIPS } = require('../common/crypto'); for (const name of ['modp1', 'modp2', 'modp5', 'modp14', 'modp15', 'modp16', 'modp17']) { // modp1 is 768 bits, FIPS requires >= 1024. // BoringSSL does not support modp1 or modp2. - if ((name === 'modp1' && crypto.getFips()) || + if (hasFIPS(3) && ['modp1', 'modp2', 'modp5'].includes(name)) { + const parameters = crypto.getDiffieHellman(name); + const group = crypto.createDiffieHellman( + parameters.getPrime(), parameters.getGenerator()); + assert.throws(() => group.generateKeys(), { + code: 'ERR_CRYPTO_OPERATION_FAILED', + }); + continue; + } + if ((name === 'modp1' && crypto.getFips() === 1) || (process.features.openssl_is_boringssl && (name === 'modp1' || name === 'modp2'))) { common.printSkipMessage(`Skipping unsupported ${name} test case`); diff --git a/test/pummel/test-dh-regr.js b/test/pummel/test-dh-regr.js index 961bea3246fb..c442fbc3a809 100644 --- a/test/pummel/test-dh-regr.js +++ b/test/pummel/test-dh-regr.js @@ -32,15 +32,30 @@ if (common.isPi()) { const assert = require('assert'); const crypto = require('crypto'); -const { hasOpenSSL3 } = require('../common/crypto'); +const { hasOpenSSL, hasFIPS } = require('../common/crypto'); -// FIPS requires length >= 1024 but we use 512/256 in this test to keep it from -// taking too long and timing out in CI. -const length = crypto.getFips() ? 1024 : hasOpenSSL3 ? 512 : 256; +let p; +let iterations = 2000; +if (hasFIPS(3)) { + assert.throws(() => crypto.createDiffieHellman(1024), { + code: 'ERR_INVALID_ARG_VALUE', + name: 'TypeError', + }); -const p = crypto.createDiffieHellman(length).getPrime(); + // Use a precomputed approved group instead of generating a 2048-bit prime + // for every test run. Its larger keys also make each pummel iteration more + // expensive, so use enough iterations to exercise the regression without + // making the FIPS job excessively slow. + p = crypto.getDiffieHellman('modp14').getPrime(); + iterations = 100; +} else { + // FIPS requires length >= 1024, but small parameters keep this pummel test + // from timing out in ordinary CI. + const length = crypto.getFips() === 1 ? 1024 : (hasOpenSSL(3) ? 512 : 256); + p = crypto.createDiffieHellman(length).getPrime(); +} -for (let i = 0; i < 2000; i++) { +for (let i = 0; i < iterations; i++) { const a = crypto.createDiffieHellman(p); const b = crypto.createDiffieHellman(p); diff --git a/test/pummel/test-webcrypto-derivebits-pbkdf2.js b/test/pummel/test-webcrypto-derivebits-pbkdf2.js index bfb01ac0c94f..9e6cdcad8ab0 100644 --- a/test/pummel/test-webcrypto-derivebits-pbkdf2.js +++ b/test/pummel/test-webcrypto-derivebits-pbkdf2.js @@ -11,7 +11,9 @@ if (common.isPi()) { } const assert = require('assert'); +const { hasFIPS } = require('../common/crypto'); const { subtle } = globalThis.crypto; +const fips4 = hasFIPS(4); function getDeriveKeyInfo(name, length, hash, ...usages) { return [{ name, length, hash }, usages]; @@ -632,6 +634,19 @@ async function testWrongKeyType( Object.keys(kDerivations[size][saltSize][hash]) .forEach((iterations) => { const args = [baseKeys, size, saltSize, hash, iterations | 0]; + if (fips4 && + (size === 'empty' || saltSize !== 'long' || iterations < 1000)) { + variations.push(assert.rejects( + testDeriveBits(...args), { name: 'OperationError' })); + kDerivedKeyTypes.forEach((keyType) => { + const keyArgs = getDeriveKeyInfo(...keyType); + variations.push(assert.rejects( + testDeriveKey(...args, ...keyArgs), + { name: 'OperationError' })); + }); + return; + } + variations.push(testDeriveBits(...args)); variations.push(testDeriveBitsBadLengths(...args)); variations.push(testDeriveBitsBadHash(...args)); @@ -674,12 +689,17 @@ async function testWrongKeyType( // https://github.com/w3c/webcrypto/pull/380 { - crypto.subtle.importKey('raw', new Uint8Array(0), 'PBKDF2', false, ['deriveBits']).then((key) => { + crypto.subtle.importKey( + 'raw', + new Uint8Array(fips4 ? 8 : 0), + 'PBKDF2', + false, + ['deriveBits']).then((key) => { return crypto.subtle.deriveBits({ name: 'PBKDF2', hash: { name: 'SHA-256' }, - iterations: 10, - salt: new Uint8Array(0), + iterations: fips4 ? 1000 : 10, + salt: new Uint8Array(fips4 ? 16 : 0), }, key, 0); }).then((bits) => { assert.deepStrictEqual(bits, new ArrayBuffer(0)); diff --git a/test/sequential/test-async-wrap-getasyncid.js b/test/sequential/test-async-wrap-getasyncid.js index 5db4a7763158..ef48f457a878 100644 --- a/test/sequential/test-async-wrap-getasyncid.js +++ b/test/sequential/test-async-wrap-getasyncid.js @@ -139,6 +139,7 @@ function testInitialized(req, ctor_name) { if (common.hasCrypto) { // eslint-disable-line node-core/crypto-check const crypto = require('crypto'); + const { hasFIPS } = require('../common/crypto'); // The handle for PBKDF2 and RandomBytes isn't returned by the function call, // so need to check it from the callback. @@ -152,7 +153,8 @@ if (common.hasCrypto) { // eslint-disable-line node-core/crypto-check testInitialized(this, 'RandomBytesJob'); })); - if (typeof internalBinding('crypto').ScryptJob === 'function') { + if (typeof internalBinding('crypto').ScryptJob === 'function' && + !hasFIPS(3)) { crypto.scrypt('password', 'salt', 8, common.mustCall(function() { testInitialized(this, 'ScryptJob'); })); diff --git a/test/wpt/status/WebCryptoAPI.cjs b/test/wpt/status/WebCryptoAPI.cjs index 8ec27109eeca..8dfd37b33b34 100644 --- a/test/wpt/status/WebCryptoAPI.cjs +++ b/test/wpt/status/WebCryptoAPI.cjs @@ -1,14 +1,20 @@ 'use strict'; -const { hasOpenSSL } = require('../../common/crypto.js'); +const { + hasOpenSSL, + hasFIPS, +} = require('../../common/crypto.js'); const conditionalFileSkips = {}; const conditionalSubtestSkips = {}; function skip(...files) { for (const file of files) { + const provider = process.features.openssl_is_boringssl ? + 'BoringSSL' : + `OpenSSL ${process.versions.openssl}${hasFIPS(3) ? ' FIPS mode' : ''}`; conditionalFileSkips[file] = { - 'skip': 'Unsupported in ' + (process.features.openssl_is_boringssl ? 'BoringSSL' : `OpenSSL ${process.versions.openssl}`), + 'skip': `Unsupported in ${provider}`, }; } } @@ -37,7 +43,7 @@ if (!hasOpenSSL(3, 0)) { 'sign_verify/kmac.tentative.https.any.js'); } -if (!hasOpenSSL(3, 2)) { +if (!hasOpenSSL(3, 2) || hasFIPS(3)) { skip( 'derive_bits_keys/argon2.tentative.https.any.js', 'import_export/Argon2_importKey.tentative.https.any.js'); @@ -91,6 +97,80 @@ if (process.features.openssl_is_boringssl) { ['supports-modern.tentative.https.any.js', /ml-kem-512/i]); } +if (hasFIPS(3)) { + skip( + 'encrypt_decrypt/aes_ocb.tentative.https.any.js', + 'encrypt_decrypt/chacha20_poly1305.tentative.https.any.js', + 'generateKey/failures_chacha20_poly1305.tentative.https.any.js', + 'generateKey/successes_chacha20_poly1305.tentative.https.any.js', + 'import_export/ChaCha20-Poly1305_importKey.tentative.https.any.js', + 'serialization/chacha20-poly1305.tentative.https.any.js'); + + skipSubtests( + [ + 'supports-modern.tentative.https.any.js', + /(?:ChaCha20-Poly1305|^supports returns (?:true|false) for algorithm objects with (?:valid|invalid) parameters$)/, + ], + [ + 'wrapKey_unwrapKey/wrapKey_unwrapKey.https.any.js', + /(?=.*(?:RSASSA-PKCS1-v1_5|RSA-PSS|RSA-OAEP) private key)(?=.*non-extractable)/, + ]); +} + +// OpenSSL 3.0 through 3.3 reject SHA-1 signature generation in FIPS mode. +// OpenSSL 3.4 permits it for legacy use cases while marking the operation as +// non-approved through a per-operation FIPS indicator. Node does not expose +// that indicator, so the round-trip tests succeed. +if (hasFIPS(3) && !hasOpenSSL(3, 4)) { + skipSubtests( + ['sign_verify/ecdsa.https.any.js', /with SHA-1.*round trip$/], + ['sign_verify/rsa_pkcs.https.any.js', /with SHA-1.*round trip$/], + ['sign_verify/rsa_pss.https.any.js', /with SHA-1.*round trip$/]); +} + +if (hasFIPS(3, 5)) { + skip( + 'derive_bits_keys/cfrg_curves_bits_curve25519.https.any.js', + 'derive_bits_keys/cfrg_curves_bits_curve448.tentative.https.any.js', + 'derive_bits_keys/cfrg_curves_keys_curve25519.https.any.js', + 'derive_bits_keys/cfrg_curves_keys_curve448.tentative.https.any.js', + 'generateKey/successes_X25519.https.any.js', + 'generateKey/successes_X448.tentative.https.any.js', + 'import_export/okp_importKey_X25519.https.any.js', + 'import_export/okp_importKey_X448.tentative.https.any.js', + 'import_export/okp_importKey_failures_X25519.https.any.js', + 'import_export/okp_importKey_failures_X448.tentative.https.any.js', + 'serialization/x25519.https.any.js', + 'serialization/x448.tentative.https.any.js'); + + skipSubtests( + [ + 'derive_bits_keys/derived_bits_length.https.any.js', + /^X25519 derivation/, + ], + ['getPublicKey.tentative.https.any.js', /(?:X25519|X448)/], + [ + 'import_export/raw_format_aliases.tentative.https.any.js', + /(?:X25519|X448)/, + ], + [ + 'supports.tentative.https.any.js', + /(?:X25519|^deriveKey promise tests$)/, + ], + [ + 'wrapKey_unwrapKey/wrapKey_unwrapKey.https.any.js', + /(?=.*(?:X25519|X448))(?=.*(?:jwk|as non-extractable using pkcs8))/, + ]); +} + +if (hasFIPS(4)) { + skipSubtests( + [ + 'derive_bits_keys/pbkdf2.https.any.js', + /(?:empty password|(?:short|empty) salt|with 1 iterations)/, + ]); +} + skipSubtests( ['digest/kangarootwelve.tentative.https.any.js', /C=(?:\d{4,}|5(?:1[3-9]|[2-9]\d)|[6-9]\d{2}) bytes/]); diff --git a/test/wpt/test-webcrypto.js b/test/wpt/test-webcrypto.js index 0d53a51901bb..0828047b3799 100644 --- a/test/wpt/test-webcrypto.js +++ b/test/wpt/test-webcrypto.js @@ -4,10 +4,38 @@ const common = require('../common'); if (!common.hasCrypto) common.skip('missing crypto'); +const { join } = require('node:path'); +const { hasFIPS } = require('../common/crypto'); const { WPTRunner } = require('../common/wpt'); const runner = new WPTRunner('WebCryptoAPI'); runner.pretendGlobalThisAs('Window'); +if (hasFIPS(3, 5)) { + const supportsFile = join( + 'WebCryptoAPI', + 'supports.tentative.https.any.js'); + const eagerX25519Key = ` deriveBitsParams: { + name: 'X25519', + public: crypto.subtle.generateKey('X25519', false, ['deriveBits']), + },`; + const unavailableX25519Key = ` deriveBitsParams: { + name: 'X25519', + public: undefined, + },`; + runner.setScriptModifier((script) => { + if (!script.filename.endsWith(supportsFile)) + return; + + const fragments = script.code.split(eagerX25519Key); + if (fragments.length !== 2) { + throw new Error( + `Expected exactly one eager X25519 key in ${script.filename}; ` + + `found ${fragments.length - 1}`); + } + script.code = fragments.join(unavailableX25519Key); + }); +} + runner.runJsTests(); From 66ee4792bfbfa71335f747ceae116c8d98aa74a2 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 11 Aug 2026 21:34:34 +0200 Subject: [PATCH 145/344] crypto: disable non-FIPS WebCrypto paths in FIPS mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hide TurboSHAKE and KangarooTwelve when FIPS is enabled. Reject cSHAKE and KMAC parameters that require implementations outside the OpenSSL provider, while keeping provider-backed paths available. Signed-off-by: Filip Skokan PR-URL: https://github.com/nodejs/node/pull/65172 Reviewed-By: Matteo Collina Reviewed-By: Yagiz Nizipli Reviewed-By: Tobias Nießen --- lib/internal/crypto/mac.js | 4 + lib/internal/crypto/util.js | 14 ++ lib/internal/crypto/webidl.js | 51 +++-- src/crypto/crypto_hash.cc | 5 + src/crypto/crypto_kmac.cc | 2 + src/crypto/crypto_turboshake.cc | 10 + src/crypto/crypto_util.cc | 8 +- src/crypto/crypto_util.h | 1 + .../test-crypto-key-objects-to-crypto-key.js | 28 ++- test/parallel/test-webcrypto-derivekey.js | 8 +- .../test-webcrypto-digest-turboshake-rfc.js | 5 + .../test-webcrypto-digest-turboshake.js | 5 + test/parallel/test-webcrypto-digest.js | 42 ++-- test/parallel/test-webcrypto-export-import.js | 106 +++++----- .../test-webcrypto-fips-exceptions.mjs | 198 ++++++++++++++++++ test/parallel/test-webcrypto-keygen-kmac.js | 49 +++-- .../test-webcrypto-prototype-pollution.mjs | 36 ++-- .../test-webcrypto-sign-verify-kmac.js | 69 +++--- test/parallel/test-webcrypto-wrap-unwrap.js | 2 +- test/wpt/status/WebCryptoAPI.cjs | 12 +- 20 files changed, 472 insertions(+), 183 deletions(-) create mode 100644 test/parallel/test-webcrypto-fips-exceptions.mjs diff --git a/lib/internal/crypto/mac.js b/lib/internal/crypto/mac.js index f6f82238b549..3297297abd59 100644 --- a/lib/internal/crypto/mac.js +++ b/lib/internal/crypto/mac.js @@ -20,6 +20,7 @@ const { normalizeHashName, numBitsToBytes, truncateToBitLength, + validateKmacKeyLength, } = require('internal/crypto/util'); const { @@ -60,6 +61,9 @@ function normalizeKeyLength(handle, algorithm) { length = algorithm.length; } + if (algorithm.name === 'KMAC128' || algorithm.name === 'KMAC256') + validateKmacKeyLength(length); + return { handle, length }; } diff --git a/lib/internal/crypto/util.js b/lib/internal/crypto/util.js index 282ce9b35591..3a70d32b8d21 100644 --- a/lib/internal/crypto/util.js +++ b/lib/internal/crypto/util.js @@ -47,9 +47,12 @@ const { EVP_PKEY_ML_KEM_1024, kKeyVariantAES_OCB_128: hasAesOcbMode, Argon2Job, + getFipsCrypto, KmacJob, } = internalBinding('crypto'); +const isFips = getFipsCrypto() === 1; + const { getOptionValue } = require('internal/options'); const { @@ -415,6 +418,8 @@ const conditionalAlgorithms = { 'Ed448': !process.features.openssl_is_boringssl, 'KMAC128': !!KmacJob, 'KMAC256': !!KmacJob, + 'KT128': !isFips, + 'KT256': !isFips, 'ML-DSA-44': !!EVP_PKEY_ML_DSA_44, 'ML-DSA-65': !!EVP_PKEY_ML_DSA_65, 'ML-DSA-87': !!EVP_PKEY_ML_DSA_87, @@ -427,6 +432,8 @@ const conditionalAlgorithms = { ArrayPrototypeIncludes(getHashes(), 'sha3-384'), 'SHA3-512': !process.features.openssl_is_boringssl || ArrayPrototypeIncludes(getHashes(), 'sha3-512'), + 'TurboSHAKE128': !isFips, + 'TurboSHAKE256': !isFips, 'X448': !process.features.openssl_is_boringssl, }; @@ -571,6 +578,11 @@ function validateMaxBufferLength(data, name, max = kMaxBufferLength) { } } +function validateKmacKeyLength(length) { + if ((length < 32 || length % 8) && isFips) + throw lazyDOMException('Invalid key length', 'NotSupportedError'); +} + /** * Converts a bit length to the number of bytes needed to contain it. * Non-byte lengths are rounded up to the next byte. @@ -1088,6 +1100,7 @@ module.exports = { kNamedCurveAliases, kSupportedAlgorithms, + isFips, normalizeAlgorithm, normalizeHashName, hasAnyNotIn, @@ -1097,6 +1110,7 @@ module.exports = { jobPromiseThen, cleanupWebCryptoResult, prepareWebCryptoResult, + validateKmacKeyLength, validateMaxBufferLength, numBitsToBytes, truncateToBitLength, diff --git a/lib/internal/crypto/webidl.js b/lib/internal/crypto/webidl.js index 5dc263120eae..9be253daaf2e 100644 --- a/lib/internal/crypto/webidl.js +++ b/lib/internal/crypto/webidl.js @@ -9,7 +9,6 @@ const { StringPrototypeSplit, StringPrototypeStartsWith, StringPrototypeToLowerCase, - TypedArrayPrototypeGetLength, } = primordials; const { @@ -28,8 +27,10 @@ const { validateMaxBufferLength, getBufferSourceByteLength, getBufferSourceBytes, + isFips, kNamedCurveAliases, numBitsToBytes, + validateKmacKeyLength, } = require('internal/crypto/util'); const { converters: webidl, @@ -276,23 +277,24 @@ function validateCShakeOutputLength(V) { } } -function bufferSourceEqualsAscii(V, string) { - if (getBufferSourceByteLength(V) !== string.length) return false; - - const bytes = getBufferSourceBytes(V); - const length = TypedArrayPrototypeGetLength(bytes); - for (let i = 0; i < length; i++) { - if (bytes[i] !== StringPrototypeCharCodeAt(string, i)) return false; - } - return true; -} +const kCShakeFunctionNames = ['KMAC', 'TupleHash', 'ParallelHash']; function validateCShakeFunctionName(V) { - if (getBufferSourceByteLength(V) === 0 || - bufferSourceEqualsAscii(V, 'KMAC') || - bufferSourceEqualsAscii(V, 'TupleHash') || - bufferSourceEqualsAscii(V, 'ParallelHash')) { - return; + const length = getBufferSourceByteLength(V); + if (length === 0) return; + + if (!isFips) { + const bytes = getBufferSourceBytes(V); + for (let i = 0; i < kCShakeFunctionNames.length; i++) { + const functionName = kCShakeFunctionNames[i]; + if (length !== functionName.length) continue; + + let j = 0; + for (; j < length; j++) { + if (bytes[j] !== StringPrototypeCharCodeAt(functionName, j)) break; + } + if (j === length) return; + } } throw lazyDOMException( @@ -300,6 +302,14 @@ function validateCShakeFunctionName(V) { 'NotSupportedError'); } +function validateCShakeCustomization(V) { + if (isFips && getBufferSourceByteLength(V) !== 0) + throw lazyDOMException( + 'Unsupported CShakeParams customization', + 'NotSupportedError'); + validateMaxBufferLength(V, 'CShakeParams.customization', 512); +} + converters.RsaPssParams = createDictionaryConverter( 'RsaPssParams', [ dictAlgorithm, @@ -457,7 +467,7 @@ converters.CShakeParams = createDictionaryConverter( { key: 'customization', converter: converters.BufferSource, - validator: (V, opts) => validateMaxBufferLength(V, 'CShakeParams.customization', 512), + validator: validateCShakeCustomization, }, ], ]); @@ -743,6 +753,7 @@ for (let i = 0; i < kKmacDictionaries.length; i++) { key: 'length', converter: (V, opts) => converters['unsigned long'](V, enforceRangeOptions(opts)), + validator: validateKmacKeyLength, }, ], ]); @@ -756,6 +767,12 @@ converters.KmacParams = createDictionaryConverter( key: 'outputLength', converter: (V, opts) => converters['unsigned long'](V, enforceRangeOptions(opts)), + validator: (V) => { + if ((V === 0 || V % 8) && isFips) + throw lazyDOMException( + 'Invalid KmacParams outputLength', + 'NotSupportedError'); + }, required: true, }, { diff --git a/src/crypto/crypto_hash.cc b/src/crypto/crypto_hash.cc index 013d3cc0689b..a932b0755a0a 100644 --- a/src/crypto/crypto_hash.cc +++ b/src/crypto/crypto_hash.cc @@ -812,6 +812,11 @@ Maybe CShakeTraits::AdditionalConfig( CShakeConfig* params) { Environment* env = Environment::GetCurrent(args); + if (IsFipsEnabled()) { + THROW_ERR_CRYPTO_UNSUPPORTED_OPERATION(env); + return Nothing(); + } + CHECK(args[offset]->IsString()); // Algorithm name Utf8Value algorithm_name(env->isolate(), args[offset]); std::string_view algorithm_str = algorithm_name.ToStringView(); diff --git a/src/crypto/crypto_kmac.cc b/src/crypto/crypto_kmac.cc index e5b29370768d..7bdbece96277 100644 --- a/src/crypto/crypto_kmac.cc +++ b/src/crypto/crypto_kmac.cc @@ -151,6 +151,8 @@ bool DeriveBitsWithCShake(const KmacConfig& params, const void* key_data, size_t key_size, ByteSource* out) { + if (IsFipsEnabled()) return false; + const size_t key_length_bytes = NumBitsToBytes(params.key_length); if (key_size < key_length_bytes) return false; diff --git a/src/crypto/crypto_turboshake.cc b/src/crypto/crypto_turboshake.cc index e53e2910c6d3..371851e69a5f 100644 --- a/src/crypto/crypto_turboshake.cc +++ b/src/crypto/crypto_turboshake.cc @@ -428,6 +428,11 @@ Maybe TurboShakeTraits::AdditionalConfig( TurboShakeConfig* params) { Environment* env = Environment::GetCurrent(args); + if (IsFipsEnabled()) { + THROW_ERR_CRYPTO_UNSUPPORTED_OPERATION(env); + return Nothing(); + } + // args[offset + 0] = algorithm name (string) CHECK(args[offset]->IsString()); Utf8Value algorithm_name(env->isolate(), args[offset]); @@ -535,6 +540,11 @@ Maybe KangarooTwelveTraits::AdditionalConfig( KangarooTwelveConfig* params) { Environment* env = Environment::GetCurrent(args); + if (IsFipsEnabled()) { + THROW_ERR_CRYPTO_UNSUPPORTED_OPERATION(env); + return Nothing(); + } + // args[offset + 0] = algorithm name (string) CHECK(args[offset]->IsString()); Utf8Value algorithm_name(env->isolate(), args[offset]); diff --git a/src/crypto/crypto_util.cc b/src/crypto/crypto_util.cc index 166fea15da59..133a5c7f7f1d 100644 --- a/src/crypto/crypto_util.cc +++ b/src/crypto/crypto_util.cc @@ -146,6 +146,11 @@ bool InitCryptoOnce(Isolate* isolate) { // be part of a larger mutex for global OpenSSL state. static Mutex fips_mutex; +bool IsFipsEnabled() { + Mutex::ScopedLock fips_lock(fips_mutex); + return ncrypto::isFipsEnabled(); +} + void InitCryptoOnce() { Mutex::ScopedLock lock(per_process::cli_options_mutex); Mutex::ScopedLock fips_lock(fips_mutex); @@ -223,8 +228,7 @@ void InitCryptoOnce() { void GetFipsCrypto(const FunctionCallbackInfo& args) { Mutex::ScopedLock lock(per_process::cli_options_mutex); - Mutex::ScopedLock fips_lock(fips_mutex); - args.GetReturnValue().Set(ncrypto::isFipsEnabled() ? 1 : 0); + args.GetReturnValue().Set(IsFipsEnabled() ? 1 : 0); } void SetFipsCrypto(const FunctionCallbackInfo& args) { diff --git a/src/crypto/crypto_util.h b/src/crypto/crypto_util.h index dd7e0842a29b..c74a6e7fd507 100644 --- a/src/crypto/crypto_util.h +++ b/src/crypto/crypto_util.h @@ -66,6 +66,7 @@ constexpr T NumBitsToBytes(T bits) { // what went wrong, or std::nullopt when there was nothing to do or the // options were applied successfully. std::optional ProcessFipsOptions(); +bool IsFipsEnabled(); bool InitCryptoOnce(v8::Isolate* isolate); void InitCryptoOnce(); diff --git a/test/parallel/test-crypto-key-objects-to-crypto-key.js b/test/parallel/test-crypto-key-objects-to-crypto-key.js index 32228e7c8a06..1f92195dceca 100644 --- a/test/parallel/test-crypto-key-objects-to-crypto-key.js +++ b/test/parallel/test-crypto-key-objects-to-crypto-key.js @@ -14,6 +14,7 @@ const { } = require('crypto'); const { hasFIPS } = require('../common/crypto'); const { kSupportedAlgorithms } = require('internal/crypto/util'); +const fips = hasFIPS(); const rejectsXCurves = hasFIPS(3, 5); const hashes = Object.keys(kSupportedAlgorithms.digest).filter((name) => { @@ -135,7 +136,7 @@ function macInvalid(algorithm, invalidLengthMessage, allowZeroKey = false) { const key = createSecretKey(randomBytes(32)); const usages = ['sign', 'verify']; - if (allowZeroKey) { + if (allowZeroKey && !fips) { const zeroKey = createSecretKey(Buffer.alloc(0)) .toCryptoKey(algorithm, true, usages); assert.strictEqual(zeroKey.algorithm.length, 0); @@ -143,6 +144,16 @@ function macInvalid(algorithm, invalidLengthMessage, allowZeroKey = false) { const explicitZeroKey = createSecretKey(Buffer.alloc(0)) .toCryptoKey({ ...algorithm, length: 0 }, true, usages); assert.strictEqual(explicitZeroKey.algorithm.length, 0); + } else if (allowZeroKey) { + for (const zeroAlgorithm of [algorithm, { ...algorithm, length: 0 }]) { + assert.throws(() => { + createSecretKey(Buffer.alloc(0)) + .toCryptoKey(zeroAlgorithm, true, usages); + }, { + name: 'NotSupportedError', + message: 'Invalid key length', + }); + } } else { assert.throws(() => { createSecretKey(Buffer.alloc(0)).toCryptoKey(algorithm, true, usages); @@ -157,12 +168,15 @@ function macInvalid(algorithm, invalidLengthMessage, allowZeroKey = false) { message: 'Usages cannot be empty when importing a secret key.' }); - assert.throws(() => { - key.toCryptoKey({ ...algorithm, length: 0 }, true, usages); - }, { - name: 'DataError', - message: invalidLengthMessage, - }); + assert.throws( + () => key.toCryptoKey({ ...algorithm, length: 0 }, true, usages), + allowZeroKey && fips ? { + name: 'NotSupportedError', + message: 'Invalid key length', + } : { + name: 'DataError', + message: invalidLengthMessage, + }); } function hmacVectors() { diff --git a/test/parallel/test-webcrypto-derivekey.js b/test/parallel/test-webcrypto-derivekey.js index 75f43bf8a9ae..dbdc067368ae 100644 --- a/test/parallel/test-webcrypto-derivekey.js +++ b/test/parallel/test-webcrypto-derivekey.js @@ -283,7 +283,7 @@ const fips4 = hasFIPS(4); })().then(common.mustCall()); } -if (hasOpenSSL(3)) { +if (hasOpenSSL(3) && !hasFIPS()) { (async () => { const derivedKeyAlgorithm = { name: 'KMAC128', length: 0 }; const usages = ['sign']; @@ -325,11 +325,7 @@ if (hasOpenSSL(3)) { name: 'KMAC128', outputLength: 256, }, derived, new Uint8Array()); - if (fips4) { - await assert.rejects(signature, { name: 'OperationError' }); - } else { - assert.strictEqual((await signature).byteLength, 32); - } + assert.strictEqual((await signature).byteLength, 32); } })().then(common.mustCall()); } diff --git a/test/parallel/test-webcrypto-digest-turboshake-rfc.js b/test/parallel/test-webcrypto-digest-turboshake-rfc.js index 271fde76ab23..462204654121 100644 --- a/test/parallel/test-webcrypto-digest-turboshake-rfc.js +++ b/test/parallel/test-webcrypto-digest-turboshake-rfc.js @@ -5,6 +5,11 @@ const common = require('../common'); if (!common.hasCrypto) common.skip('missing crypto'); +const { hasFIPS } = require('../common/crypto'); + +if (hasFIPS()) + common.skip('TurboSHAKE and KangarooTwelve are not available in FIPS mode'); + const assert = require('assert'); const { subtle } = globalThis.crypto; diff --git a/test/parallel/test-webcrypto-digest-turboshake.js b/test/parallel/test-webcrypto-digest-turboshake.js index a6f4b2d50f94..bd09362caa25 100644 --- a/test/parallel/test-webcrypto-digest-turboshake.js +++ b/test/parallel/test-webcrypto-digest-turboshake.js @@ -5,6 +5,11 @@ const common = require('../common'); if (!common.hasCrypto) common.skip('missing crypto'); +const { hasFIPS } = require('../common/crypto'); + +if (hasFIPS()) + common.skip('TurboSHAKE and KangarooTwelve are not available in FIPS mode'); + const assert = require('assert'); const { subtle } = globalThis.crypto; diff --git a/test/parallel/test-webcrypto-digest.js b/test/parallel/test-webcrypto-digest.js index 447948212bc1..7c0ef7668c61 100644 --- a/test/parallel/test-webcrypto-digest.js +++ b/test/parallel/test-webcrypto-digest.js @@ -10,7 +10,7 @@ const { Buffer } = require('buffer'); const { subtle } = globalThis.crypto; const { createHash, getHashes } = require('crypto'); const { hasOpenSSL, hasFIPS } = require('../common/crypto'); -const fips4 = hasFIPS(4); +const fips = hasFIPS(); const kTests = [ ['SHA-1', ['sha1'], 160], @@ -291,6 +291,8 @@ if (getHashes().includes('shake128')) { message: 'Unsupported CShakeParams functionName', }); + if (fips) return; + await assert.rejects( subtle.digest( { @@ -398,35 +400,19 @@ if (getHashes().includes('shake128')) { 'ca6f88db415829', }, ]) { - const digest = subtle.digest(algorithm, data); - if (fips4) { - await assert.rejects( - digest, - (err) => err.name === 'OperationError' && - err.cause?.code === 'ERR_OSSL_EVP_UNSUPPORTED'); - } else { - assert.strictEqual( - Buffer.from(await digest).toString('hex'), - expected); - } + assert.strictEqual( + Buffer.from(await subtle.digest(algorithm, data)).toString('hex'), + expected); } - const truncatedDigest = subtle.digest( + const truncated = Buffer.from(await subtle.digest( { ...nistCShakeSample1.algorithm, outputLength: 255 }, - nistCShakeSample1.data); - if (fips4) { - await assert.rejects( - truncatedDigest, - (err) => err.name === 'OperationError' && - err.cause?.code === 'ERR_OSSL_EVP_UNSUPPORTED'); - } else { - const truncated = Buffer.from(await truncatedDigest); - const expected = Buffer.from(nistCShakeSample1.expected, 'hex'); - assert.strictEqual(truncated.byteLength, expected.byteLength); - assert.deepStrictEqual( - truncated.subarray(0, 31), expected.subarray(0, 31)); - assert.strictEqual(truncated[31] & 0b00000001, 0); - assert.strictEqual(truncated[31] | 0b00000001, expected[31]); - } + nistCShakeSample1.data)); + const expected = Buffer.from(nistCShakeSample1.expected, 'hex'); + assert.strictEqual(truncated.byteLength, expected.byteLength); + assert.deepStrictEqual( + truncated.subarray(0, 31), expected.subarray(0, 31)); + assert.strictEqual(truncated[31] & 0b00000001, 0); + assert.strictEqual(truncated[31] | 0b00000001, expected[31]); })().then(common.mustCall()); } diff --git a/test/parallel/test-webcrypto-export-import.js b/test/parallel/test-webcrypto-export-import.js index 9f6b2125d8ed..30d1ea622fae 100644 --- a/test/parallel/test-webcrypto-export-import.js +++ b/test/parallel/test-webcrypto-export-import.js @@ -286,66 +286,68 @@ if (hasOpenSSL(3)) { { name: 'SyntaxError', message: 'Usages cannot be empty when importing a secret key.' }); { - const importedZeroImplicit = await subtle.importKey( - 'raw-secret', - new Uint8Array(), - name, - true, - ['sign', 'verify']); - const importedZeroImplicitRaw = - await subtle.exportKey('raw-secret', importedZeroImplicit); - assert.strictEqual(importedZeroImplicit.algorithm.length, 0); - assert.strictEqual(importedZeroImplicitRaw.byteLength, 0); + if (getFips() !== 1) { + const importedZeroImplicit = await subtle.importKey( + 'raw-secret', + new Uint8Array(), + name, + true, + ['sign', 'verify']); + const importedZeroImplicitRaw = + await subtle.exportKey('raw-secret', importedZeroImplicit); + assert.strictEqual(importedZeroImplicit.algorithm.length, 0); + assert.strictEqual(importedZeroImplicitRaw.byteLength, 0); - const importedZeroExplicit = await subtle.importKey( - 'raw-secret', - new Uint8Array(), - { name, length: 0 }, - true, - ['sign', 'verify']); - const importedZeroExplicitRaw = - await subtle.exportKey('raw-secret', importedZeroExplicit); - assert.strictEqual(importedZeroExplicit.algorithm.length, 0); - assert.strictEqual(importedZeroExplicitRaw.byteLength, 0); - - await assert.rejects( - subtle.importKey( + const importedZeroExplicit = await subtle.importKey( 'raw-secret', - new Uint8Array([0xff]), + new Uint8Array(), { name, length: 0 }, true, - ['sign', 'verify']), - { name: 'DataError', message: 'Invalid key length' }); - - const generated = await subtle.generateKey( - { name, length: 9 }, - true, - ['sign', 'verify']); - const generatedRaw = await subtle.exportKey('raw-secret', generated); - assert.strictEqual(generated.algorithm.length, 9); - assert.strictEqual(generatedRaw.byteLength, 2); - assert.strictEqual(new Uint8Array(generatedRaw)[1] & 0b01111111, 0); + ['sign', 'verify']); + const importedZeroExplicitRaw = + await subtle.exportKey('raw-secret', importedZeroExplicit); + assert.strictEqual(importedZeroExplicit.algorithm.length, 0); + assert.strictEqual(importedZeroExplicitRaw.byteLength, 0); + + await assert.rejects( + subtle.importKey( + 'raw-secret', + new Uint8Array([0xff]), + { name, length: 0 }, + true, + ['sign', 'verify']), + { name: 'DataError', message: 'Invalid key length' }); + + const generated = await subtle.generateKey( + { name, length: 9 }, + true, + ['sign', 'verify']); + const generatedRaw = await subtle.exportKey('raw-secret', generated); + assert.strictEqual(generated.algorithm.length, 9); + assert.strictEqual(generatedRaw.byteLength, 2); + assert.strictEqual(new Uint8Array(generatedRaw)[1] & 0b01111111, 0); - const importedExplicit = await subtle.importKey( - 'raw-secret', - new Uint8Array([0xff, 0xff]), - { name, length: 9 }, - true, - ['sign', 'verify']); - const importedExplicitRaw = await subtle.exportKey('raw-secret', importedExplicit); - assert.strictEqual(importedExplicit.algorithm.length, 9); - assert.deepStrictEqual( - new Uint8Array(importedExplicitRaw), - new Uint8Array([0xff, 0x80])); - - await assert.rejects( - subtle.importKey( + const importedExplicit = await subtle.importKey( 'raw-secret', - new Uint8Array([0xff]), + new Uint8Array([0xff, 0xff]), { name, length: 9 }, true, - ['sign', 'verify']), - { name: 'DataError', message: 'Invalid key length' }); + ['sign', 'verify']); + const importedExplicitRaw = await subtle.exportKey('raw-secret', importedExplicit); + assert.strictEqual(importedExplicit.algorithm.length, 9); + assert.deepStrictEqual( + new Uint8Array(importedExplicitRaw), + new Uint8Array([0xff, 0x80])); + + await assert.rejects( + subtle.importKey( + 'raw-secret', + new Uint8Array([0xff]), + { name, length: 9 }, + true, + ['sign', 'verify']), + { name: 'DataError', message: 'Invalid key length' }); + } } } diff --git a/test/parallel/test-webcrypto-fips-exceptions.mjs b/test/parallel/test-webcrypto-fips-exceptions.mjs new file mode 100644 index 000000000000..ecc0f3c6989d --- /dev/null +++ b/test/parallel/test-webcrypto-fips-exceptions.mjs @@ -0,0 +1,198 @@ +// Flags: --expose-internals + +import * as common from '../common/index.mjs'; +import assert from 'node:assert'; +import { createRequire } from 'node:module'; +import { hasFIPS } from '../common/crypto.js'; + +if (!common.hasCrypto) + common.skip('missing crypto'); + +if (!hasFIPS(3)) + common.skip('requires OpenSSL >= 3 in FIPS mode'); + +const require = createRequire(import.meta.url); +const { internalBinding } = require('internal/test/binding'); +const { getCryptoKeyHandle } = require('internal/crypto/keys'); +const { + CShakeJob, + KangarooTwelveJob, + KmacJob, + TurboShakeJob, + kCryptoJobWebCrypto, + kSignJobModeSign, +} = internalBinding('crypto'); +const { subtle } = globalThis.crypto; +const { SubtleCrypto } = globalThis; +const data = new Uint8Array(); + +async function assertFipsException(operation, algorithm, fn, message) { + assert.strictEqual(SubtleCrypto.supports(operation, algorithm), false); + await assert.rejects(fn(), { + name: 'NotSupportedError', + message, + }); +} + +for (const algorithm of [ + { name: 'turboshake128', outputLength: 128 }, + { name: 'TurboSHAKE256', outputLength: 256 }, + { name: 'KT128', outputLength: 128 }, + { name: 'KT256', outputLength: 256, customization: data }, +]) { + await assertFipsException( + 'digest', + algorithm, + () => subtle.digest(algorithm, data), + 'Unrecognized algorithm name'); +} + +for (const createJob of [ + () => new TurboShakeJob( + kCryptoJobWebCrypto, 'TurboSHAKE128', 0x1f, 16, data), + () => new KangarooTwelveJob( + kCryptoJobWebCrypto, 'KT128', undefined, 16, data), + () => new CShakeJob( + kCryptoJobWebCrypto, + 'cSHAKE128', + data, + Buffer.from('KMAC'), + undefined, + 128), +]) { + assert.throws(createJob, { + code: 'ERR_CRYPTO_UNSUPPORTED_OPERATION', + message: 'Unsupported crypto operation', + }); +} + +const emptyCShake = { + name: 'cSHAKE128', + outputLength: 256, + customization: data, + functionName: data, +}; +assert.strictEqual(SubtleCrypto.supports('digest', emptyCShake), true); + +for (const length of [1, 513]) { + const algorithm = { + name: 'cSHAKE128', + outputLength: 256, + customization: new Uint8Array(length), + }; + await assertFipsException( + 'digest', + algorithm, + () => subtle.digest(algorithm, data), + 'Unsupported CShakeParams customization'); +} + +const functionName = { + name: 'cSHAKE256', + outputLength: 256, + functionName: Buffer.from('KMAC'), +}; +await assertFipsException( + 'digest', + functionName, + () => subtle.digest(functionName, data), + 'Unsupported CShakeParams functionName'); + +const bothCShakeParams = { + ...functionName, + customization: new Uint8Array(1), +}; +await assertFipsException( + 'digest', + bothCShakeParams, + () => subtle.digest(bothCShakeParams, data), + 'Unsupported CShakeParams customization'); + +for (const length of [0, 24, 33]) { + const algorithm = { name: 'KMAC128', length }; + await assertFipsException( + 'generateKey', + algorithm, + () => subtle.generateKey(algorithm, false, ['sign', 'verify']), + 'Invalid key length'); + await assertFipsException( + 'importKey', + algorithm, + () => subtle.importKey( + 'raw-secret', + new Uint8Array(length === 24 ? 4 : Math.ceil(length / 8)), + algorithm, + false, + ['sign', 'verify']), + 'Invalid key length'); +} + +const minimumKmac = { name: 'KMAC128', length: 32 }; +assert.strictEqual( + SubtleCrypto.supports('generateKey', minimumKmac), true); +assert.strictEqual( + SubtleCrypto.supports('importKey', minimumKmac), true); +await assert.rejects( + subtle.importKey( + 'raw-secret', + new Uint8Array(5), + minimumKmac, + false, + ['sign', 'verify']), { + name: 'DataError', + message: 'Invalid key length', + }); + +for (const length of [0, 3]) { + await assert.rejects( + subtle.importKey( + 'raw-secret', + new Uint8Array(length), + 'KMAC128', + false, + ['sign', 'verify']), { + name: 'NotSupportedError', + message: 'Invalid key length', + }); +} +const key = await subtle.importKey( + 'raw-secret', + new Uint8Array(4), + 'KMAC128', + false, + ['sign', 'verify']); +assert.strictEqual(key.algorithm.length, 32); + +await assert.rejects( + new KmacJob( + kCryptoJobWebCrypto, + kSignJobModeSign, + getCryptoKeyHandle(key), + 'KMAC128', + undefined, + 32, + 9, + data, + undefined).run(), + (err) => { + assert.strictEqual(err.name, 'OperationError'); + assert.strictEqual(err.cause?.code, 'ERR_CRYPTO_OPERATION_FAILED'); + return true; + }); + +const minimumOutput = { name: 'KMAC128', outputLength: 8 }; +assert.strictEqual(SubtleCrypto.supports('sign', minimumOutput), true); +assert.strictEqual(SubtleCrypto.supports('verify', minimumOutput), true); +for (const outputLength of [0, 9]) { + const algorithm = { name: 'KMAC128', outputLength }; + await assertFipsException( + 'sign', + algorithm, + () => subtle.sign(algorithm, key, data), + 'Invalid KmacParams outputLength'); + await assertFipsException( + 'verify', + algorithm, + () => subtle.verify(algorithm, key, data, data), + 'Invalid KmacParams outputLength'); +} diff --git a/test/parallel/test-webcrypto-keygen-kmac.js b/test/parallel/test-webcrypto-keygen-kmac.js index c1125412892e..33716095751f 100644 --- a/test/parallel/test-webcrypto-keygen-kmac.js +++ b/test/parallel/test-webcrypto-keygen-kmac.js @@ -5,7 +5,7 @@ const common = require('../common'); if (!common.hasCrypto) common.skip('missing crypto'); -const { hasOpenSSL } = require('../common/crypto'); +const { hasFIPS, hasOpenSSL } = require('../common/crypto'); if (!hasOpenSSL(3)) common.skip('requires OpenSSL >= 3'); @@ -13,38 +13,45 @@ if (!hasOpenSSL(3)) const assert = require('assert'); const { types: { isCryptoKey } } = require('util'); const { subtle } = globalThis.crypto; +const fips = hasFIPS(); const usages = ['sign', 'verify']; async function test(name, length) { - length ??= name === 'KMAC128' ? 128 : 256; - const key = await subtle.generateKey({ - name, - length, - }, true, usages); - - assert(key); - assert(isCryptoKey(key)); - - assert.strictEqual(key.type, 'secret'); - assert.strictEqual(key.toString(), '[object CryptoKey]'); - assert.strictEqual(key.extractable, true); - assert.deepStrictEqual(key.usages, usages); - assert.strictEqual(key.algorithm.name, name); - assert.strictEqual(key.algorithm.length, length); - assert.strictEqual(key.algorithm, key.algorithm); - assert.strictEqual(key.usages, key.usages); - - const raw = await subtle.exportKey('raw-secret', key); - assert.strictEqual(raw.byteLength, Math.ceil(length / 8)); + const expectedLength = length ?? (name === 'KMAC128' ? 128 : 256); + const algorithm = { name }; + if (length !== undefined) + algorithm.length = length; + + if (fips && length !== undefined && + (length < 32 || length % 8 !== 0)) return; + + const generatedKey = await subtle.generateKey(algorithm, true, usages); + + assert(generatedKey); + assert(isCryptoKey(generatedKey)); + + assert.strictEqual(generatedKey.type, 'secret'); + assert.strictEqual(generatedKey.toString(), '[object CryptoKey]'); + assert.strictEqual(generatedKey.extractable, true); + assert.deepStrictEqual(generatedKey.usages, usages); + assert.strictEqual(generatedKey.algorithm.name, name); + assert.strictEqual(generatedKey.algorithm.length, expectedLength); + assert.strictEqual(generatedKey.algorithm, generatedKey.algorithm); + assert.strictEqual(generatedKey.usages, generatedKey.usages); + + const raw = await subtle.exportKey('raw-secret', generatedKey); + assert.strictEqual(raw.byteLength, Math.ceil(expectedLength / 8)); } const kTests = [ ['KMAC128', 0], + ['KMAC128', 32], ['KMAC128', 128], ['KMAC128', 256], ['KMAC128'], ['KMAC256', 0], + ['KMAC256', 32], ['KMAC256', 128], ['KMAC256', 256], ['KMAC256'], diff --git a/test/parallel/test-webcrypto-prototype-pollution.mjs b/test/parallel/test-webcrypto-prototype-pollution.mjs index a7104c2b7ae4..5ed77fc6021e 100644 --- a/test/parallel/test-webcrypto-prototype-pollution.mjs +++ b/test/parallel/test-webcrypto-prototype-pollution.mjs @@ -142,16 +142,24 @@ if (supports('digest', 'cSHAKE128')) { outputLength: 256, customization: new Uint8Array([1, 2, 3]), }; - const expected = new Uint8Array(await subtle.digest(algorithm, data)); - const plain = new Uint8Array( - await subtle.digest({ name: 'cSHAKE128', outputLength: 256 }, data)); - assert.notDeepStrictEqual(expected, plain); - await withPoisoned(poisonTypedArrayByteLength(0), - common.mustCall(async () => { - assert.deepStrictEqual( - new Uint8Array(await subtle.digest(algorithm, data)), - expected); - })); + if (getFips() === 1) { + await withPoisoned(poisonTypedArrayByteLength(0), common.mustCall(() => + assert.rejects(subtle.digest(algorithm, data), { + name: 'NotSupportedError', + message: 'Unsupported CShakeParams customization', + }))); + } else { + const expected = new Uint8Array(await subtle.digest(algorithm, data)); + const plain = new Uint8Array( + await subtle.digest({ name: 'cSHAKE128', outputLength: 256 }, data)); + assert.notDeepStrictEqual(expected, plain); + await withPoisoned(poisonTypedArrayByteLength(0), + common.mustCall(async () => { + assert.deepStrictEqual( + new Uint8Array(await subtle.digest(algorithm, data)), + expected); + })); + } } } @@ -290,17 +298,17 @@ await withPoisoned( // enforceRangeOptions(): [EnforceRange] uses IntegerPart, not round-half-even. { const key = await subtle.importKey( - 'raw-secret', new Uint8Array(4), 'PBKDF2', false, ['deriveBits']); + 'raw-secret', new Uint8Array(32), 'PBKDF2', false, ['deriveBits']); const pbkdf2 = (iterations) => subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt: new Uint8Array(16), iterations, - }, key, 8); + }, key, 112); - const expected = new Uint8Array(await pbkdf2(1)); + const expected = new Uint8Array(await pbkdf2(1000)); await withPoisoned(inherited('clamp', true), common.mustCall(async () => { - assert.deepStrictEqual(new Uint8Array(await pbkdf2(1.5)), expected); + assert.deepStrictEqual(new Uint8Array(await pbkdf2(1000.5)), expected); })); } diff --git a/test/parallel/test-webcrypto-sign-verify-kmac.js b/test/parallel/test-webcrypto-sign-verify-kmac.js index 160067b9b760..ac0b738bcd57 100644 --- a/test/parallel/test-webcrypto-sign-verify-kmac.js +++ b/test/parallel/test-webcrypto-sign-verify-kmac.js @@ -12,15 +12,24 @@ if (!hasOpenSSL(3)) const assert = require('assert'); const { subtle } = globalThis.crypto; +const fips = hasFIPS(); const fips4 = hasFIPS(4); const vectors = require('../fixtures/crypto/kmac')(); -function isFipsUnsupported(err) { +function isFipsProviderUnsupported(err) { return err.name === 'OperationError' && err.cause?.code === 'ERR_OSSL_EVP_UNSUPPORTED'; } +function usesNonFipsImplementation({ key, keyLength, outputLength }) { + const keyLengthInBits = keyLength ?? key.byteLength * 8; + return outputLength === 0 || + outputLength % 8 !== 0 || + keyLengthInBits < 32 || + keyLengthInBits % 8 !== 0; +} + function isFips4Incompatible({ key, keyLength, outputLength }) { const keyLengthInBits = keyLength ?? key.byteLength * 8; return keyLengthInBits < 128 || @@ -206,9 +215,13 @@ async function testSign({ algorithm, const variations = []; for (const vector of vectors) { + if (fips && usesNonFipsImplementation(vector)) continue; + if (fips4 && isFips4Incompatible(vector)) { - variations.push(assert.rejects(testVerify(vector), isFipsUnsupported)); - variations.push(assert.rejects(testSign(vector), isFipsUnsupported)); + variations.push(assert.rejects( + testVerify(vector), isFipsProviderUnsupported)); + variations.push(assert.rejects( + testSign(vector), isFipsProviderUnsupported)); } else { variations.push(testVerify(vector)); variations.push(testSign(vector)); @@ -227,24 +240,18 @@ async function testSign({ algorithm, ['sign', 'verify']); const algorithm = { name: 'KMAC128', - outputLength: fips4 ? 16 : 9, + outputLength: fips ? 16 : 9, customization: new Uint8Array(), }; const data = new Uint8Array([1, 2, 3]); - if (fips4) { - await assert.rejects( - subtle.sign({ ...algorithm, outputLength: 9 }, key, data), - isFipsUnsupported); - } - const signature = await subtle.sign(algorithm, key, data); assert.strictEqual(signature.byteLength, 2); - if (!fips4) + if (!fips) assert.strictEqual(new Uint8Array(signature)[1] & 0b01111111, 0); assert(await subtle.verify(algorithm, key, signature, data)); - if (fips4) { + if (fips) { const signature128 = await subtle.sign({ ...algorithm, outputLength: 128, @@ -264,25 +271,23 @@ async function testSign({ algorithm, } const invalidSignature = new Uint8Array(signature); - if (fips4) + if (fips) invalidSignature[0] ^= 0b00000001; else invalidSignature[1] |= 0b00000001; assert(!(await subtle.verify(algorithm, key, invalidSignature, data))); - const nonByteKey = await subtle.importKey( - 'raw-secret', - new Uint8Array([0xff, 0xff, 0xff, 0xff]), - { name: 'KMAC128', length: 25 }, - false, - ['sign', 'verify']); - const nonByteKeySignature = subtle.sign({ - ...algorithm, - outputLength: 16, - }, nonByteKey, data); - if (fips4) { - await assert.rejects(nonByteKeySignature, isFipsUnsupported); - } else { + if (!fips) { + const nonByteKey = await subtle.importKey( + 'raw-secret', + new Uint8Array([0xff, 0xff, 0xff, 0xff]), + { name: 'KMAC128', length: 25 }, + false, + ['sign', 'verify']); + const nonByteKeySignature = subtle.sign({ + ...algorithm, + outputLength: 16, + }, nonByteKey, data); const result = await nonByteKeySignature; assert.strictEqual(result.byteLength, 2); assert(await subtle.verify({ @@ -293,6 +298,8 @@ async function testSign({ algorithm, })().then(common.mustCall()); (async function() { + if (fips) return; + const data = new Uint8Array([1, 2, 3]); for (const name of ['KMAC128', 'KMAC256']) { @@ -311,13 +318,9 @@ async function testSign({ algorithm, const algorithm = { name, outputLength: 256 }; const signature = subtle.sign(algorithm, key, data); - if (fips4) { - await assert.rejects(signature, isFipsUnsupported); - } else { - const result = await signature; - assert.strictEqual(result.byteLength, 32); - assert(await subtle.verify(algorithm, key, result, data)); - } + const result = await signature; + assert.strictEqual(result.byteLength, 32); + assert(await subtle.verify(algorithm, key, result, data)); } } })().then(common.mustCall()); diff --git a/test/parallel/test-webcrypto-wrap-unwrap.js b/test/parallel/test-webcrypto-wrap-unwrap.js index 342eae0859e4..c2c089ed0ffa 100644 --- a/test/parallel/test-webcrypto-wrap-unwrap.js +++ b/test/parallel/test-webcrypto-wrap-unwrap.js @@ -485,7 +485,7 @@ async function testNonByteLengthWrapUnwrap({ implicitAlgorithm: hmacAlgorithm, }); - if (hasOpenSSL(3)) { + if (hasOpenSSL(3) && getFips() !== 1) { const kmacAlgorithm = { name: 'KMAC128' }; const kmacKey = await subtle.importKey( 'raw-secret', diff --git a/test/wpt/status/WebCryptoAPI.cjs b/test/wpt/status/WebCryptoAPI.cjs index 8dfd37b33b34..ae73e17f5bb9 100644 --- a/test/wpt/status/WebCryptoAPI.cjs +++ b/test/wpt/status/WebCryptoAPI.cjs @@ -117,6 +117,12 @@ if (hasFIPS(3)) { ]); } +if (hasFIPS()) { + skip( + 'digest/kangarootwelve.tentative.https.any.js', + 'digest/turboshake.tentative.https.any.js'); +} + // OpenSSL 3.0 through 3.3 reject SHA-1 signature generation in FIPS mode. // OpenSSL 3.4 permits it for legacy use cases while marking the operation as // non-approved through a per-operation FIPS indicator. Node does not expose @@ -171,8 +177,10 @@ if (hasFIPS(4)) { ]); } -skipSubtests( - ['digest/kangarootwelve.tentative.https.any.js', /C=(?:\d{4,}|5(?:1[3-9]|[2-9]\d)|[6-9]\d{2}) bytes/]); +if (!hasFIPS()) { + skipSubtests( + ['digest/kangarootwelve.tentative.https.any.js', /C=(?:\d{4,}|5(?:1[3-9]|[2-9]\d)|[6-9]\d{2}) bytes/]); +} function assertNoOverlap(fileSkips, subtestSkips) { const subtestSkipFiles = new Set(Object.keys(subtestSkips)); From af867ce3e48e1a11b0c5aeb51d4c4a9c16659f36 Mon Sep 17 00:00:00 2001 From: Adam Mcgrath Date: Thu, 6 Aug 2026 09:20:28 +0100 Subject: [PATCH 146/344] crypto: add mgf1Hash for RSA-OAEP Signed-off-by: Adam Mcgrath PR-URL: https://github.com/nodejs/node/pull/65073 Reviewed-By: Filip Skokan Reviewed-By: Aviv Keller --- deps/ncrypto/ncrypto.cc | 10 +- deps/ncrypto/ncrypto.h | 1 + doc/api/crypto.md | 20 ++- lib/internal/crypto/cipher.js | 6 +- src/crypto/crypto_cipher.cc | 13 +- src/crypto/crypto_cipher.h | 1 + src/crypto/crypto_rsa.cc | 1 + test/parallel/test-crypto-rsa-oaep-mgf1.js | 149 +++++++++++++++++++++ typings/internalBinding/crypto.d.ts | 1 + 9 files changed, 192 insertions(+), 10 deletions(-) create mode 100644 test/parallel/test-crypto-rsa-oaep-mgf1.js diff --git a/deps/ncrypto/ncrypto.cc b/deps/ncrypto/ncrypto.cc index b4fa65daa78c..d731e823b39d 100644 --- a/deps/ncrypto/ncrypto.cc +++ b/deps/ncrypto/ncrypto.cc @@ -5650,9 +5650,11 @@ DataPointer RSA_Cipher(const EVPKeyPointer& key, if (!key) return {}; EVPKeyCtxPointer ctx = key.newCtx(); + const Digest& mgf1_digest = + params.mgf1_digest != nullptr ? params.mgf1_digest : params.digest; if (!ctx || init(ctx.get()) <= 0 || !ctx.setRsaPadding(params.padding) || - (params.digest != nullptr && (!ctx.setRsaOaepMd(params.digest) || - !ctx.setRsaMgf1Md(params.digest)))) { + (params.digest != nullptr && + (!ctx.setRsaOaepMd(params.digest) || !ctx.setRsaMgf1Md(mgf1_digest)))) { return {}; } @@ -5691,7 +5693,9 @@ DataPointer CipherImpl(const EVPKeyPointer& key, if (!key) return {}; EVPKeyCtxPointer ctx = key.newCtx(); if (!ctx || init(ctx.get()) <= 0 || !ctx.setRsaPadding(params.padding) || - (params.digest != nullptr && !ctx.setRsaOaepMd(params.digest))) { + (params.digest != nullptr && !ctx.setRsaOaepMd(params.digest)) || + (params.mgf1_digest != nullptr && + !ctx.setRsaMgf1Md(params.mgf1_digest))) { return {}; } diff --git a/deps/ncrypto/ncrypto.h b/deps/ncrypto/ncrypto.h index 53302394f38b..a11d67ae460a 100644 --- a/deps/ncrypto/ncrypto.h +++ b/deps/ncrypto/ncrypto.h @@ -508,6 +508,7 @@ class Cipher final { struct CipherParams { int padding; Digest digest; + Digest mgf1_digest; const Buffer label; }; diff --git a/doc/api/crypto.md b/doc/api/crypto.md index 659905e17482..413533c63d01 100644 --- a/doc/api/crypto.md +++ b/doc/api/crypto.md @@ -5300,6 +5300,9 @@ An array of supported digest functions can be retrieved using * `privateKey` {Object|string|ArrayBuffer|Buffer|TypedArray|DataView|KeyObject|CryptoKey|URL} - * `oaepHash` {string} The hash function to use for OAEP padding and MGF1. - **Default:** `'sha1'` + * `oaepHash` {string} The hash function to use for OAEP padding and, unless + `mgf1Hash` is set, MGF1. **Default:** `'sha1'` + * `mgf1Hash` {string} The hash function to use for the MGF1 mask generation + function of OAEP padding. If not specified, the value of `oaepHash` is used. + This allows the OAEP digest and the MGF1 digest to differ. * `oaepLabel` {string|ArrayBuffer|Buffer|TypedArray|DataView} The label to use for OAEP padding. If not specified, no label is used. * `padding` {crypto.constants} An optional padding value defined in @@ -5442,6 +5448,9 @@ be passed instead of a public key. + +> Stability: 1.0 - Early development + +The ZIP archive API is experimental. Using any part of it (this class among +them) emits an experimental warning the first time; merely importing +`node:zlib` does not. + +An in-memory, **zero-copy** view over the entries of a ZIP archive already +held in a `Buffer`, `TypedArray`, `DataView`, or `ArrayBuffer`. Its set of +entries can be edited - entries added or removed - but, unlike [`ZipFile`][], +those edits are **not** written into the source buffer: a newly added entry is +held as a separate in-memory [`ZipEntry`][] (the passed buffer is a fixed-size +view with no room to append to), and removal just drops the entry from +`ZipBuffer`'s index. The original bytes are never modified. +[`zipBuffer.toBuffer()`][] serializes the current set of entries into a fresh +archive. + +`ZipBuffer` does not copy the archive you hand it. It keeps a view onto that +memory and reads each entry's content lazily and directly from it, which is +what makes construction cheap regardless of archive size. The trade-off is +that you **must not modify or reuse** that memory - including the +`ArrayBuffer` backing a `TypedArray`/`DataView` - while the `ZipBuffer`, or +any [`ZipEntry`][] obtained from it, is still in use: a later read would +observe the change and may fail or return corrupt data. Pass a copy (for +example `Buffer.from(source)`) if the source might be mutated or reused. + +`add()` and `toBuffer()` each have a `*Sync` counterpart +([`addSync()`][`zipBuffer.addSync()`], [`toBufferSync()`][`zipBuffer.toBufferSync()`]) +that performs the same compression work synchronously. As with the +synchronous `node:fs` APIs, these block the Node.js event loop and further +JavaScript execution until the operation completes; use them only where +synchronous execution is appropriate (for example, short-lived scripts or +startup code), not in code that must stay responsive. + +```mjs +import { ZipBuffer } from 'node:zlib'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { Buffer } from 'node:buffer'; + +const zip = new ZipBuffer(readFileSync('archive.zip')); +for (const [name, entry] of zip) { + console.log(name, entry.size); +} +await zip.add('hello.txt', Buffer.from('Hello, world!')); +zip.delete('unwanted.txt'); +writeFileSync('archive.zip', await zip.toBuffer()); +``` + +```cjs +const { ZipBuffer } = require('node:zlib'); +const { readFileSync, writeFileSync } = require('node:fs'); + +async function main() { + const zip = new ZipBuffer(readFileSync('archive.zip')); + for (const [name, entry] of zip) { + console.log(name, entry.size); + } + await zip.add('hello.txt', Buffer.from('Hello, world!')); + zip.delete('unwanted.txt'); + writeFileSync('archive.zip', await zip.toBuffer()); +} +main(); +``` + +### `new zlib.ZipBuffer(buffer)` + + + +* `buffer` {Buffer|TypedArray|DataView|ArrayBuffer} A complete ZIP archive. + +Parses the archive's central directory. Throws an [`ERR_ZIP_INVALID_ARCHIVE`][] +or [`ERR_ZIP_UNSUPPORTED_FEATURE`][] error if `buffer` is not a well-formed, +supported archive. + +`buffer` is **not copied**: the `ZipBuffer` retains a zero-copy view of it (for +a `TypedArray`, `DataView`, or `ArrayBuffer`, of the underlying `ArrayBuffer`) +and reads entry content directly from it on demand. Do not mutate or reuse that +memory while the `ZipBuffer` or any entry read from it is still live; pass a +copy if it might change. + +### `zipBuffer.add(filename, data[, options])` + + + +* `filename` {string} The entry's name within the archive. A trailing `/` + marks a directory entry. +* `data` {Buffer|TypedArray|DataView|ArrayBuffer} The entry's complete, + uncompressed content. +* `options` {Object} See [`zlib.ZipEntry.create()`][]. +* Returns: {Promise} Fulfilled with the created {ZipEntry}. + +Equivalent to `zipBuffer.addEntry(await zlib.ZipEntry.create(filename, data, +options))`. + +### `zipBuffer.addSync(filename, data[, options])` + + + +* `filename` {string} The entry's name within the archive. A trailing `/` + marks a directory entry. +* `data` {Buffer|TypedArray|DataView|ArrayBuffer} The entry's complete, + uncompressed content. +* `options` {Object} See [`zlib.ZipEntry.createSync()`][]. +* Returns: {ZipEntry} The created entry. + +The synchronous version of [`zipBuffer.add()`][]. Equivalent to +`zipBuffer.addEntry(zlib.ZipEntry.createSync(filename, data, options))`. + +### `zipBuffer.addEntry(entry)` + + + +* `entry` {ZipEntry} +* Returns: {ZipEntry} `entry`. + +Adds an already-built entry, keyed by its own [`zipEntry.name`][]. Replaces +any existing entry of that name. + +### `zipBuffer.clear()` + + + +Removes every entry. + +### `zipBuffer.comment` + + + +* Type: {string} + +The archive-level comment, preserved byte-for-byte across +[`zipBuffer.toBuffer()`][] calls unless overridden. The bytes are decoded as +UTF-8 when they are valid UTF-8 and as CP437 otherwise (the field carries no +encoding flag of its own). + +### `zipBuffer.delete(name)` + + + +* `name` {string} +* Returns: {boolean} `true` if an entry named `name` existed and was removed. + +### `zipBuffer.entries()` + + + +* Returns: {Iterator} of `[name, entry]` pairs, where `entry` is a + [`ZipEntry`][]. + +### `zipBuffer.forEach(callback[, thisArg])` + + + +* `callback` {Function} +* `thisArg` {any} + +Calls `callback` once for each entry, in the order the archive lists them. + +### `zipBuffer.get(name)` + + + +* `name` {string} +* Returns: {ZipEntry} + +Throws [`ERR_ZIP_ENTRY_NOT_FOUND`][] if the archive has no entry named `name`. + +### `zipBuffer.has(name)` + + + +* `name` {string} +* Returns: {boolean} + +### `zipBuffer.keys()` + + + +* Returns: {Iterator} of entry names. + +### `zipBuffer.size` + + + +* Type: {number} + +The number of entries in the archive. + +### `zipBuffer.toBuffer([options])` + + + +* `options` {string|Object} An archive comment, as a shorthand for + `{ comment: options }`. + * `comment` {string} An archive comment. **Default:** [`zipBuffer.comment`][]. + * `baseOffset` {number} Shifts every offset the archive records by this + many bytes, so the serialized archive is self-describing even when it is + written somewhere other than the start of its eventual file - for example, + after `baseOffset` bytes of other content already written to the same + output. **Default:** `0`. +* Returns: {Promise} Fulfilled with a {Buffer} containing the serialized + archive. + +Serializes the current set of entries - in the order they were added or +read - into a fresh archive, switching to Zip64 structures automatically as +needed (see [`zlib.createZipArchive()`][]). + +### `zipBuffer.toBufferSync([options])` + + + +* `options` {string|Object} See [`zipBuffer.toBuffer()`][]. +* Returns: {Buffer} The serialized archive. + +The synchronous version of [`zipBuffer.toBuffer()`][] (see +[`zlib.createZipArchiveSync()`][]). + +### `zipBuffer.values()` + + + +* Returns: {Iterator} of [`ZipEntry`][]. + +### `zipBuffer.writable` + + + +* Type: {boolean} + +Always `true`. + +## Class: `zlib.ZipEntry` + + + +> Stability: 1.0 - Early development + +The ZIP archive API is experimental. Using any part of it (this class among +them) emits an experimental warning the first time; merely importing +`node:zlib` does not. + +A single file or directory inside a ZIP archive. Instances are produced by +[`ZipBuffer`][] and [`ZipFile`][], or created directly for writing with +`ZipEntry.create()`/`ZipEntry.createStream()`. + +`create()` and `content()` each have a `*Sync` counterpart (the streaming +`contentIterator()` does not). As with the synchronous `node:fs` APIs, these +block the +Node.js event loop and further JavaScript execution until the operation +(including any deflate/inflate pass) completes; use them only where +synchronous execution is appropriate (for example, short-lived scripts or +startup code), not in code that must stay responsive. + +### Static method: `zlib.ZipEntry.create(filename, data[, options])` + + + +* `filename` {string} The entry's name within the archive. A trailing `/` + marks a directory entry. +* `data` {Buffer|TypedArray|DataView|ArrayBuffer} The entry's complete, + uncompressed content. Must be empty when `filename` names a directory. +* `options` {Object} + * `comment` {string} An entry comment. + * `mode` {integer} Unix permission bits. **Default:** `0o644` (`0o755` for + directories). + * `modified` {Date} The entry's modification time. **Default:** the + current time. + * `method` {string} One of `'deflate'`, `'store'`, or `'zstd'`. **Default:** + `'deflate'`, except for directories and empty content, which are always + stored. +* Returns: {Promise} Fulfilled with a {ZipEntry}. + +Compresses `data` (unless `method` is `'store'`, or compression would not +reduce its size) and computes its CRC-32. + +When the entry ends up stored uncompressed (because `method` is `'store'`, +or because compression would not reduce the size), the entry retains a +zero-copy view of `data` rather than a copy, and its CRC-32 has already been +recorded. Do not mutate `data` after creating the entry; pass a copy if it +might change. + +The MS-DOS date/time fields ZIP uses for `modified` have 2-second resolution +and no time zone. When `modified` does not fall on a whole 2-second +boundary, an Info-ZIP extended-timestamp extra field is written as well, +recording the whole (UTC) second so the time round-trips more precisely (see +[`zipEntry.modified`][]). This applies to every entry-creation path. + +### Static method: `zlib.ZipEntry.createStream(filename, source[, options])` + + + +* `filename` {string} The entry's name within the archive. Must not end + in `/`. +* `source` {AsyncIterable} Yields the entry's uncompressed content as + `Uint8Array` chunks. +* `options` {Object} + * `comment` {string} An entry comment. + * `mode` {integer} Unix permission bits. **Default:** `0o644`. + * `modified` {Date} The entry's modification time. **Default:** the + current time. + * `method` {string} One of `'deflate'`, `'store'`, or `'zstd'`. **Default:** + `'deflate'`. +* Returns: {ZipEntry} + +Creates an entry whose content is compressed on the fly as it is serialized +by [`zlib.createZipArchive()`][], without buffering `source` in memory. Its +`size`, `compressedSize`, and `crc32` only become available once +serialization has finished. There is no synchronous counterpart: streaming +entries only make sense with an asynchronous, incrementally-produced +`source`. + +`source` is drained exactly once, during serialization. Until that happens +the entry has no readable content, so [`zipEntry.content()`][], +[`zipEntry.contentSync()`][], and [`zipEntry.contentIterator()`][] throw +[`ERR_INVALID_STATE`][]. If the entry is serialized by adding it to a writable +[`ZipFile`][] with [`zipFile.addEntry()`][] (or `addEntrySync()`), it is then +**promoted in place** to a file-backed entry pointing at the copy just written, +so it becomes readable (and can be serialized again) for as long as that +`ZipFile` stays open. Serializing it any other way (for example directly +through [`zlib.createZipArchive()`][]) leaves it spent and unreadable. + +Because `source` may hold an operating-system resource (a file read stream, +say), a streaming entry is disposable: its `Symbol.dispose` and +`Symbol.asyncDispose` methods destroy `source` if it has not been consumed. +An entry passed to an archive is disposed by that archive (see +[`zlib.createZipArchive()`][]); dispose an entry directly only when it was +built but never handed to one. Disposal is a no-op for non-streaming entries - +in particular a file-backed entry never closes the [`ZipFile`][] descriptor it +borrows. + +### Static method: `zlib.ZipEntry.createSymlink(filename, target[, options])` + + + +* `filename` {string} The entry's name within the archive. +* `target` {string} The symbolic link's target path. +* `options` {Object} + * `comment` {string} An entry comment. + * `mode` {integer} Unix permission bits. **Default:** `0o777`. + * `modified` {Date} The entry's modification time. **Default:** the current + time. +* Returns: {ZipEntry} + +Creates a symbolic-link entry: a stored entry whose content is `target` and +whose Unix mode type bits mark it as a symlink, so [`zipEntry.isSymlink`][] is +`true` when it is read back. Extraction tools that honor symlink entries +recreate the link; treat `target` as untrusted (see [`zipEntry.name`][] on +path safety). + +### Static method: `zlib.ZipEntry.createSync(filename, data[, options])` + + + +* `filename` {string} The entry's name within the archive. A trailing `/` + marks a directory entry. +* `data` {Buffer|TypedArray|DataView|ArrayBuffer} The entry's complete, + uncompressed content. Must be empty when `filename` names a directory. +* `options` {Object} See [`zlib.ZipEntry.create()`][]. +* Returns: {ZipEntry} + +The synchronous version of [`zlib.ZipEntry.create()`][]. + +### Static method: `zlib.ZipEntry.read(buffer)` + + + +* `buffer` {Buffer|TypedArray|DataView|ArrayBuffer} A complete ZIP archive. +* Returns: {Iterator} of {ZipEntry}. + +Parses every entry out of `buffer` directly, without indexing it into a +[`ZipBuffer`][]. Like [`ZipBuffer`][], the yielded entries hold zero-copy views +of `buffer` rather than copies of their content, so the same rule applies: do +not mutate or reuse `buffer` while any of them is still in use. + +### `zipEntry.comment` + + + +* Type: {string} + +### `zipEntry.compressed` + + + +* Type: {boolean} + +`true` if the entry's content is stored in compressed form (any compression +method, currently deflate or Zstandard); `false` if it is stored +uncompressed. + +### `zipEntry.compressedSize` + + + +* Type: {number} + +### `zipEntry.content([options])` + + + +* `options` {Object} + * `verify` {boolean} Verify the entry's CRC-32 checksum. **Default:** `true`. + * `maxSize` {number} Reject content declaring more than this many + uncompressed bytes, before allocating anything. **Default:** + [`zlib.getMaxZipContentSize()`][]. +* Returns: {Promise} Fulfilled with a {Buffer} containing the entry's + decompressed content. The buffer is a fresh copy that shares no memory + with the archive or with data the entry was created from. + +Throws an [`ERR_ZIP_ENTRY_TOO_LARGE`][] error if the entry's declared size +exceeds `maxSize`, an [`ERR_ZIP_ENTRY_CORRUPT`][] error if the content fails +CRC-32 verification or does not match its declared size, and an +[`ERR_INVALID_STATE`][] error for a streaming entry +([`zlib.ZipEntry.createStream()`][]) whose content is not yet available (see +that method for when a streaming entry becomes readable). + +### `zipEntry.contentSync([options])` + + + +* `options` {Object} See [`zipEntry.content()`][]. +* Returns: {Buffer} The entry's decompressed content. + +The synchronous version of [`zipEntry.content()`][]. + +### `zipEntry.contentIterator([options])` + + + +* `options` {Object} + * `verify` {boolean} Verify the entry's CRC-32 checksum. **Default:** `true`. + * `maxSize` {number} Reject content declaring more than this many + uncompressed bytes, before decompressing anything. **Default:** no limit. +* Returns: {AsyncIterator} of {Buffer} chunks of the entry's decompressed + content. + +Unlike [`zipEntry.content()`][], this does not buffer the whole member in +memory. For a file-backed entry (one returned by [`zipFile.get()`][]) the +compressed bytes are read from disk as the iterator is consumed and nothing is +retained; the entry is valid only while its `ZipFile` is open. + +Because streaming is the bounded-memory path for arbitrarily large members, it +is **not** capped by [`zlib.getMaxZipContentSize()`][] the way +[`zipEntry.content()`][] is - that default guards a single large allocation, +which streaming never makes. Output is still bounded per chunk to the declared +uncompressed size; pass `maxSize` to impose an explicit ceiling. + +For an in-memory entry stored without compression, the yielded chunks are +zero-copy views of the entry's retained content (see +[`zipEntry.rawContent`][]); do not mutate them. + +The yielded chunks are **provisional until the iterator completes**. CRC-32 +verification (and the final declared-size check) can only run once every byte +has been read, so a corrupt or truncated entry is reported by the iterator +throwing _after_ the last chunk, not before the first. Each chunk is still +bounded so the total never exceeds the declared size or `maxSize`, but a +consumer that must not act on unverified bytes should buffer them (or use +[`zipEntry.content()`][], which verifies before returning anything) rather than +processing chunks as they arrive. + +### `zipEntry.crc32` + + + +* Type: {number} + +### `zipEntry.flags` + + + +* Type: {number} + +The entry's raw general-purpose bit flag. + +### `zipEntry.isDirectory` + + + +* Type: {boolean} + +`true` if the entry is a directory (its name ends with `/`). + +### `zipEntry.isFile` + + + +* Type: {boolean} + +`true` if the entry is a regular file — that is, neither a directory nor a +symbolic link. + +### `zipEntry.isSymlink` + + + +* Type: {boolean} + +`true` if the entry is a symbolic link (its Unix mode type bits are +`S_IFLNK`); its content is the link target. Always `false` for archives not +written on a Unix-like system. When extracting, treat a symlink's target as +untrusted — see [`zipEntry.name`][] on path safety. + +### `zipEntry.mode` + + + +* Type: {number} + +The entry's Unix mode permission bits, including the setuid, setgid, and +sticky bits (the low 12 bits, `0o7777`), or `0` if the archive was not written +on a Unix-like system. The file-type bits are not included here; use +[`zipEntry.isDirectory`][] / [`zipEntry.isSymlink`][] for the type. + +### `zipEntry.modified` + + + +* Type: {Date} + +The entry's last-modification time. When the archive carries a higher-fidelity +timestamp in an extra field — an NTFS (`0x000a`), Info-ZIP extended (`0x5455`), +or Info-ZIP Unix (`0x5855`) field, as most modern tools write — that absolute +(UTC) time is used; otherwise the coarse, local-time MS-DOS date/time field +(2-second resolution) is used. + +Some tools store their high-fidelity timestamp only in the local file header, +so on a file-backed entry (one returned by [`zipFile.get()`][]) the first read +of this property may perform a small synchronous positioned disk read to +resolve that header. If that read fails, the value silently falls back to the +central-directory data. + +### `zipEntry.method` + + + +* Type: {number} + +The entry's raw compression method: `0` for stored, `8` for deflate, `93` +for Zstandard. + +### `zipEntry.name` + + + +* Type: {string} + +The entry's name, decoded from the central directory, which is treated as +authoritative — a local file header that disagrees is ignored, so a +mismatched-header ("ZIP-confusion") archive cannot make `name` disagree with +what is read. The bytes are decoded from a valid Info-ZIP Unicode Path extra +field (`0x7075`) when one is present; otherwise as UTF-8 when the +language-encoding flag (general-purpose bit 11) is set **or the bytes are +valid UTF-8** (plenty of tools wrote UTF-8 names without ever setting the +flag); and as CP437 — the historical default — only when they are not. +See [`zipEntry.nameBuffer`][] for the raw bytes. + +The name is returned **verbatim**: it is never normalized, and a name +containing `..`, a leading `/`, a drive letter, or backslashes is neither +rewritten nor rejected. A `ZipFile`/`ZipBuffer` never writes to disk, so +guarding against path traversal ("Zip Slip") when extracting is the caller's +responsibility. + +### `zipEntry.nameBuffer` + + + +* Type: {Buffer} + +The entry's raw name bytes, before any character decoding. Useful when the +archive's names are in an encoding other than UTF-8 or CP437 and the caller +wants to decode them itself. + +### `zipEntry.rawContent` + + + +* Type: {Buffer|null} + +The entry's raw (still compressed, if applicable) content when it is held in +memory, or `null` when there is no in-memory buffer to expose - for an entry +created with [`zlib.ZipEntry.createStream()`][], or a file-backed entry +returned by [`zipFile.get()`][], whose bytes are read from disk on demand +rather than retained. Use [`zipEntry.content()`][] or +[`zipEntry.contentIterator()`][] to read a file-backed entry. + +### `zipEntry.size` + + + +* Type: {number} + +The entry's uncompressed size, in bytes. + +## Class: `zlib.ZipFile` + + + +> Stability: 1.0 - Early development + +The ZIP archive API is experimental. Using any part of it (this class among +them) emits an experimental warning the first time; merely importing +`node:zlib` does not. + +A random-access view over the entries of a ZIP archive on disk. Only the +archive's tail and central directory are read up front; member content is +read from disk lazily, on demand. Writable when opened with +`{ writable: true }`: [`zipFile.addEntry()`][]/[`zipFile.add()`][] append the +new member's data where the central directory used to be, then rewrite the +central directory immediately after it; [`zipFile.delete()`][] just rewrites +the central directory. Both mean the file is altered as soon as the method's +returned `Promise` fulfills. Deleted or replaced members are left behind as +dead space; [`zipFile.compact()`][] produces a stream with none. + +These in-place edits are **not crash-atomic**. Rewriting the central directory +happens in place, so a write that fails partway - the disk fills, the device +disconnects, the process is killed - can leave the archive on disk with a +partial or missing central directory, i.e. unreadable, even though the member +data before it is intact. The rejected call surfaces the underlying error and +the `ZipFile` object is left usable (its in-memory view is not discarded, so a +caller can attempt recovery - for example re-writing the entries elsewhere with +[`zipFile.compact()`][]), but that in-memory view may no longer match the bytes +on disk. Write to a copy, or `compact()` into a fresh file, when durability +across a failure matters. + +Every method has a `*Sync` counterpart. As with the synchronous `node:fs` +APIs, these block the Node.js event loop and further JavaScript execution +until the operation completes; use them only where synchronous execution is +appropriate (for example, short-lived scripts or startup code), not in code +that must stay responsive. A synchronous method throws `ERR_INVALID_STATE` +if called while an asynchronous `add()`, `addEntry()`, `delete()`, or +`close()` on the same `ZipFile` has not settled yet, since letting the two +interleave could corrupt the archive. + +```mjs +import { ZipFile } from 'node:zlib'; +import { Buffer } from 'node:buffer'; + +const zip = await ZipFile.open('archive.zip', { writable: true }); +try { + const entry = await zip.get('member.txt'); + console.log((await entry.content()).toString()); + for await (const chunk of await zip.stream('huge.bin')) { + // Process each chunk without buffering the whole member. + } + await zip.add('new.txt', Buffer.from('hello')); + await zip.delete('unwanted.txt'); +} finally { + await zip.close(); +} +``` + +```cjs +const { ZipFile } = require('node:zlib'); + +async function main() { + const zip = await ZipFile.open('archive.zip', { writable: true }); + try { + const entry = await zip.get('member.txt'); + console.log((await entry.content()).toString()); + for await (const chunk of await zip.stream('huge.bin')) { + // Process each chunk without buffering the whole member. + } + await zip.add('new.txt', Buffer.from('hello')); + await zip.delete('unwanted.txt'); + } finally { + await zip.close(); + } +} +main(); +``` + +### Static method: `zlib.ZipFile.open(filename[, options])` + + + +* `filename` {string} +* `options` {Object} + * `writable` {boolean} Open the underlying file for both reading and + writing (`'r+'`), enabling [`zipFile.addEntry()`][]/[`zipFile.add()`][]/ + [`zipFile.delete()`][]. **Default:** `false`. +* Returns: {Promise} Fulfilled with a {ZipFile}. + +Throws an [`ERR_ZIP_ARCHIVE_TOO_LARGE`][] error if the archive's central +directory is too large to buffer in memory. + +### Static method: `zlib.ZipFile.openSync(filename[, options])` + + + +* `filename` {string} +* `options` {Object} See [`zlib.ZipFile.open()`][]. +* Returns: {ZipFile} + +The synchronous version of [`zlib.ZipFile.open()`][]. + +### `zipFile.add(filename, data[, options])` + + + +* `filename` {string} The entry's name within the archive. A trailing `/` + marks a directory entry. +* `data` {Buffer|TypedArray|DataView|ArrayBuffer} The entry's complete, + uncompressed content. +* `options` {Object} See [`zlib.ZipEntry.create()`][]. +* Returns: {Promise} Fulfilled with the created {ZipEntry}. + +Equivalent to `zipFile.addEntry(await zlib.ZipEntry.create(filename, data, +options))`. + +### `zipFile.addEntry(entry)` + + + +* `entry` {ZipEntry} +* Returns: {Promise} Fulfilled with `entry`. + +Writes `entry` where the central directory currently starts, then rewrites +the central directory to include it, replacing any existing entry of the +same name. Throws [`ERR_ZIP_NOT_WRITABLE`][] if the `ZipFile` was not opened +with `{ writable: true }`. + +The returned (same) `entry` is left readable: a streaming entry created with +[`zlib.ZipEntry.createStream()`][], which would otherwise be spent once +serialized, is promoted in place to a file-backed entry pointing at the copy +just written (valid while this `ZipFile` is open). In-memory entries keep their +own buffer unchanged. + +### `zipFile.addEntrySync(entry)` + + + +* `entry` {ZipEntry} +* Returns: {ZipEntry} `entry`. + +The synchronous version of [`zipFile.addEntry()`][]. `entry` must not be a +pending streaming entry (one created with +[`zlib.ZipEntry.createStream()`][]) - there is no synchronous way to drain +its asynchronous source. + +### `zipFile.addSync(filename, data[, options])` + + + +* `filename` {string} The entry's name within the archive. A trailing `/` + marks a directory entry. +* `data` {Buffer|TypedArray|DataView|ArrayBuffer} The entry's complete, + uncompressed content. +* `options` {Object} See [`zlib.ZipEntry.createSync()`][]. +* Returns: {ZipEntry} The created entry. + +The synchronous version of [`zipFile.add()`][]. Equivalent to +`zipFile.addEntrySync(zlib.ZipEntry.createSync(filename, data, options))`. + +### `zipFile.close()` + + + +* Returns: {Promise} + +Closes the underlying file handle. + +Closing does not invalidate outstanding objects: `ZipEntry` objects previously +returned by [`zipFile.get()`][] and the `ZipFile`'s own methods will fail with +system-level errors (for example `EBADF`) if used after close, rather than a +dedicated Node.js error code. The same applies to [`zipFile.closeSync()`][]. + +### `zipFile.closeSync()` + + + +The synchronous version of [`zipFile.close()`][]. + +### `zipFile.comment` + + + +* Type: {string} + +The archive-level comment, preserved byte-for-byte across +[`zipFile.addEntry()`][]/[`zipFile.delete()`][] calls. The bytes are decoded +as UTF-8 when they are valid UTF-8 and as CP437 otherwise (the field carries +no encoding flag of its own). + +### `zipFile.compact([comment])` + + + +* `comment` {string} An archive comment. **Default:** [`zipFile.comment`][]. +* Returns: {stream.Readable} A stream of the currently live entries, + serialized as a fresh archive with no dead space left by prior + [`zipFile.addEntry()`][]/[`zipFile.delete()`][] calls. + +Does not modify the open file; pipe the result into a new one: + +```mjs +import { createWriteStream } from 'node:fs'; +zip.compact().pipe(createWriteStream('compacted.zip')); +``` + +### `zipFile.compactSync([comment])` + + + +* `comment` {string} An archive comment. **Default:** [`zipFile.comment`][]. +* Returns: {Buffer} The currently live entries, serialized as a fresh + archive with no dead space left by prior + [`zipFile.addEntry()`][]/[`zipFile.delete()`][] calls. + +The synchronous version of [`zipFile.compact()`][]. Does not modify the +open file. + +### `zipFile.delete(name)` + + + +* `name` {string} +* Returns: {Promise} Fulfilled with `true` if an entry named `name` existed + and was removed, `false` otherwise. + +Rewrites the central directory without writing any new content - the +archive does not grow. Throws [`ERR_ZIP_NOT_WRITABLE`][] if the `ZipFile` was +not opened with `{ writable: true }`. + +### `zipFile.deleteSync(name)` + + + +* `name` {string} +* Returns: {boolean} `true` if an entry named `name` existed and was + removed, `false` otherwise. + +The synchronous version of [`zipFile.delete()`][]. + +### `zipFile.entries()` + + + +* Returns: {Iterator} of `[name, entry]` pairs, where `entry` is a + {Promise} fulfilled with a [`ZipEntry`][]. + +### `zipFile.entriesSync()` + + + +* Returns: {Iterator} of `[name, entry]` pairs, where `entry` is a resolved + [`ZipEntry`][] (not a `Promise`). + +The synchronous version of [`zipFile.entries()`][]. + +### `zipFile.forEach(callback[, thisArg])` + + + +* `callback` {Function} +* `thisArg` {any} + +### `zipFile.forEachSync(callback[, thisArg])` + + + +* `callback` {Function} +* `thisArg` {any} + +The synchronous version of [`zipFile.forEach()`][]: `callback` is invoked +with a resolved [`ZipEntry`][] instead of a `Promise`. + +### `zipFile.get(name)` + + + +* `name` {string} +* Returns: {Promise} Fulfilled with a {ZipEntry}. + +Returns a lazy, file-backed [`ZipEntry`][] for `name`. Nothing is read from +disk here and no content is buffered: the returned entry reads (and, for +[`zipEntry.content()`][], decompresses) its member straight from the file on +each access, and the `ZipFile` retains no member content. The entry is valid +only while this `ZipFile` is open. Reading its content later may throw +[`ERR_ZIP_ENTRY_TOO_LARGE`][] if the member is too large to hold in a single +buffer; use [`zipEntry.contentIterator()`][] (or [`zipFile.stream()`][]) +instead. Throws [`ERR_ZIP_ENTRY_NOT_FOUND`][] if the archive has no entry +named `name`. + +### `zipFile.getSync(name)` + + + +* `name` {string} +* Returns: {ZipEntry} + +The synchronous version of [`zipFile.get()`][]. Like `get()`, it reads +nothing up front and only builds the lazy handle, so it does not itself block +on I/O - but reads performed later through the returned entry (such as +[`zipEntry.contentSync()`][]) do; see the note above on synchronous methods. + +### `zipFile.has(name)` + + + +* `name` {string} +* Returns: {boolean} + +### `zipFile.keys()` + + + +* Returns: {Iterator} of entry names. + +### `zipFile.size` + + + +* Type: {number} + +The number of entries in the archive. + +### `zipFile.stream(name[, options])` + + + +* `name` {string} +* `options` {Object} + * `verify` {boolean} Verify the entry's CRC-32 checksum. **Default:** `true`. + * `maxSize` {number} Reject content declaring more than this many + uncompressed bytes. **Default:** no limit. +* Returns: {Promise} Fulfilled with a {stream.Readable} of the member's + decompressed content, without buffering the whole member in memory. + +Convenience wrapper that resolves to a `Readable` over +[`zipEntry.contentIterator()`][] of [`zipFile.get()`][]`(name)`; the +compressed bytes are read from disk as the stream is consumed. The returned +promise rejects with [`ERR_ZIP_ENTRY_NOT_FOUND`][] if the archive has no entry +named `name`. + +### `zipFile.values()` + + + +* Returns: {Iterator} of {Promise} objects, each fulfilled with a + [`ZipEntry`][]. + +### `zipFile.valuesSync()` + + + +* Returns: {Iterator} of resolved [`ZipEntry`][] values (not `Promise`s). + +The synchronous version of [`zipFile.values()`][]. + +### `zipFile.writable` + + + +* Type: {boolean} + +Whether this `ZipFile` was opened with `{ writable: true }`. + ## Class: `zlib.ZlibBase` + +> Stability: 1.0 - Early development + +The ZIP archive API is experimental. Using any part of it (this function among +them) emits an experimental warning the first time; merely importing +`node:zlib` does not. + +* `entries` {Iterable|AsyncIterable} of [`ZipEntry`][]. +* `options` {string|Object} An archive comment, as a shorthand for + `{ comment: options }`. + * `comment` {string} An archive comment. + * `baseOffset` {number} Shifts every local/central header offset the + archive records by this many bytes, so the emitted stream is + self-describing even when something else is written before it - for + example, appending the archive after `baseOffset` bytes already written to + the same file, rather than at its start. **Default:** `0`. +* Returns: {stream.Readable} A byte stream of the serialized archive. + +Serializes `entries` into a ZIP archive, switching to Zip64 structures +automatically once the entry count, or any offset or size, exceeds what the +classic 32-/16-bit ZIP fields can hold. The returned `Readable` is also an +`AsyncIterable` of the same {Buffer} chunks it streams. + +Entries are written in iteration order and nothing deduplicates names: an +iterable that yields two entries with the same name produces an archive +containing both, and most extraction tools keep the one that appears later. +[`ZipBuffer`][] and [`ZipFile`][] `add()` methods replace entries by name +instead. + +The entries are owned by the returned stream: each is consumed as the archive +is produced and must not be reused afterwards. This matters for streaming +entries (from [`zlib.ZipEntry.createStream()`][]), which hold an underlying +source such as a file read stream. If the returned stream is destroyed before +it is fully consumed - for example, the destination of a [`pipeline()`][] +fails - it disposes the entry it was serializing and every entry still queued +behind it, destroying their sources so no descriptor leaks. Consume the stream +to the end, or destroy it (directly, through a failed `pipeline()`, or with +`await using`), to guarantee this cleanup; a stream that is neither consumed +nor destroyed cannot release anything. A [`ZipEntry`][] that is never handed to +an archive can be released directly with `Symbol.dispose` / `Symbol.asyncDispose`. + +Throws an [`ERR_ZIP_ARCHIVE_TOO_LARGE`][] error if the archive comment +exceeds 65,535 bytes when encoded as UTF-8. + +```mjs +import { createWriteStream } from 'node:fs'; +import { pipeline } from 'node:stream/promises'; +import { Buffer } from 'node:buffer'; +import { ZipEntry, createZipArchive } from 'node:zlib'; + +const entries = [ + await ZipEntry.create('hello.txt', Buffer.from('Hello, world!')), + await ZipEntry.create('data/', Buffer.alloc(0)), +]; +await pipeline( + createZipArchive(entries, 'created by node:zlib'), + createWriteStream('archive.zip'), +); +``` + +```cjs +const { createWriteStream } = require('node:fs'); +const { pipeline } = require('node:stream/promises'); +const { ZipEntry, createZipArchive } = require('node:zlib'); + +async function main() { + const entries = [ + await ZipEntry.create('hello.txt', Buffer.from('Hello, world!')), + await ZipEntry.create('data/', Buffer.alloc(0)), + ]; + await pipeline( + createZipArchive(entries, 'created by node:zlib'), + createWriteStream('archive.zip'), + ); +} +main(); +``` + +Passing `options.baseOffset` produces an archive that is valid immediately +when placed after other content in the same file, without relying on a +reader's self-extracting-archive detection to compensate for the shift: + +```mjs +import { createWriteStream } from 'node:fs'; +import { Buffer } from 'node:buffer'; +import { ZipEntry, createZipArchive } from 'node:zlib'; + +const prefix = Buffer.from('#!/bin/sh\nexit 0\n'); +const entries = [await ZipEntry.create('hello.txt', Buffer.from('Hello, world!'))]; +const out = createWriteStream('self-extracting.zip'); +out.write(prefix); +createZipArchive(entries, { baseOffset: prefix.byteLength }).pipe(out); +``` + +## `zlib.createZipArchiveSync(entries[, options])` + + + +> Stability: 1.0 - Early development + +The ZIP archive API is experimental. Using any part of it (this function among +them) emits an experimental warning the first time; merely importing +`node:zlib` does not. + +* `entries` {Iterable} of [`ZipEntry`][]. +* `options` {string|Object} See [`zlib.createZipArchive()`][]. +* Returns: {Iterator} of {Buffer} chunks making up the serialized archive. + +The synchronous version of [`zlib.createZipArchive()`][]. Blocks the +Node.js event loop and further JavaScript execution until the whole +archive (including any deflate passes) has been produced; use only where +synchronous execution is appropriate (for example, short-lived scripts or +startup code), not in code that must stay responsive. `entries` must be a +plain (synchronous) `Iterable` - a streaming entry created with +[`zlib.ZipEntry.createStream()`][] throws when its turn to serialize comes +up, since draining its asynchronous source has no synchronous equivalent. + +As with [`zlib.createZipArchive()`][], the entries are owned by the returned +iterator and must not be reused. If iteration stops early - including the +throw on a streaming entry - the entry that stopped it and every entry still +queued behind it are disposed, releasing any sources they hold. + +## `zlib.zipFiles(files[, options])` + + + +> Stability: 1.0 - Early development + +The ZIP archive API is experimental. Using any part of it (this function among +them) emits an experimental warning the first time; merely importing +`node:zlib` does not. + +* `files` {Iterable} of `[sourcePath, entryName]` string pairs. Any iterable + works — an array, a `Map`, the result of `Object.entries()`, a generator. +* `options` {string|Object} + * `followSymlinks` {boolean} Resolve a symbolic link and archive the file it + points to, rather than storing the link itself. **Default:** `true`. + * `comment` {string} An archive comment; a string `options` is shorthand for + `{ comment: options }`. + * `baseOffset` {number} See [`zlib.createZipArchive()`][]. +* Returns: {stream.Readable} of {Buffer} chunks making up the serialized + archive. + +Builds an archive from files on disk. For each `[sourcePath, entryName]` pair +it reads `sourcePath` and adds an entry named `entryName`, capturing the file's +Unix mode and modification time. A directory becomes a directory entry; a +regular file's contents are streamed in (as a [`zlib.ZipEntry.createStream()`][] +entry) without being buffered in memory. Directory contents are not walked +recursively — list each path you want included. + +When `followSymlinks` is `true` (the default) a symbolic link is resolved and +archived as its target file; when it is `false` the link itself is stored as a +symbolic-link entry whose content is the target path (see +[`zlib.ZipEntry.createSymlink()`][]). + +```mjs +import { zipFiles } from 'node:zlib'; +import { createWriteStream } from 'node:fs'; +import { pipeline } from 'node:stream/promises'; + +await pipeline( + zipFiles([ + ['/data/report.pdf', 'report.pdf'], + ['/data/notes.txt', 'docs/notes.txt'], + ]), + createWriteStream('archive.zip'), +); +``` + ## `zlib.createZstdCompress([options])` > Stability: 1 - Experimental @@ -1363,6 +2639,44 @@ added: Creates and returns a new [`ZstdDecompress`][] object. +## `zlib.getMaxZipContentSize()` + + + +> Stability: 1.0 - Early development + +The ZIP archive API is experimental. Using any part of it (this function among +them) emits an experimental warning the first time; merely importing +`node:zlib` does not. + +* Returns: {number} + +The current default ceiling, in bytes, applied by [`zipEntry.content()`][] +when no explicit `maxSize` is given. **Default:** `268435456` (256 MiB). + +## `zlib.setMaxZipContentSize(size)` + + + +> Stability: 1.0 - Early development + +The ZIP archive API is experimental. Using any part of it (this function among +them) emits an experimental warning the first time; merely importing +`node:zlib` does not. + +* `size` {number} + +Sets the default ceiling used by [`zipEntry.content()`][] when no explicit +`maxSize` option is given. This is a guard against zip bombs: an archive +whose central directory declares a member larger than this is rejected +before allocating memory for it. Streaming reads +([`zipEntry.contentIterator()`][], [`zipFile.stream()`][]) are bounded-memory +by design and are not affected by this setting. + ## Convenience methods @@ -2029,11 +3343,22 @@ Create a Zstandard decompression transform. [`Content-Encoding`]: https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.11 [`DeflateRaw`]: #class-zlibdeflateraw [`Deflate`]: #class-zlibdeflate +[`ERR_INVALID_STATE`]: errors.md#err_invalid_state +[`ERR_ZIP_ARCHIVE_TOO_LARGE`]: errors.md#err_zip_archive_too_large +[`ERR_ZIP_ENTRY_CORRUPT`]: errors.md#err_zip_entry_corrupt +[`ERR_ZIP_ENTRY_NOT_FOUND`]: errors.md#err_zip_entry_not_found +[`ERR_ZIP_ENTRY_TOO_LARGE`]: errors.md#err_zip_entry_too_large +[`ERR_ZIP_INVALID_ARCHIVE`]: errors.md#err_zip_invalid_archive +[`ERR_ZIP_NOT_WRITABLE`]: errors.md#err_zip_not_writable +[`ERR_ZIP_UNSUPPORTED_FEATURE`]: errors.md#err_zip_unsupported_feature [`Gunzip`]: #class-zlibgunzip [`Gzip`]: #class-zlibgzip [`InflateRaw`]: #class-zlibinflateraw [`Inflate`]: #class-zlibinflate [`Unzip`]: #class-zlibunzip +[`ZipBuffer`]: #class-zlibzipbuffer +[`ZipEntry`]: #class-zlibzipentry +[`ZipFile`]: #class-zlibzipfile [`ZlibBase`]: #class-zlibzlibbase [`ZstdCompress`]: #class-zlibzstdcompress [`ZstdDecompress`]: #class-zlibzstddecompress @@ -2041,8 +3366,43 @@ Create a Zstandard decompression transform. [`deflateInit2` and `inflateInit2`]: https://zlib.net/manual.html#Advanced [`node:stream/iter`]: stream_iter.md [`pipeTo()`]: stream_iter.md#pipetosource-transforms-writer-options +[`pipeline()`]: stream.md#streampipelinesource-transforms-destination-callback [`pull()`]: stream_iter.md#pullsource-transforms-options [`stream.Transform`]: stream.md#class-streamtransform +[`zipBuffer.add()`]: #zipbufferaddfilename-data-options +[`zipBuffer.addSync()`]: #zipbufferaddsyncfilename-data-options +[`zipBuffer.comment`]: #zipbuffercomment +[`zipBuffer.toBuffer()`]: #zipbuffertobufferoptions +[`zipBuffer.toBufferSync()`]: #zipbuffertobuffersyncoptions +[`zipEntry.content()`]: #zipentrycontentoptions +[`zipEntry.contentIterator()`]: #zipentrycontentiteratoroptions +[`zipEntry.contentSync()`]: #zipentrycontentsyncoptions +[`zipEntry.isDirectory`]: #zipentryisdirectory +[`zipEntry.isSymlink`]: #zipentryissymlink +[`zipEntry.modified`]: #zipentrymodified +[`zipEntry.nameBuffer`]: #zipentrynamebuffer +[`zipEntry.name`]: #zipentryname +[`zipEntry.rawContent`]: #zipentryrawcontent +[`zipFile.add()`]: #zipfileaddfilename-data-options +[`zipFile.addEntry()`]: #zipfileaddentryentry +[`zipFile.close()`]: #zipfileclose +[`zipFile.closeSync()`]: #zipfileclosesync +[`zipFile.comment`]: #zipfilecomment +[`zipFile.compact()`]: #zipfilecompactcomment +[`zipFile.delete()`]: #zipfiledeletename +[`zipFile.entries()`]: #zipfileentries +[`zipFile.forEach()`]: #zipfileforeachcallback-thisarg +[`zipFile.get()`]: #zipfilegetname +[`zipFile.stream()`]: #zipfilestreamname-options +[`zipFile.values()`]: #zipfilevalues +[`zlib.ZipEntry.create()`]: #static-method-zlibzipentrycreatefilename-data-options +[`zlib.ZipEntry.createStream()`]: #static-method-zlibzipentrycreatestreamfilename-source-options +[`zlib.ZipEntry.createSymlink()`]: #static-method-zlibzipentrycreatesymlinkfilename-target-options +[`zlib.ZipEntry.createSync()`]: #static-method-zlibzipentrycreatesyncfilename-data-options +[`zlib.ZipFile.open()`]: #static-method-zlibzipfileopenfilename-options +[`zlib.createZipArchive()`]: #zlibcreateziparchiveentries-options +[`zlib.createZipArchiveSync()`]: #zlibcreateziparchivesyncentries-options +[`zlib.getMaxZipContentSize()`]: #zlibgetmaxzipcontentsize [convenience methods]: #convenience-methods [zlib documentation]: https://zlib.net/manual.html#Constants [zlib.createGzip example]: #zlib diff --git a/doc/type-map.json b/doc/type-map.json index a35196d3a55e..0483bd15f5e5 100644 --- a/doc/type-map.json +++ b/doc/type-map.json @@ -155,6 +155,9 @@ "WritableStreamDefaultController": "webstreams.html#class-writablestreamdefaultcontroller", "WritableStreamDefaultWriter": "webstreams.html#class-writablestreamdefaultwriter", "X509Certificate": "crypto.html#class-x509certificate", + "ZipBuffer": "zlib.html#class-zlibzipbuffer", + "ZipEntry": "zlib.html#class-zlibzipentry", + "ZipFile": "zlib.html#class-zlibzipfile", "zlib options": "zlib.html#class-options", "zstd options": "zlib.html#class-zstdoptions" } diff --git a/lib/internal/errors.js b/lib/internal/errors.js index 900f7fe740b3..d98495aa6566 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -2002,4 +2002,11 @@ E('ERR_WORKER_UNSERIALIZABLE_ERROR', 'Serializing an uncaught exception failed', Error); E('ERR_WORKER_UNSUPPORTED_OPERATION', '%s is not supported in workers', TypeError); +E('ERR_ZIP_ARCHIVE_TOO_LARGE', 'ZIP archive structure exceeds the allowed size: %s', RangeError); +E('ERR_ZIP_ENTRY_CORRUPT', 'ZIP entry is corrupt: %s', Error); +E('ERR_ZIP_ENTRY_NOT_FOUND', 'no such entry %j in the archive', Error); +E('ERR_ZIP_ENTRY_TOO_LARGE', 'ZIP entry exceeds the allowed size: %s', RangeError); +E('ERR_ZIP_INVALID_ARCHIVE', 'invalid ZIP archive: %s', Error); +E('ERR_ZIP_NOT_WRITABLE', 'this archive was not opened for writing', TypeError); +E('ERR_ZIP_UNSUPPORTED_FEATURE', 'unsupported ZIP feature: %s', Error); E('ERR_ZSTD_INVALID_PARAM', '%s is not a valid zstd parameter', RangeError); diff --git a/lib/internal/zip.js b/lib/internal/zip.js new file mode 100644 index 000000000000..1889a2eea9da --- /dev/null +++ b/lib/internal/zip.js @@ -0,0 +1,44 @@ +'use strict'; + +// Public entry point for ZIP archive support in `node:zlib`. The +// implementation is split across `internal/zip/`: +// +// constants shared signatures, flags, symbols, and small values +// binary bounds-checked reads and buffer coercion +// content-size the module-global in-memory decompression ceiling +// dos MS-DOS date/time and CP437 legacy name/text decoding +// extra-fields TLV extra-field parsing and building +// headers reader-side header structures and archive-end location +// header-builders writer-side header/record builders +// compression deflate/inflate/zstd plumbing and member decoding +// fs-util fd read/write helpers +// entry ZipEntry +// archive createZipArchive()/zipFiles() serialization +// buffer ZipBuffer +// file ZipFile +// +// This barrel re-exports only the surface `lib/zlib.js` consumes. + +const { ZipEntry } = require('internal/zip/entry'); +const { ZipBuffer } = require('internal/zip/buffer'); +const { ZipFile } = require('internal/zip/file'); +const { + createZipArchive, + createZipArchiveSync, + zipFiles, +} = require('internal/zip/archive'); +const { + getMaxZipContentSize, + setMaxZipContentSize, +} = require('internal/zip/content-size'); + +module.exports = { + ZipEntry, + ZipFile, + ZipBuffer, + createZipArchive, + createZipArchiveSync, + zipFiles, + getMaxZipContentSize, + setMaxZipContentSize, +}; diff --git a/lib/internal/zip/archive.js b/lib/internal/zip/archive.js new file mode 100644 index 000000000000..7693ce1297f2 --- /dev/null +++ b/lib/internal/zip/archive.js @@ -0,0 +1,321 @@ +'use strict'; + +// Archive serialization: `createZipArchive()`/`createZipArchiveSync()` and +// the `zipFiles()` on-disk variant, the `generateZipArchive()` generator they +// build on (auto-switching to Zip64 as offsets/counts overflow), and the +// shared archive-option normalizer. + +const { + ArrayPrototypePush, + JSONStringify, + NumberMAX_SAFE_INTEGER, + StringPrototypeEndsWith, + SymbolAsyncDispose, + SymbolAsyncIterator, + SymbolDispose, + SymbolIterator, +} = primordials; + +const { + codes: { + ERR_ZIP_ARCHIVE_TOO_LARGE, + ERR_ZIP_UNSUPPORTED_FEATURE, + }, +} = require('internal/errors'); +const { + validateBoolean, + validateInteger, + validateObject, + validateString, +} = require('internal/validators'); +const { isUint8Array } = require('internal/util/types'); +const { Buffer } = require('buffer'); +const { Readable } = require('stream'); +const fs = require('fs'); +const { + EMPTY_BUFFER, + SENTINEL16, + kFinalize, +} = require('internal/zip/constants'); +const { + buildArchiveTrailer, +} = require('internal/zip/header-builders'); +const { + fsStatAsync, + fsLstatAsync, + fsReadlinkAsync, +} = require('internal/zip/fs-util'); +const { ZipEntry } = require('internal/zip/entry'); + +/** + * `createZipArchive()`/`createZipArchiveSync()` (and the `ZipBuffer` + * `toBuffer()`/`toBufferSync()` methods that forward to them) take a single + * optional `options` argument that doubles as a plain archive comment: a + * string is shorthand for `{ comment: options }`. + * @param {string | { comment?: string, baseOffset?: number }} [options] + * @returns {{ comment: string | undefined, baseOffset: number }} + */ +function normalizeArchiveOptions(options) { + if (options === undefined) return { comment: undefined, baseOffset: 0 }; + if (typeof options === 'string') return { comment: options, baseOffset: 0 }; + validateObject(options, 'options'); + const { comment, baseOffset = 0 } = options; + // A Buffer comment is an internal convenience (used when round-tripping an + // existing archive) that preserves the original bytes without forcing them + // through a decode/re-encode cycle that would corrupt non-UTF-8 comments. + if (comment !== undefined && !isUint8Array(comment)) { + validateString(comment, 'options.comment'); + } + validateInteger(baseOffset, 'options.baseOffset', 0, NumberMAX_SAFE_INTEGER); + return { comment, baseOffset }; +} + +/** + * Serializes `entries` (a (async) iterable of `ZipEntry`) into a `Readable` + * stream of archive byte chunks, automatically switching to Zip64 structures + * once the entry count or any offset/size exceeds the classic 32-/16-bit + * limits. + * + * `options.baseOffset` shifts every local/central header offset the archive + * records by that many bytes, so the emitted bytes are self-describing even + * when something else is written before them - for example, appending the + * archive after `baseOffset` bytes already written to the same file, rather + * than at its start. + * @param {Iterable | AsyncIterable} entries + * @param {string | { comment?: string, baseOffset?: number }} [options] + * @returns {Readable} + */ +function createZipArchive(entries, options) { + return Readable.from(generateZipArchive(entries, options), { objectMode: false }); +} + +/** + * Creates an archive from files on disk. `files` is an iterable of + * `[sourcePath, entryName]` pairs - an array, a `Map`, the result of + * `Object.entries()`, a generator, and so on. Each entry captures the file's + * Unix mode and modification time; a directory becomes a directory entry and a + * regular file's contents are streamed in without being buffered in memory. + * + * With `options.followSymlinks` (default `true`) a symbolic link is resolved + * and archived as the file it points to; with it `false` the link itself is + * stored as a symlink entry whose content is the target path. + * @param {Iterable<[string, string]>} files + * @param {string | { followSymlinks?: boolean, comment?: string, baseOffset?: number }} [options] + * @returns {import('stream').Readable} + */ +function zipFiles(files, options) { + const followSymlinks = options?.followSymlinks ?? true; + validateBoolean(followSymlinks, 'options.followSymlinks'); + return createZipArchive(fileEntries(files, followSymlinks), options); +} + +// Turn each `[sourcePath, entryName]` pair into a `ZipEntry`, stat-ing the +// source to capture its mode/mtime and picking the symlink/directory/file +// entry shape; the file-backed variant streams contents rather than buffering. +async function* fileEntries(files, followSymlinks) { + for await (const pair of files) { + const sourcePath = pair[0]; + const name = pair[1]; + validateString(sourcePath, 'sourcePath'); + validateString(name, 'name'); + // Following links resolves through to the target (stat); otherwise the + // link itself is inspected (lstat) and stored as a symlink entry. + const stats = followSymlinks ? await fsStatAsync(sourcePath) : await fsLstatAsync(sourcePath); + const options = { __proto__: null, mode: stats.mode & 0o7777, modified: stats.mtime }; + if (stats.isSymbolicLink()) { + yield ZipEntry.createSymlink(name, await fsReadlinkAsync(sourcePath), options); + } else if (stats.isDirectory()) { + const dirName = StringPrototypeEndsWith(name, '/') ? name : `${name}/`; + yield await ZipEntry.create(dirName, EMPTY_BUFFER, options); + } else { + // Open the file ourselves and stream from that exact descriptor instead + // of re-resolving the path with createReadStream(). Re-opening by path + // would let a symlink swapped in after the classification above redirect + // the read - a TOCTOU that defeats followSymlinks:false. O_NOFOLLOW makes + // a final-component symlink fail the open outright when not following; + // the fstat then confirms a regular file, never a FIFO, device, or + // socket (which as a stream source could block or emit unbounded data). + // Metadata comes from that same fstat so it describes the inode we read. + const flags = followSymlinks ? + fs.constants.O_RDONLY : + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0); + const handle = await fs.promises.open(sourcePath, flags); + let ownsHandle = true; + try { + const stat = await handle.stat(); + if (!stat.isFile()) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE( + `cannot archive ${JSONStringify(sourcePath)}: not a regular file`); + } + const fileOptions = { + __proto__: null, + mode: stat.mode & 0o7777, + modified: stat.mtime, + }; + // The stream adopts the handle and closes it when it ends or is + // destroyed (autoClose); if the consumer stops before taking the + // entry, the finally destroys the stream, releasing the descriptor. + const stream = fs.createReadStream(undefined, { fd: handle }); + ownsHandle = false; + let handedOff = false; + try { + yield ZipEntry.createStream(name, stream, fileOptions); + handedOff = true; + } finally { + if (!handedOff) stream.destroy(); + } + } finally { + if (ownsHandle) await handle.close(); + } + } + } +} + +// Encode the archive comment (a string, or raw bytes when round-tripping a +// non-UTF-8 comment) and enforce the 16-bit length field (sec. 4.3.16). +function normalizeCommentBuffer(comment) { + if (comment === undefined) return EMPTY_BUFFER; + const buffer = isUint8Array(comment) ? comment : Buffer.from(comment, 'utf8'); + if (buffer.length > SENTINEL16) { + throw new ERR_ZIP_ARCHIVE_TOO_LARGE( + 'the archive comment must not exceed 65535 bytes when encoded as UTF-8'); + } + return buffer; +} + +// Dispose the entries still queued behind an interrupted serialization. A +// ZipEntry handed to the archive is owned by it, so when the consumer destroys +// the output stream early - which runs `generateZipArchive`'s `finally` - the +// entries it never reached must be released too (a streaming entry may hold a +// file read stream). The entry currently being serialized is torn down by the +// `for await ... of entry` loop's own return propagation, so only the queue +// behind it is handled here. +// +// A materialized source (an array, `Map`, `Object.entries()`) has already +// created every entry, so each remaining one is pulled and disposed. A lazy +// async source is instead `return()`ed: forcing it to produce every remaining +// entry could open thousands of descriptors just to close them, so it is +// signalled to stop and release its own in-flight resource (see +// `fileEntries()`). +async function disposeQueuedEntries(iterator, isAsync) { + if (isAsync) { + if (typeof iterator.return === 'function') await iterator.return(); + return; + } + for (let next = iterator.next(); !next.done; next = iterator.next()) { + const entry = next.value; + if (typeof entry?.[SymbolAsyncDispose] === 'function') await entry[SymbolAsyncDispose](); + } +} + +// Core serializer backing `createZipArchive()`: emits each entry's local header +// + data, then the central directory, then the end-of-central-directory record +// (APPNOTE sec. 4.3.6 archive layout), promoting to Zip64 end records +// (sec. 4.3.14/4.3.15) once any count/offset/size overflows its classic field. +// +// The entries are owned by this generator: it drives the iterator by hand +// (rather than `for await`) so that if the consumer destroys the returned +// stream partway - `Readable.from()` then calls this generator's `return()` - +// the `finally` can dispose every entry that was never fully serialized. A +// synchronous source is stepped without `await` so a pulled-but-not-yet-used +// entry cannot be stranded by a return injected at the `await`. +async function* generateZipArchive(entries, options) { + const { comment, baseOffset } = normalizeArchiveOptions(options); + const commentBuffer = normalizeCommentBuffer(comment); + const centralHeaders = []; + let pos = baseOffset; + const isAsync = entries[SymbolAsyncIterator] !== undefined; + const iterator = isAsync ? entries[SymbolAsyncIterator]() : entries[SymbolIterator](); + let completed = false; + try { + while (true) { + const next = isAsync ? await iterator.next() : iterator.next(); + if (next.done) break; + const entry = next.value; + const start = pos; + for await (const chunk of entry) { + yield chunk; + pos += chunk.length; + } + ArrayPrototypePush(centralHeaders, entry[kFinalize](start)); + } + const centralDirectoryOffset = pos; + for (let i = 0; i < centralHeaders.length; i++) { + const chunk = centralHeaders[i]; + yield chunk; + pos += chunk.length; + } + const centralDirectorySize = pos - centralDirectoryOffset; + const count = centralHeaders.length; + const trailer = buildArchiveTrailer(count, centralDirectorySize, centralDirectoryOffset, commentBuffer); + for (let i = 0; i < trailer.length; i++) yield trailer[i]; + completed = true; + } finally { + if (!completed) await disposeQueuedEntries(iterator, isAsync); + } +} + +/** + * The synchronous counterpart of `createZipArchive()`. `entries` must be a + * plain (synchronous) `Iterable` of entries that don't require an + * asynchronous serialization pass - a streaming entry created with + * `ZipEntry.createStream()` throws when its turn to serialize comes up, the + * same as calling `entry[Symbol.iterator]()` on one directly. Blocks the + * event loop and further JavaScript execution until the whole archive + * (including any deflate passes) has been produced; see + * `zipEntry.contentSync()`. + * @param {Iterable} entries + * @param {string | { comment?: string, baseOffset?: number }} [options] + * @yields {Buffer} + */ +function* createZipArchiveSync(entries, options) { + const { comment, baseOffset } = normalizeArchiveOptions(options); + const commentBuffer = normalizeCommentBuffer(comment); + const centralHeaders = []; + let pos = baseOffset; + const iterator = entries[SymbolIterator](); + let completed = false; + // As in generateZipArchive(), the entries are owned here: a streaming entry + // throws when serialized synchronously, so on that throw (or an early + // return of this generator) dispose the entry that failed and every entry + // still queued behind it, releasing any sources they hold. + let current = null; + try { + for (let next = iterator.next(); !next.done; next = iterator.next()) { + current = next.value; + const start = pos; + for (const chunk of current) { + yield chunk; + pos += chunk.length; + } + ArrayPrototypePush(centralHeaders, current[kFinalize](start)); + current = null; + } + const centralDirectoryOffset = pos; + for (let i = 0; i < centralHeaders.length; i++) { + const chunk = centralHeaders[i]; + yield chunk; + pos += chunk.length; + } + const centralDirectorySize = pos - centralDirectoryOffset; + const count = centralHeaders.length; + const trailer = buildArchiveTrailer(count, centralDirectorySize, centralDirectoryOffset, commentBuffer); + for (let i = 0; i < trailer.length; i++) yield trailer[i]; + completed = true; + } finally { + if (!completed) { + if (typeof current?.[SymbolDispose] === 'function') current[SymbolDispose](); + for (let next = iterator.next(); !next.done; next = iterator.next()) { + const entry = next.value; + if (typeof entry?.[SymbolDispose] === 'function') entry[SymbolDispose](); + } + } + } +} + +module.exports = { + normalizeArchiveOptions, + createZipArchive, + createZipArchiveSync, + zipFiles, +}; diff --git a/lib/internal/zip/binary.js b/lib/internal/zip/binary.js new file mode 100644 index 000000000000..3aa6b89a5cd3 --- /dev/null +++ b/lib/internal/zip/binary.js @@ -0,0 +1,87 @@ +'use strict'; + +// Low-level binary helpers: bounds-checked archive ranges, safe 64-bit +// integer read/write, and buffer coercion of user input. + +const { + BigInt, + Number, + NumberIsInteger, +} = primordials; + +const { + codes: { + ERR_INVALID_ARG_TYPE, + ERR_ZIP_INVALID_ARCHIVE, + }, +} = require('internal/errors'); +const { + isAnyArrayBuffer, + isArrayBufferView, + isUint8Array, +} = require('internal/util/types'); +const { Buffer } = require('buffer'); +const { BIGINT_MAX_SAFE_INTEGER } = require('internal/zip/constants'); + +// Reject an [offset, offset + length) slice that escapes the archive buffer +// before it is used to read a record: guards every offset taken from archive +// bytes against corrupt or hostile values. +function validateArchiveRange(buffer, offset, length, what) { + if ( + !NumberIsInteger(offset) || + offset < 0 || + !NumberIsInteger(length) || + length < 0 || + offset + length > buffer.length + ) { + throw new ERR_ZIP_INVALID_ARCHIVE(`${what} is out of bounds`); + } +} + +// Read a little-endian u64 (sizes/offsets, Zip64) that must land in the JS +// safe-integer range; a field past the buffer or beyond that range means a +// corrupt or hostile archive. +function readSafeUint64(buffer, offset) { + if (offset + 8 > buffer.length) { + throw new ERR_ZIP_INVALID_ARCHIVE('64-bit field is out of bounds'); + } + const value = buffer.readBigUInt64LE(offset); + if (value > BIGINT_MAX_SAFE_INTEGER) { + throw new ERR_ZIP_INVALID_ARCHIVE('64-bit field exceeds the safe integer range'); + } + return Number(value); +} + +// Write a JS number as a little-endian u64; the write paths only feed values +// already bounded by the safe-integer range, so no range check is needed here. +function writeSafeUint64(buffer, offset, value) { + buffer.writeBigUInt64LE(BigInt(value), offset); +} + + +// Coerce user-supplied binary input to a Buffer, aliasing the same memory +// (no copy) for a TypedArray/DataView/ArrayBuffer and rejecting anything else. +// internal/crypto/util.js's getArrayBufferOrView() covers similar coercion but +// returns the view unchanged (and accepts strings with an encoding); this +// helper exists because the ZIP code needs an actual Buffer over that memory. +function toBuffer(value, name) { + if (isUint8Array(value)) { + return Buffer.isBuffer(value) ? + value : Buffer.from(value.buffer, value.byteOffset, value.byteLength); + } + if (isArrayBufferView(value)) { + return Buffer.from(value.buffer, value.byteOffset, value.byteLength); + } + if (isAnyArrayBuffer(value)) { + return Buffer.from(value); + } + throw new ERR_INVALID_ARG_TYPE( + name, ['Buffer', 'TypedArray', 'DataView', 'ArrayBuffer'], value); +} + +module.exports = { + validateArchiveRange, + readSafeUint64, + writeSafeUint64, + toBuffer, +}; diff --git a/lib/internal/zip/buffer.js b/lib/internal/zip/buffer.js new file mode 100644 index 000000000000..582d8ae799af --- /dev/null +++ b/lib/internal/zip/buffer.js @@ -0,0 +1,186 @@ +'use strict'; + +// `ZipBuffer`: an in-memory, writable view over the entries of an archive +// held in a `Buffer`, serializing the current set back out with +// `toBuffer()`/`toBufferSync()`. + +const { + ArrayPrototypePush, + FunctionPrototypeCall, + Map, + MapPrototypeClear, + MapPrototypeDelete, + MapPrototypeEntries, + MapPrototypeGet, + MapPrototypeGetSize, + MapPrototypeHas, + MapPrototypeKeys, + MapPrototypeSet, + SymbolDispose, + SymbolIterator, + SymbolToStringTag, +} = primordials; + +const { + codes: { + ERR_INVALID_ARG_TYPE, + ERR_ZIP_ENTRY_NOT_FOUND, + }, +} = require('internal/errors'); +const { + validateFunction, + validateString, +} = require('internal/validators'); +const { Buffer } = require('buffer'); +const { toBuffer } = require('internal/zip/binary'); +const { decodeZipText } = require('internal/zip/dos'); +const { findArchiveEnd } = require('internal/zip/headers'); +const { + readArchiveEntries, + ZipEntry, +} = require('internal/zip/entry'); +const { + createZipArchive, + createZipArchiveSync, + normalizeArchiveOptions, +} = require('internal/zip/archive'); + +/** + * An in-memory view over the entries of a ZIP archive, writable in place: + * entries can be added or removed, and `toBuffer()` serializes the current + * set of entries into a fresh archive. + */ +class ZipBuffer { + #entries = new Map(); + #comment; + + /** + * Parses an existing archive's central directory into an in-memory, + * editable map of entries keyed by name. + * @param {Buffer | TypedArray | DataView | ArrayBuffer} buffer + */ + constructor(buffer) { + const buf = toBuffer(buffer, 'buffer'); + // Locate the archive end once; it supplies both the comment and the + // central-directory bounds for the entry walk. + const end = findArchiveEnd(buf); + this.#comment = end.comment; + for (const entry of readArchiveEntries(buf, end)) { + MapPrototypeSet(this.#entries, entry.name, entry); + } + } + get writable() { return true; } + // The EOCD comment has no encoding flag; apply the same UTF-8/CP437 + // heuristic as unflagged member names and comments. + get comment() { return decodeZipText(this.#comment, 0); } + has(name) { + validateString(name, 'name'); + return MapPrototypeHas(this.#entries, name); + } + get(name) { + validateString(name, 'name'); + const entry = MapPrototypeGet(this.#entries, name); + if (entry === undefined) throw new ERR_ZIP_ENTRY_NOT_FOUND(name); + return entry; + } + /** + * Adds an already-built entry, keyed by its own name (replacing any + * existing entry of that name). + * @param {ZipEntry} entry + * @returns {ZipEntry} + */ + addEntry(entry) { + if (!(entry instanceof ZipEntry)) { + throw new ERR_INVALID_ARG_TYPE('entry', 'ZipEntry', entry); + } + MapPrototypeSet(this.#entries, entry.name, entry); + return entry; + } + /** + * Builds an entry from in-memory `data` and adds it (replacing any entry of + * the same name). + * @param {string} filename + * @param {Buffer | TypedArray | DataView | ArrayBuffer} data + * @param {{ comment?: string, mode?: number, modified?: Date, method?: 'deflate' | 'store' | 'zstd' }} [options] + * @returns {Promise} + */ + async add(filename, data, options) { + return this.addEntry(await ZipEntry.create(filename, data, options)); + } + /** + * The synchronous counterpart of `add()`. Blocks the event loop and + * further JavaScript execution until done; see `zipEntry.contentSync()`. + * @param {string} filename + * @param {Buffer | TypedArray | DataView | ArrayBuffer} data + * @param {{ comment?: string, mode?: number, modified?: Date, method?: 'deflate' | 'store' | 'zstd' }} [options] + * @returns {ZipEntry} + */ + addSync(filename, data, options) { + return this.addEntry(ZipEntry.createSync(filename, data, options)); + } + /** + * @param {string} name + * @returns {boolean} + */ + delete(name) { + validateString(name, 'name'); + return MapPrototypeDelete(this.#entries, name); + } + clear() { + MapPrototypeClear(this.#entries); + } + keys() { return MapPrototypeKeys(this.#entries); } + *values() { + for (const name of this.keys()) yield this.get(name); + } + *entries() { + for (const name of this.keys()) yield [name, this.get(name)]; + } + get size() { return MapPrototypeGetSize(this.#entries); } + [SymbolIterator]() { return this.entries(); } + get [SymbolToStringTag]() { return 'ZipBuffer'; } + forEach(callback, thisArg) { + validateFunction(callback, 'callback'); + for (const { 0: key, 1: value } of MapPrototypeEntries(this.#entries)) { + FunctionPrototypeCall(callback, thisArg === undefined ? this : thisArg, value, key, this); + } + } + /** + * Serializes the current set of entries into a fresh archive. + * @param {string | { comment?: string, baseOffset?: number }} [options] + * @returns {Promise} + */ + async toBuffer(options) { + const { comment, baseOffset } = normalizeArchiveOptions(options); + const chunks = []; + // Defaulting to the raw #comment bytes (not the decoded string) + // round-trips a non-UTF-8 archive comment unchanged. + for await (const chunk of createZipArchive(this.values(), { comment: comment ?? this.#comment, baseOffset })) { + ArrayPrototypePush(chunks, chunk); + } + return Buffer.concat(chunks); + } + /** + * The synchronous counterpart of `toBuffer()`. Blocks the event loop and + * further JavaScript execution until the whole archive has been + * serialized; see `zipEntry.contentSync()`. + * @param {string | { comment?: string, baseOffset?: number }} [options] + * @returns {Buffer} + */ + toBufferSync(options) { + const { comment, baseOffset } = normalizeArchiveOptions(options); + const chunks = []; + for (const chunk of createZipArchiveSync(this.values(), { comment: comment ?? this.#comment, baseOffset })) { + ArrayPrototypePush(chunks, chunk); + } + return Buffer.concat(chunks); + } + // Dispose: drop all entries (this view holds no fd of its own). + [SymbolDispose]() { + MapPrototypeClear(this.#entries); + } +} + +module.exports = { + ZipBuffer, +}; diff --git a/lib/internal/zip/compression.js b/lib/internal/zip/compression.js new file mode 100644 index 000000000000..fd9a7373c365 --- /dev/null +++ b/lib/internal/zip/compression.js @@ -0,0 +1,303 @@ +'use strict'; + +// Compression/decompression plumbing over the lazily-required `zlib` facade: +// one-shot (async and sync) and streaming deflate/inflate/zstd helpers, plus +// the member decoders that add method dispatch, size bounding, and CRC-32 +// verification on top. + +const { + JSONStringify, + MathMin, + Promise, +} = primordials; + +const { + codes: { + ERR_ZIP_ENTRY_CORRUPT, + ERR_ZIP_ENTRY_TOO_LARGE, + ERR_ZIP_UNSUPPORTED_FEATURE, + }, +} = require('internal/errors'); +const { kMaxLength } = require('buffer'); +const { compose } = require('stream'); +const { crc32: crc32Native } = internalBinding('zlib'); +const { + FLAG_ENCRYPTED, + METHOD_STORE, + METHOD_DEFLATE, + METHOD_ZSTD, +} = require('internal/zip/constants'); + +// `internal/zip` is required from `lib/zlib.js`, so it must not require the +// public `zlib` facade at load time (its module.exports is not yet +// populated). Compression is only needed once an entry is actually read or +// written, well after `zlib.js` has finished loading, so a lazy reference is +// enough to break the cycle. +let zlib; +function lazyZlib() { + zlib ??= require('zlib'); + return zlib; +} + +// -- compression plumbing ------------------------------------------------------ + +// The one-shot (async/sync) and streaming helpers below are thin adapters that +// promisify or stream-wrap the lazy `zlib` facade; individually trivial, they +// exist only so the rest of the module never touches `zlib` directly. + +function deflateRawAsync(buffer) { + return new Promise((resolve, reject) => { + lazyZlib().deflateRaw(buffer, (err, result) => { + if (err) reject(err); + else resolve(result); + }); + }); +} + +function inflateRawAsync(buffer, options) { + return new Promise((resolve, reject) => { + lazyZlib().inflateRaw(buffer, options, (err, result) => { + if (err) reject(err); + else resolve(result); + }); + }); +} + +// Drive `source` through a zlib transform stream, returning an async-iterable +// stream of its output. `compose()` (pipeline-backed) wires error propagation +// both ways and tears the whole chain down when the consumer errors or stops +// early, so an abandoned iteration cannot leak the pipeline. +function pumpThroughTransform(source, transform) { + return compose(source, transform); +} + +function deflateRawStream(source) { + return pumpThroughTransform(source, lazyZlib().createDeflateRaw()); +} + +function inflateRawStream(source) { + return pumpThroughTransform(source, lazyZlib().createInflateRaw()); +} + +function zstdCompressAsync(buffer) { + return new Promise((resolve, reject) => { + lazyZlib().zstdCompress(buffer, (err, result) => { + if (err) reject(err); + else resolve(result); + }); + }); +} + +function zstdDecompressAsync(buffer, options) { + return new Promise((resolve, reject) => { + lazyZlib().zstdDecompress(buffer, options, (err, result) => { + if (err) reject(err); + else resolve(result); + }); + }); +} + +function zstdCompressStream(source) { + return pumpThroughTransform(source, lazyZlib().createZstdCompress()); +} + +function zstdDecompressStream(source) { + return pumpThroughTransform(source, lazyZlib().createZstdDecompress()); +} + + +function deflateRawSync(buffer) { + return lazyZlib().deflateRawSync(buffer); +} + +function zstdCompressSync(buffer) { + return lazyZlib().zstdCompressSync(buffer); +} + +/** + * @typedef {{ + * name: string, + * flags: number, + * method: number, + * crc32: number, + * uncompressedSize: number, + * }} ZipMemberInfo + */ + +// Shared entry guards for the member decoders below: encryption and +// unsupported compression methods are rejected up front, and when the caller +// bounds the output, a declared size beyond that bound fails before anything +// is decompressed or allocated. +function assertDecodable(info, options) { + if (info.flags & FLAG_ENCRYPTED) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE( + `entry ${JSONStringify(info.name)} is encrypted`); + } + if (info.method !== METHOD_STORE && info.method !== METHOD_DEFLATE && info.method !== METHOD_ZSTD) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE( + `entry ${JSONStringify(info.name)} uses compression method ${info.method}`); + } + if (options?.maxSize !== undefined && info.uncompressedSize > options.maxSize) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + `entry ${JSONStringify(info.name)} declares ${info.uncompressedSize} bytes, ` + + `exceeding the ${options.maxSize} byte limit`); + } +} + +// Bound one-shot decompression by the declared size, not just the caller's +// limit: a member that decompresses to more than it declares is corrupt by +// definition, so there is no reason to materialize more than `declared + 1` +// bytes (the +1 makes an overrun detectable) no matter how generous `maxSize` +// is. This keeps a tiny archive from forcing a `maxSize`-sized allocation. +function outputCap(info, options) { + return MathMin( + info.uncompressedSize + 1, options?.maxSize ?? kMaxLength, kMaxLength); +} + +// Map a one-shot decompression failure to a corrupt-entry error. +// `assertDecodable()` has already ensured `declared <= maxSize`, so hitting +// the output cap always means the stream produced more than the member +// declared. +function rethrowDecodeFailure(err, info, method) { + if (err?.code === 'ERR_BUFFER_TOO_LARGE') { + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(info.name)} ` + + `${method === METHOD_DEFLATE ? 'inflates' : 'decompresses'} beyond its ` + + `declared size of ${info.uncompressedSize} bytes`); + } + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(info.name)} failed to ` + + `${method === METHOD_DEFLATE ? 'inflate' : 'decompress'}: ${err.message}`); +} + +// Enforce the declared size and (unless opted out) the CRC-32 on a fully +// decoded member. +function checkDecoded(data, info, verify) { + if (data.length !== info.uncompressedSize) { + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(info.name)} produced ${data.length} bytes, expected ` + + `${info.uncompressedSize}`); + } + if (verify && crc32Native(data, 0) !== info.crc32) { + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(info.name)} failed CRC-32 verification`); + } +} + +/** + * Decodes one member's compressed byte stream: rejects encrypted entries and + * unsupported compression methods, inflates method 8 or decompresses method + * 93 (Zstandard), enforces the declared uncompressed size and verifies + * CRC-32 (on by default). + * @param {AsyncIterable} source + * @param {ZipMemberInfo} info + * @param {{ verify?: boolean, maxSize?: number }} [options] + * @yields {Buffer} + */ +async function* decodeMemberStream(source, info, options) { + assertDecodable(info, options); + const verify = options?.verify !== false; + let produced = 0; + let state = 0; + const decoded = info.method === METHOD_DEFLATE ? inflateRawStream(source) : + info.method === METHOD_ZSTD ? zstdDecompressStream(source) : source; + for await (const chunk of decoded) { + produced += chunk.length; + if (produced > info.uncompressedSize) { + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(info.name)} inflates beyond its declared size of ` + + `${info.uncompressedSize} bytes`); + } + if (verify) state = crc32Native(chunk, state); + yield chunk; + } + if (produced !== info.uncompressedSize) { + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(info.name)} is truncated: got ${produced} of ` + + `${info.uncompressedSize} bytes`); + } + if (verify && state !== info.crc32) { + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(info.name)} failed CRC-32 verification`); + } +} + +/** + * Decodes one member held completely in `compressed`, in one shot: the same + * guards, declared-size bounding, and CRC-32 verification as + * `decodeMemberStream()`, returning the whole decoded member. For the store + * method the input buffer itself is returned - callers that must hand out + * caller-owned memory copy it (see `zipEntry.content()`). + * @param {Buffer} compressed + * @param {ZipMemberInfo} info + * @param {{ verify?: boolean, maxSize?: number }} [options] + * @returns {Promise} + */ +async function decodeMemberAsync(compressed, info, options) { + assertDecodable(info, options); + const cap = outputCap(info, options); + let data; + if (info.method === METHOD_DEFLATE) { + try { + data = await inflateRawAsync(compressed, { maxOutputLength: cap }); + } catch (err) { + rethrowDecodeFailure(err, info, METHOD_DEFLATE); + } + } else if (info.method === METHOD_ZSTD) { + try { + data = await zstdDecompressAsync(compressed, { maxOutputLength: cap }); + } catch (err) { + rethrowDecodeFailure(err, info, METHOD_ZSTD); + } + } else { + data = compressed; + } + checkDecoded(data, info, options?.verify !== false); + return data; +} + +/** + * The synchronous counterpart of `decodeMemberAsync()`. There is no public + * synchronous incremental inflate API, so - unlike the streaming path - + * `compressed` must already be the member's complete compressed byte + * stream, and the whole result is produced (and verified) in one call + * rather than yielded incrementally. + * @param {Buffer} compressed + * @param {ZipMemberInfo} info + * @param {{ verify?: boolean, maxSize?: number }} [options] + * @returns {Buffer} + */ +function decodeMemberSync(compressed, info, options) { + assertDecodable(info, options); + const cap = outputCap(info, options); + let data; + if (info.method === METHOD_DEFLATE) { + try { + data = lazyZlib().inflateRawSync(compressed, { maxOutputLength: cap }); + } catch (err) { + rethrowDecodeFailure(err, info, METHOD_DEFLATE); + } + } else if (info.method === METHOD_ZSTD) { + try { + data = lazyZlib().zstdDecompressSync(compressed, { maxOutputLength: cap }); + } catch (err) { + rethrowDecodeFailure(err, info, METHOD_ZSTD); + } + } else { + data = compressed; + } + checkDecoded(data, info, options?.verify !== false); + return data; +} + +module.exports = { + deflateRawAsync, + deflateRawSync, + zstdCompressAsync, + zstdCompressSync, + deflateRawStream, + zstdCompressStream, + decodeMemberStream, + decodeMemberAsync, + decodeMemberSync, +}; diff --git a/lib/internal/zip/constants.js b/lib/internal/zip/constants.js new file mode 100644 index 000000000000..2fe9d684b7f4 --- /dev/null +++ b/lib/internal/zip/constants.js @@ -0,0 +1,109 @@ +'use strict'; + +// Shared constants, symbols, and tiny shared values for the ZIP +// implementation. This module is a leaf: it requires nothing from the rest of +// `internal/zip`, so every other zip module can require it without cycles. + +const { + BigInt, + NumberMAX_SAFE_INTEGER, + Symbol, +} = primordials; + +const { FastBuffer } = require('internal/buffer'); + +const EMPTY_BUFFER = new FastBuffer(); +const BIGINT_MAX_SAFE_INTEGER = BigInt(NumberMAX_SAFE_INTEGER); + +// ZIP record signatures (APPNOTE.TXT, PKWARE Inc.) +const SIG_LOCAL_FILE_HEADER = 0x04034b50; // sec. 4.3.7 +const SIG_DATA_DESCRIPTOR = 0x08074b50; // sec. 4.3.9 +const SIG_CENTRAL_FILE_HEADER = 0x02014b50; // sec. 4.3.12 +const SIG_ZIP64_EOCD_RECORD = 0x06064b50; // sec. 4.3.14 +const SIG_ZIP64_EOCD_LOCATOR = 0x07064b50; // sec. 4.3.15 +const SIG_EOCD = 0x06054b50; // sec. 4.3.16 + +const MADE_BY_UNIX = 3; // sec. 4.4.2 +const ZIP64_EXTRA_ID = 0x0001; // sec. 4.5.3 + +const SENTINEL16 = 0xffff; +const SENTINEL32 = 0xffffffff; + +const FLAG_ENCRYPTED = 0x0001; // sec. 4.4.4 bit 0 +const FLAG_DATA_DESCRIPTOR = 0x0008; // bit 3 +const FLAG_UTF8 = 0x0800; // bit 11: name/comment are UTF-8 (EFS) + +const METHOD_STORE = 0; // sec. 4.4.5 +const METHOD_DEFLATE = 8; // sec. 4.4.5 +const METHOD_ZSTD = 93; // sec. 4.4.5 + +const VERSION_DEFAULT = 20; // 2.0: deflate + directories (sec. 4.4.3) +const VERSION_ZIP64 = 45; // 4.5: Zip64 structures +const VERSION_ZSTD = 63; // 6.3: Zstandard compression (method 93) + +// The Zip64 EOCD record may carry an extensible data sector of arbitrary +// length between its fixed part and the locator, so it can start before any +// fixed-size tail read. When the locator points further back than the bytes +// at hand, callers re-read from the recorded offset - but only within this +// bound, so a hostile locator cannot demand an unbounded allocation. +const ZIP64_EOCD_MAX_LENGTH = 56 + 1024 * 1024; + +const S_IFREG = 0o100000; // Unix mode type bits: regular file +const S_IFDIR = 0o040000; // Unix mode type bits: directory +const S_IFLNK = 0o120000; // Unix mode type bits: symbolic link +const S_IFMT = 0o170000; // Unix mode type mask + +// Extra-field header IDs we consult on read (sec. 4.5). +const EXTRA_ID_NTFS = 0x000a; // NTFS times (100 ns since 1601) +const EXTRA_ID_EXT_TIMESTAMP = 0x5455; // Info-ZIP extended timestamp ("UT") +const EXTRA_ID_UNIX_OLD = 0x5855; // Info-ZIP Unix, original ("UX") +const EXTRA_ID_UNICODE_PATH = 0x7075; // Info-ZIP Unicode Path ("up") + +// Passed between `ZipEntry` and the archive writers/`ZipFile`: `kFinalize` +// asks an entry for its central-directory header at a known local offset; +// `kPromote` rebinds a just-serialized streaming entry to its on-disk copy. +const kFinalize = Symbol('kFinalize'); +const kPromote = Symbol('kPromote'); + +// The chunk size for reading a file-backed member's compressed bytes. +const READ_CHUNK_SIZE = 4 * 1024 * 1024; +// EOCD + max comment + Zip64 locator + Zip64 record + slack for an +// extensible data sector: the fixed-size tail `ZipFile.open()` reads first. +const TAIL_LENGTH = 22 + SENTINEL16 + 20 + 56 + 4096; + +module.exports = { + EMPTY_BUFFER, + BIGINT_MAX_SAFE_INTEGER, + SIG_LOCAL_FILE_HEADER, + SIG_DATA_DESCRIPTOR, + SIG_CENTRAL_FILE_HEADER, + SIG_ZIP64_EOCD_RECORD, + SIG_ZIP64_EOCD_LOCATOR, + SIG_EOCD, + MADE_BY_UNIX, + ZIP64_EXTRA_ID, + SENTINEL16, + SENTINEL32, + FLAG_ENCRYPTED, + FLAG_DATA_DESCRIPTOR, + FLAG_UTF8, + METHOD_STORE, + METHOD_DEFLATE, + METHOD_ZSTD, + VERSION_DEFAULT, + VERSION_ZIP64, + VERSION_ZSTD, + ZIP64_EOCD_MAX_LENGTH, + S_IFREG, + S_IFDIR, + S_IFLNK, + S_IFMT, + EXTRA_ID_NTFS, + EXTRA_ID_EXT_TIMESTAMP, + EXTRA_ID_UNIX_OLD, + EXTRA_ID_UNICODE_PATH, + kFinalize, + kPromote, + READ_CHUNK_SIZE, + TAIL_LENGTH, +}; diff --git a/lib/internal/zip/content-size.js b/lib/internal/zip/content-size.js new file mode 100644 index 000000000000..0693e86f1568 --- /dev/null +++ b/lib/internal/zip/content-size.js @@ -0,0 +1,41 @@ +'use strict'; + +// The module-global default ceiling on in-memory member decompression, and +// its public getter/setter. Kept in its own module so every read path sees +// the one mutable value. + +const { validateInteger } = require('internal/validators'); + +// A default ceiling on the uncompressed size that the buffering read paths +// (`ZipEntry.prototype.content()`, and therefore `ZipBuffer`/`ZipFile` +// `get()`) will materialize in memory when the caller does not pass an +// explicit `maxSize`. An archive whose central directory declares a member +// larger than this is rejected before any large allocation happens. Callers +// that need larger members can either pass a per-call `maxSize` or raise the +// module default with `setMaxZipContentSize()`. The streaming read paths +// (`contentIterator()`, `ZipFile.prototype.stream()`) are bounded-memory by +// design and are not subject to this default. +const DEFAULT_MAX_ZIP_CONTENT_SIZE = 256 * 1024 * 1024; // 256 MiB +let maxZipContentSize = DEFAULT_MAX_ZIP_CONTENT_SIZE; + +/** + * @returns {number} + */ +function getMaxZipContentSize() { + return maxZipContentSize; +} + +/** + * @param {number} size + * @returns {void} + */ +function setMaxZipContentSize(size) { + validateInteger(size, 'size', 0); + maxZipContentSize = size; +} + +module.exports = { + DEFAULT_MAX_ZIP_CONTENT_SIZE, + getMaxZipContentSize, + setMaxZipContentSize, +}; diff --git a/lib/internal/zip/dos.js b/lib/internal/zip/dos.js new file mode 100644 index 000000000000..d3a41323b87e --- /dev/null +++ b/lib/internal/zip/dos.js @@ -0,0 +1,138 @@ +'use strict'; + +// DOS/IBM legacy decoding: MS-DOS date/time fields (sec. 4.4.6) and the +// historical Code Page 437 name/comment encoding, plus the modern +// UTF-8/Unicode-Path handling layered on top of them. + +const { + Date, + NumberIsNaN, + StringFromCharCode, +} = primordials; + +const { + codes: { + ERR_INVALID_ARG_VALUE, + }, +} = require('internal/errors'); +const { crc32: crc32Native } = internalBinding('zlib'); +const { isUtf8 } = internalBinding('buffer'); +const { + FLAG_UTF8, + EXTRA_ID_UNICODE_PATH, +} = require('internal/zip/constants'); +const { forEachExtraField } = require('internal/zip/extra-fields'); + +// DOS date/time (sec. 4.4.6): local time by convention. +// time: bits 0-4 seconds/2, 5-10 minutes, 11-15 hours +// date: bits 0-4 day, 5-8 month, 9-15 years since 1980 +function decodeDosDateTime(time, date) { + // A zeroed/absent date field has month 0 and day 0, both invalid; the DOS + // epoch is 1980-01-01. Month/day 0 are treated as 1 so a zero field decodes + // to 1980-01-01 (and re-encodes to the same value). + return new Date( + ((date >>> 9) & 0x7f) + 1980, + ((date >>> 5) & 0x0f || 1) - 1, + (date & 0x1f) || 1, + (time >>> 11) & 0x1f, + (time >>> 5) & 0x3f, + (time & 0x1f) * 2, + ); +} + +// Encode a Date into packed MS-DOS time/date fields (sec. 4.4.6), clamping to +// the representable range 1980-01-01 .. 2107-12-31. +function encodeDosDateTime(value) { + const year = value.getFullYear(); + if (NumberIsNaN(year)) { + throw new ERR_INVALID_ARG_VALUE('modified', value, 'must be a valid Date'); + } + if (year < 1980) return { time: 0, date: (1 << 5) | 1 }; // Clamp to 1980-01-01 00:00:00 + if (year > 2107) { + // Clamp to 2107-12-31 23:59:58 + return { + time: (23 << 11) | (59 << 5) | 29, + date: (127 << 9) | (12 << 5) | 31, + }; + } + const date = + ((year - 1980) << 9) | ((value.getMonth() + 1) << 5) | value.getDate(); + const time = + (value.getHours() << 11) | + (value.getMinutes() << 5) | + (value.getSeconds() >>> 1); + return { time, date }; +} + +// Code Page 437 high half (0x80-0xFF) -> Unicode. Names without the UTF-8 +// language-encoding flag (bit 11) are historically CP437, which every real +// tool (Info-ZIP, Windows Explorer) assumes; bytes 0x00-0x7F are ASCII. +const CP437_HIGH = [ + 0x00c7, 0x00fc, 0x00e9, 0x00e2, 0x00e4, 0x00e0, 0x00e5, 0x00e7, + 0x00ea, 0x00eb, 0x00e8, 0x00ef, 0x00ee, 0x00ec, 0x00c4, 0x00c5, + 0x00c9, 0x00e6, 0x00c6, 0x00f4, 0x00f6, 0x00f2, 0x00fb, 0x00f9, + 0x00ff, 0x00d6, 0x00dc, 0x00a2, 0x00a3, 0x00a5, 0x20a7, 0x0192, + 0x00e1, 0x00ed, 0x00f3, 0x00fa, 0x00f1, 0x00d1, 0x00aa, 0x00ba, + 0x00bf, 0x2310, 0x00ac, 0x00bd, 0x00bc, 0x00a1, 0x00ab, 0x00bb, + 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556, + 0x2555, 0x2563, 0x2551, 0x2557, 0x255d, 0x255c, 0x255b, 0x2510, + 0x2514, 0x2534, 0x252c, 0x251c, 0x2500, 0x253c, 0x255e, 0x255f, + 0x255a, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256c, 0x2567, + 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256b, + 0x256a, 0x2518, 0x250c, 0x2588, 0x2584, 0x258c, 0x2590, 0x2580, + 0x03b1, 0x00df, 0x0393, 0x03c0, 0x03a3, 0x03c3, 0x00b5, 0x03c4, + 0x03a6, 0x0398, 0x03a9, 0x03b4, 0x221e, 0x03c6, 0x03b5, 0x2229, + 0x2261, 0x00b1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00f7, 0x2248, + 0x00b0, 0x2219, 0x00b7, 0x221a, 0x207f, 0x00b2, 0x25a0, 0x00a0, +]; + +// Decode a CP437-encoded name/comment to a JS string via the table above. +function decodeCp437(buffer) { + let out = ''; + for (let i = 0; i < buffer.length; i++) { + const b = buffer[i]; + out += StringFromCharCode(b < 0x80 ? b : CP437_HIGH[b - 0x80]); + } + return out; +} + +// The UTF-8 name from an Info-ZIP Unicode Path extra field (sec. 4.6.9), but +// only when its version is 1 and its CRC-32 matches the standard-field name +// bytes (so a stale extra left over from a rename is ignored). Otherwise null. +function unicodePathName(extra, standardNameBuffer) { + let result = null; + forEachExtraField(extra, (id, body) => { + if (result !== null || id !== EXTRA_ID_UNICODE_PATH || body.length < 5) return; + if (body[0] !== 1) return; + // The crc32 binding returns an unsigned uint32, directly comparable. + if (body.readUInt32LE(1) !== crc32Native(standardNameBuffer, 0)) return; + result = body.toString('utf8', 5); + }); + return result; +} + +// Decode an entry name: prefer a valid Unicode Path extra field, else the +// UTF-8/CP437 heuristic below. +function decodeZipName(nameBuffer, flags, extra) { + if (extra?.length) { + const unicode = unicodePathName(extra, nameBuffer); + if (unicode !== null) return unicode; + } + return decodeZipText(nameBuffer, flags); +} + +// Decode a name/comment: UTF-8 when bit 11 says so, or when the bytes are +// valid UTF-8 anyway - plenty of real tools (pre-JDK7 java.util.zip among +// them) wrote UTF-8 names without ever setting the flag. Only genuinely +// non-UTF-8 bytes take the historical CP437 default. +function decodeZipText(buffer, flags) { + if ((flags & FLAG_UTF8) || isUtf8(buffer)) return buffer.toString('utf8'); + return decodeCp437(buffer); +} + +module.exports = { + decodeDosDateTime, + encodeDosDateTime, + decodeZipName, + decodeZipText, +}; diff --git a/lib/internal/zip/entry.js b/lib/internal/zip/entry.js new file mode 100644 index 000000000000..4eab161db9b2 --- /dev/null +++ b/lib/internal/zip/entry.js @@ -0,0 +1,962 @@ +'use strict'; + +// `ZipEntry`: a single archive member. Reads (buffered and streaming), +// (de)serialization, and the `create()`/`createSync()`/`createStream()`/ +// `createSymlink()` builders, plus the `createEntryMeta()` helper that +// normalizes their options into the internal metadata record. + +const { + ArrayPrototypePush, + ArrayPrototypeSlice, + ArrayPrototypeSort, + Date, + DateNow, + JSONStringify, + MathFloor, + MathMin, + NumberMAX_SAFE_INTEGER, + StringPrototypeEndsWith, + SymbolAsyncDispose, + SymbolAsyncIterator, + SymbolDispose, + SymbolIterator, +} = primordials; + +const { + codes: { + ERR_INVALID_ARG_TYPE, + ERR_INVALID_ARG_VALUE, + ERR_INVALID_STATE, + ERR_ZIP_ENTRY_TOO_LARGE, + ERR_ZIP_INVALID_ARCHIVE, + ERR_ZIP_UNSUPPORTED_FEATURE, + }, +} = require('internal/errors'); +const { + validateInteger, + validateString, + validateUint32, +} = require('internal/validators'); +const { + isDate, + isUint8Array, +} = require('internal/util/types'); +const { Buffer, kMaxLength } = require('buffer'); +const { crc32: crc32Native } = internalBinding('zlib'); +const { + EMPTY_BUFFER, + SIG_LOCAL_FILE_HEADER, + SENTINEL16, + FLAG_DATA_DESCRIPTOR, + FLAG_UTF8, + MADE_BY_UNIX, + METHOD_STORE, + METHOD_DEFLATE, + METHOD_ZSTD, + S_IFREG, + S_IFDIR, + S_IFLNK, + S_IFMT, + READ_CHUNK_SIZE, + kFinalize, + kPromote, +} = require('internal/zip/constants'); +const { + toBuffer, + validateArchiveRange, +} = require('internal/zip/binary'); +const { + extraFieldMtime, + stripZip64Extra, +} = require('internal/zip/extra-fields'); +const { + decodeZipName, + decodeZipText, +} = require('internal/zip/dos'); +const { + CentralFileHeader, + LocalFileHeader, + findArchiveEnd, +} = require('internal/zip/headers'); +const { + buildLocalHeader, + buildCentralHeader, + buildDataDescriptor64, +} = require('internal/zip/header-builders'); +const { + deflateRawAsync, + deflateRawSync, + zstdCompressAsync, + zstdCompressSync, + deflateRawStream, + zstdCompressStream, + decodeMemberStream, + decodeMemberAsync, + decodeMemberSync, +} = require('internal/zip/compression'); +const { + readFdFully, + readFdFullySync, +} = require('internal/zip/fs-util'); +const { getMaxZipContentSize } = require('internal/zip/content-size'); + +// The whole (UTC) second to record in an extended-timestamp extra field when +// `mtimeMs` cannot be represented exactly by the 2-second-resolution, +// local-time DOS date/time fields (sub-second parts and odd seconds alike), +// or null when the DOS fields suffice or the value does not fit the extra +// field's signed 32-bit Unix-seconds range (through 2038). One rule, shared +// by createEntryMeta() and #finalizeMeta() so the two cannot drift. +function extendedMtimeSeconds(mtimeMs) { + const seconds = MathFloor(mtimeMs / 1000); + return (mtimeMs % 2000 !== 0 && seconds >= -2147483648 && seconds <= 2147483647) ? + seconds : null; +} + +// Normalize the public builder options into the internal metadata record: +// name/comment bytes, the UTF-8 flag, the Unix mode packed into the external +// attributes (sec. 4.4.15), and the DOS/extended-timestamp fields. +function createEntryMeta(filename, options) { + validateString(filename, 'filename'); + const name = Buffer.from(filename, 'utf8'); + if (name.length === 0) { + throw new ERR_INVALID_ARG_VALUE('filename', filename, 'must not be empty'); + } + if (name.length > SENTINEL16) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + 'the entry name must not exceed 65535 bytes when encoded as UTF-8'); + } + let comment = EMPTY_BUFFER; + if (options?.comment !== undefined) { + validateString(options.comment, 'options.comment'); + comment = Buffer.from(options.comment, 'utf8'); + if (comment.length > SENTINEL16) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + 'the entry comment must not exceed 65535 bytes when encoded as UTF-8'); + } + } + const isSymlink = options?.symlink === true; + const isDirectory = !isSymlink && StringPrototypeEndsWith(filename, '/'); + const mode = options?.mode ?? (isSymlink ? 0o777 : isDirectory ? 0o755 : 0o644); + validateUint32(mode, 'options.mode'); + // Default to the current time at the DOS fields' 2-second resolution, so a + // default entry needs no extended-timestamp extra field (see below). + const modified = options?.modified ?? new Date(MathFloor(DateNow() / 2000) * 2000); + if (!isDate(modified)) { + throw new ERR_INVALID_ARG_TYPE('options.modified', 'Date', modified); + } + if (options?.method !== undefined && + options.method !== 'deflate' && options.method !== 'store' && options.method !== 'zstd') { + throw new ERR_INVALID_ARG_VALUE( + 'options.method', options.method, "must be 'deflate', 'store', or 'zstd'"); + } + const typeBits = isSymlink ? S_IFLNK : isDirectory ? S_IFDIR : S_IFREG; + const unixAttrs = (typeBits | (mode & 0o7777)) & SENTINEL16; + const external = ((unixAttrs << 16) | (isDirectory ? 0x10 : 0)) >>> 0; + // Record the whole (UTC) second in an extended-timestamp extra field when + // the DOS fields cannot represent the time exactly; see + // extendedMtimeSeconds(). + const extendedMtime = extendedMtimeSeconds(modified.getTime()); + return { + name, + comment, + extra: EMPTY_BUFFER, + flags: FLAG_UTF8, + method: 0, + crc: 0, + compressedSize: 0, + uncompressedSize: 0, + modified, + extendedMtime, + external, + internal: 0, + madeBy: MADE_BY_UNIX, + pending: true, + }; +} + +/** + * A single file or directory inside a ZIP archive: reading, writing, and + * (de)serializing one archive member. + */ +class ZipEntry { + #central; + #local; + #content; + #source = null; + #meta = null; + #serialized = false; + // When #fd is non-null the entry is "file-backed": it holds no content + // buffer, only a descriptor *handle* ({ fd, closed }) shared with the + // owning ZipFile plus the local-header offset, and reads its compressed + // bytes from disk on demand (see #compressedBytes/#rawChunks). The shared + // handle lets close() invalidate every outstanding entry at once, so a + // read never falls through to a bare (possibly reused) fd number. + // #contentOffset caches the resolved start of the compressed data (a + // number, not a buffer) once the local header has been read. + #fd = null; + #localOffset = -1; + #contentOffset = -1; + + /** + * @private + */ + constructor(central, local, content, fd = null, localOffset = -1) { + this.#central = central; + this.#local = local; + this.#content = content; + this.#fd = fd; + this.#localOffset = localOffset; + } + + // Whether the content is stored in compressed form, whatever the method. + get compressed() { return this.method !== METHOD_STORE; } + get rawContent() { return this.#content; } + get method() { + return this.#meta ? this.#meta.method : this.#central.compressionMethod; + } + get flags() { + return this.#meta ? this.#meta.flags : (this.#local ?? this.#central).flags; + } + get crc32() { + if (this.#meta) { + this.#assertNotPending(); + return this.#meta.crc; + } + return this.#central.crc32; + } + get name() { + // The central directory is authoritative; a mismatched local-header name + // is deliberately ignored (defends against parser-confusion attacks). + // Both branches use the same decoding (Unicode Path extra, then + // UTF-8/CP437), so serialization - which snapshots the raw bytes into + // #meta - never changes what this getter reports. + return this.#meta ? + decodeZipName(this.#meta.name, this.#meta.flags, this.#meta.extra) : + this.#central.fileName; + } + get nameBuffer() { + return this.#meta ? this.#meta.name : this.#central.fileNameBuffer; + } + get comment() { + return this.#meta ? + decodeZipText(this.#meta.comment, this.#meta.flags) : + this.#central.fileComment; + } + get size() { + if (this.#meta) { + this.#assertNotPending(); + return this.#meta.uncompressedSize; + } + return this.#central.uncompressedSize; + } + get compressedSize() { + if (this.#meta) { + this.#assertNotPending(); + return this.#meta.compressedSize; + } + return this.#central.compressedSize; + } + get modified() { + if (this.#meta) return this.#meta.modified; + // Prefer an extra-field timestamp (absolute, higher-resolution) over the + // coarse local-time DOS date/time fields when a foreign archive carries + // one; consult both the central and local headers. A file-backed entry + // starts out with only its central header, but some tools (7-Zip, for + // one) write their high-fidelity timestamp only into the local header - + // resolve it lazily (a small, one-time positioned read) rather than + // silently reporting a coarser time than the archive carries, and fall + // back to the central data when the local header cannot be read. + if (this.#fd !== null && this.#local === null) { + try { + this.#resolveLocalHeaderSync(); + } catch { + // A malformed local header fails loudly on the content read paths; + // for metadata, the central directory alone has to do. + } + } + return extraFieldMtime(this.#central.extraField, this.#local?.extraField) ?? + this.#central.lastModified; + } + get mode() { + // The external attributes' high 16 bits hold Unix permissions only when + // the entry was made by a Unix host (sec. 4.4.2/4.4.15) - the same rule + // as `CentralFileHeader.prototype.mode`, which the non-meta branch + // defers to. + if (this.#meta) { + return this.#meta.madeBy === MADE_BY_UNIX ? + (this.#meta.external >>> 16) & 0o7777 : 0; + } + return this.#central.mode; + } + get isSymlink() { + // Derived from the made-by host and the external attributes' Unix type + // bits in both branches, so it survives serialization (which preserves + // both verbatim); this also makes a fresh `createSymlink()` entry report + // itself as one. + if (this.#meta) { + return this.#meta.madeBy === MADE_BY_UNIX && + ((this.#meta.external >>> 16) & S_IFMT) === S_IFLNK; + } + return this.#central.isSymlink; + } + get isFile() { return !this.isDirectory && !this.isSymlink; } + get isDirectory() { return StringPrototypeEndsWith(this.name, '/'); } + + // Guard: reject metadata/content reads on a write-streaming entry whose + // sizes and CRC are not yet known (still pending serialization). + #assertNotPending() { + if (this.#meta?.pending) { + throw new ERR_INVALID_STATE( + 'this streaming entry has not finished serializing yet'); + } + } + + // Snapshot this (parsed) entry's central-directory data into a + // re-serializable #meta record, memoized. Needed so a round-tripped entry + // can be re-emitted from stable, extra-field-aware values rather than raw + // header bytes; clears the data-descriptor flag (see below). + #finalizeMeta() { + if (this.#meta) { + this.#assertNotPending(); + return this.#meta; + } + const central = this.#central; + // Descriptor entries (bit 3) are re-emitted with known sizes/CRC and bit + // 3 cleared: full re-serialization emits a fresh local header from this + // same record, so it never reproduces a bit-3 local header without a + // data descriptor. (This invariant does NOT hold for `ZipFile`'s + // in-place central-directory rewrite, which leaves local headers on disk + // untouched - that path re-asserts bit 3 via `[kFinalize]`; see there.) + // Sizes come from the central directory + // (Zip64-aware); Zip64 extras are regenerated as needed, and all other + // extra-field records (Unicode Path, NTFS/UT timestamps, ...) are + // preserved so a re-serialized entry keeps its name encoding and + // timestamps. + const extra = stripZip64Extra(central.extraField); + // Snapshot the resolved (extra-field-aware) time, not the raw DOS field, + // so serialization does not degrade what `modified` reports. When that + // time cannot be represented exactly in the DOS fields and no preserved + // extra record carries it, record it in an extended-timestamp extra + // (see extendedMtimeSeconds(); same rule as createEntryMeta()). + const modified = this.modified; + const extendedMtime = extraFieldMtime(extra) === null ? + extendedMtimeSeconds(modified.getTime()) : null; + const meta = { + name: central.fileNameBuffer, + comment: central.fileCommentBuffer, + extra, + flags: central.flags & ~FLAG_DATA_DESCRIPTOR, + method: central.compressionMethod, + crc: central.crc32, + compressedSize: central.compressedSize, + uncompressedSize: central.uncompressedSize, + modified, + extendedMtime, + external: central.externalFileAttributes, + internal: central.internalFileAttributes, + // Preserve the creator's host byte (sec. 4.4.2): the external + // attributes are only interpretable relative to it. + madeBy: central.version >>> 8, + pending: false, + }; + this.#meta = meta; + return meta; + } + + // The live numeric descriptor for a file-backed entry, or a clean state + // error if the ZipFile that owns it has since been closed - never a raw + // (possibly OS-reused) fd number. Returns null for an in-memory entry. + // A file-backed entry holds a descriptor *handle* ({ fd, closed }) shared + // with its ZipFile, not a bare fd, so close() is observable here. + #liveDescriptor() { + const handle = this.#fd; + if (handle === null) return null; + if (handle.closed) { + throw new ERR_INVALID_STATE( + 'cannot read a ZipEntry after its backing ZipFile has been closed'); + } + return handle.fd; + } + + // Read (and cache) this file-backed entry's local file header - whose + // length (fixed 30 bytes plus variable name/extra fields) is only known + // from the file itself, not from the central directory. Resolving it also + // yields the offset where the compressed data begins, and gives the + // `modified` getter access to a local-header-only timestamp extra. + async #resolveLocalHeader() { + if (this.#local !== null) return this.#local; + const fd = this.#liveDescriptor(); + const fixed = Buffer.allocUnsafe(30); + await readFdFully(fd, fixed, this.#localOffset); + if (fixed.readUInt32LE(0) !== SIG_LOCAL_FILE_HEADER) { + throw new ERR_ZIP_INVALID_ARCHIVE( + `entry ${JSONStringify(this.name)} has an invalid local file header`); + } + const length = LocalFileHeader.length(fixed, 0); + const full = Buffer.allocUnsafe(length); + fixed.copy(full, 0); + if (length > 30) { + await readFdFully(fd, full.subarray(30), this.#localOffset + 30); + } + this.#local = new LocalFileHeader(full, 0); + this.#contentOffset = this.#localOffset + length; + return this.#local; + } + // Sync counterpart of #resolveLocalHeader(). + #resolveLocalHeaderSync() { + if (this.#local !== null) return this.#local; + const fd = this.#liveDescriptor(); + const fixed = Buffer.allocUnsafe(30); + readFdFullySync(fd, fixed, this.#localOffset); + if (fixed.readUInt32LE(0) !== SIG_LOCAL_FILE_HEADER) { + throw new ERR_ZIP_INVALID_ARCHIVE( + `entry ${JSONStringify(this.name)} has an invalid local file header`); + } + const length = LocalFileHeader.length(fixed, 0); + const full = Buffer.allocUnsafe(length); + fixed.copy(full, 0); + if (length > 30) { + readFdFullySync(fd, full.subarray(30), this.#localOffset + 30); + } + this.#local = new LocalFileHeader(full, 0); + this.#contentOffset = this.#localOffset + length; + return this.#local; + } + // The offset where this file-backed entry's compressed data begins, reading + // the local header first if that has not been resolved yet. + async #resolveContentOffset() { + if (this.#contentOffset < 0) await this.#resolveLocalHeader(); + return this.#contentOffset; + } + // Sync counterpart of #resolveContentOffset(). + #resolveContentOffsetSync() { + if (this.#contentOffset < 0) this.#resolveLocalHeaderSync(); + return this.#contentOffset; + } + // The in-memory raw bytes, or a clean state error when there are none - a + // write-streaming entry (`createStream()`) has no readable content until it + // has been serialized into a backing archive (after which `addEntry()` + // promotes it to file-backed; see [kPromote]). + #inMemoryCompressed() { + if (this.#content === null) { + throw new ERR_INVALID_STATE( + 'the content of a streaming entry is not available for reading'); + } + return this.#content; + } + // The entry's raw (still-compressed) bytes. For an in-memory entry this is + // the entry's retained buffer - shared memory, NOT a copy: it may alias + // the source archive (`ZipEntry.read()`) or the caller's original `data` + // (`create()` when storing). For a file-backed entry it is freshly read + // from disk and caller-owned. Paths that hand these bytes out undecoded + // (the store method) must copy the in-memory case; see `content()`. + async #compressedBytes() { + if (this.#fd === null) return this.#inMemoryCompressed(); + const size = this.compressedSize; + // A member at or beyond the maximum Buffer length cannot be materialized + // in one allocation; stream it instead. (kMaxLength equals the safe + // integer ceiling on 64-bit, so a larger size fails to parse anyway.) + if (size >= kMaxLength) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + `entry ${JSONStringify(this.name)} is too large to buffer ` + + `(${size} compressed bytes); use contentIterator() instead`); + } + const start = await this.#resolveContentOffset(); + const compressed = Buffer.allocUnsafe(size); + await readFdFully(this.#liveDescriptor(), compressed, start); + return compressed; + } + // Sync counterpart of #compressedBytes(). + #compressedBytesSync() { + if (this.#fd === null) return this.#inMemoryCompressed(); + const size = this.compressedSize; + if (size >= kMaxLength) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + `entry ${JSONStringify(this.name)} is too large to buffer ` + + `(${size} compressed bytes); use contentIterator() instead`); + } + const start = this.#resolveContentOffsetSync(); + const compressed = Buffer.allocUnsafe(size); + readFdFullySync(this.#liveDescriptor(), compressed, start); + return compressed; + } + // The entry's raw compressed bytes as a bounded-memory chunk stream, read + // straight from disk (file-backed entries only). Nothing is retained. + async *#rawChunks() { + this.#liveDescriptor(); + let pos = await this.#resolveContentOffset(); + let remaining = this.compressedSize; + while (remaining > 0) { + const take = MathMin(READ_CHUNK_SIZE, remaining); + const chunk = Buffer.allocUnsafe(take); + // Re-check per chunk: the ZipFile may be closed mid-stream. + await readFdFully(this.#liveDescriptor(), chunk, pos); + pos += take; + remaining -= take; + yield chunk; + } + } + // Sync counterpart of #rawChunks(). + *#rawChunksSync() { + this.#liveDescriptor(); + let pos = this.#resolveContentOffsetSync(); + let remaining = this.compressedSize; + while (remaining > 0) { + const take = MathMin(READ_CHUNK_SIZE, remaining); + const chunk = Buffer.allocUnsafe(take); + // Re-check per chunk: the ZipFile may be closed mid-stream. + readFdFullySync(this.#liveDescriptor(), chunk, pos); + pos += take; + remaining -= take; + yield chunk; + } + } + + /** + * Reads, decompresses, and (by default) CRC-32-verifies the whole entry + * into a single `Buffer`, enforcing the declared-size and `maxSize` limits. + * @param {{ verify?: boolean, maxSize?: number }} [options] + * @returns {Promise} + */ + async content(options) { + const declared = this.size; + const maxSize = options?.maxSize ?? getMaxZipContentSize(); + if (declared > maxSize) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + `entry ${JSONStringify(this.name)} declares ${declared} bytes, ` + + `exceeding the ${maxSize} byte limit`); + } + const compressed = await this.#compressedBytes(); + const data = await decodeMemberAsync(compressed, { + name: this.name, + flags: this.flags, + method: this.method, + crc32: this.crc32, + uncompressedSize: declared, + }, { verify: options?.verify, maxSize }); + // `data === compressed` only on the store path; copy the in-memory case + // (the entry's retained buffer, see #compressedBytes()) so the result is + // caller-owned on every path. + return data === compressed && this.#fd === null ? Buffer.from(data) : data; + } + + /** + * The synchronous counterpart of `content()`. Blocks the event loop and + * further JavaScript execution until the whole entry has been read and, if + * applicable, inflated - use only where synchronous I/O is appropriate + * (for example, short-lived scripts or startup code), not in code that + * must stay responsive. + * @param {{ verify?: boolean, maxSize?: number }} [options] + * @returns {Buffer} + */ + contentSync(options) { + const declared = this.size; + const maxSize = options?.maxSize ?? getMaxZipContentSize(); + if (declared > maxSize) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + `entry ${JSONStringify(this.name)} declares ${declared} bytes, ` + + `exceeding the ${maxSize} byte limit`); + } + const compressed = this.#compressedBytesSync(); + const data = decodeMemberSync(compressed, { + name: this.name, + flags: this.flags, + method: this.method, + crc32: this.crc32, + uncompressedSize: declared, + }, { verify: options?.verify, maxSize }); + // `data === compressed` only on the store path; copy the in-memory case + // so the result is caller-owned on every path (see content()). + return data === compressed && this.#fd === null ? Buffer.from(data) : data; + } + + // The raw (still-compressed) bytes as an async iterable, without buffering + // the whole member: straight from disk for a file-backed entry, or the + // single in-memory buffer otherwise. Throws synchronously for a pending + // write-streaming entry, whose content is not yet available for reading. + #rawSource() { + if (this.#fd !== null) return this.#rawChunks(); + const content = this.#inMemoryCompressed(); + return (async function* () { + if (content.length) yield content; + })(); + } + + /** + * Yields the entry's decompressed content as a bounded-memory async + * iterator of `Buffer` chunks, decompressing on the way and (by default) + * verifying CRC-32. For a file-backed entry (from `ZipFile.get()`) the + * compressed bytes are read from disk as they are consumed and nothing is + * retained. + * @param {{ verify?: boolean, maxSize?: number }} [options] + * @returns {AsyncGenerator} + */ + contentIterator(options) { + // No default maxSize here, unlike content(): streaming is the + // bounded-memory path for arbitrarily large members, so imposing content()'s + // buffer-oriented ceiling would defeat its purpose (and cap legitimate + // multi-gigabyte reads). Output is still bounded per chunk to the declared + // uncompressed size; a caller that wants an explicit cap passes + // options.maxSize. + return decodeMemberStream(this.#rawSource(), { + name: this.name, + flags: this.flags, + method: this.method, + crc32: this.crc32, + uncompressedSize: this.size, + }, options); + } + + // Synchronous serialization: emit the local file header (sec. 4.3.7) + // followed by the raw (already-compressed) content bytes. Streaming + // (source-backed) entries cannot be serialized this way. + *[SymbolIterator]() { + if (this.#source) { + throw new ERR_INVALID_STATE('a streaming entry cannot be serialized synchronously'); + } + const meta = this.#finalizeMeta(); + yield buildLocalHeader(meta); + if (this.#fd !== null) { + yield* this.#rawChunksSync(); + } else if (this.#content?.length) { + yield this.#content; + } + } + + // Asynchronous serialization. For a write-streaming entry, drain the + // source once, computing CRC-32 and sizes on the fly and emitting a Zip64 + // data descriptor after the content (sec. 4.3.9); otherwise defer to the + // buffered/file-backed paths. + async *[SymbolAsyncIterator]() { + const source = this.#source; + if (!source) { + if (this.#fd !== null) { + yield buildLocalHeader(this.#finalizeMeta()); + yield* this.#rawChunks(); + return; + } + yield* this[SymbolIterator](); + return; + } + if (this.#serialized) { + throw new ERR_INVALID_STATE('a streaming entry can only be serialized once'); + } + this.#serialized = true; + const meta = this.#meta; + yield buildLocalHeader(meta); + let state = 0; + let uncompressedSize = 0; + let compressedSize = 0; + const counted = (async function* () { + for await (const chunk of source) { + if (!isUint8Array(chunk)) { + throw new ERR_INVALID_ARG_TYPE('chunk', 'Uint8Array', chunk); + } + if (!chunk.length) continue; + state = crc32Native(chunk, state); + uncompressedSize += chunk.length; + yield chunk; + } + })(); + const output = meta.method === METHOD_DEFLATE ? deflateRawStream(counted) : + meta.method === METHOD_ZSTD ? zstdCompressStream(counted) : counted; + for await (const chunk of output) { + compressedSize += chunk.length; + yield chunk; + } + meta.crc = state; + meta.uncompressedSize = uncompressedSize; + meta.compressedSize = compressedSize; + meta.pending = false; + yield buildDataDescriptor64(meta.crc, compressedSize, uncompressedSize); + } + + // Release the entry's write-source, if it has one. Only a streaming entry + // (`createStream()`) owns a source - a caller-supplied `AsyncIterable`, + // often a file read stream holding a descriptor - and once that entry is + // handed to `createZipArchive()` the caller can no longer reach it, so the + // archive machinery disposes it when the archive finishes or its output + // stream is destroyed early (see `generateZipArchive()`); a caller holding + // an unused streaming entry can dispose it directly. In-memory and + // file-backed entries hold no source - a file-backed entry's descriptor + // belongs to the `ZipFile`, never to the entry - so disposing them is a + // no-op. Disposal is idempotent and marks the entry spent, so a disposed + // streaming entry can no longer be serialized. + #releaseSource() { + const source = this.#source; + this.#source = null; + this.#serialized = true; + return source; + } + [SymbolDispose]() { + const source = this.#releaseSource(); + if (source === null) return; + if (typeof source.destroy === 'function') source.destroy(); + else if (typeof source.return === 'function') source.return(); + } + async [SymbolAsyncDispose]() { + const source = this.#releaseSource(); + if (source === null) return; + if (typeof source[SymbolAsyncDispose] === 'function') await source[SymbolAsyncDispose](); + else if (typeof source.destroy === 'function') source.destroy(); + else if (typeof source.return === 'function') await source.return(); + } + + /** + * Builds this entry's central-directory header (sec. 4.3.12) recording its + * local-header start; called by the archive writer once the offset is fixed. + * + * `preserveDescriptorFlag` is set by `ZipFile`'s in-place + * central-directory rewrite, which never regenerates local headers: when + * the on-disk local header advertises a data descriptor (bit 3, which + * `#finalizeMeta()` clears for full re-serialization), the rebuilt central + * header must keep advertising it too, or the two headers would contradict + * each other (sec. 4.3.12 expects them to agree). Full re-serialization + * emits a fresh, bit-3-free local header from the same meta, so there the + * cleared flag is the consistent one. + * @private + * @param {number} localOffset + * @param {boolean} [preserveDescriptorFlag] + * @returns {Buffer} + */ + [kFinalize](localOffset, preserveDescriptorFlag = false) { + validateInteger(localOffset, 'localOffset', 0, NumberMAX_SAFE_INTEGER); + const meta = this.#finalizeMeta(); + if (preserveDescriptorFlag && this.#central !== null && + (this.#central.flags & FLAG_DATA_DESCRIPTOR) !== 0) { + return buildCentralHeader( + { ...meta, flags: meta.flags | FLAG_DATA_DESCRIPTOR }, localOffset); + } + return buildCentralHeader(meta, localOffset); + } + + // Rebind a just-serialized write-streaming entry to its on-disk copy so it + // stops being dead weight: after `addEntry()`/`addEntrySync()` writes the + // entry into `fd` at `localOffset` (its local-header start), the spent + // source is dropped and the entry becomes a readable, re-serializable + // file-backed entry (valid while `fd` stays open). Only a spent stream + // entry - one with neither an in-memory buffer nor an existing backing fd - + // is promoted; in-memory and already-file-backed entries are left as they + // are. The kept `#meta` still supplies the (now-final) name/sizes/crc. + [kPromote](fd, localOffset) { + if (this.#content !== null || this.#fd !== null) return; + this.#fd = fd; + this.#localOffset = localOffset; + this.#contentOffset = -1; + this.#source = null; + this.#serialized = false; + // The descriptor flag has served its purpose: the sizes and CRC are + // known now, and the fd-backed serialization path emits plain headers + // and never writes a descriptor. Left set, a re-serialization would + // advertise (bit 3) a data descriptor that never follows - a corrupt + // archive for any reader that honors the flag. + this.#meta.flags &= ~FLAG_DATA_DESCRIPTOR; + } + + /** + * Parses an in-memory archive, walking the central directory (sec. 4.3.12) + * and each referenced local header (sec. 4.3.7), and yields one read-only + * `ZipEntry` per member. + * @param {Buffer | TypedArray | DataView | ArrayBuffer} buffer + * @yields {ZipEntry} + */ + static *read(buffer) { + const buf = toBuffer(buffer, 'buffer'); + yield* readArchiveEntries(buf, findArchiveEnd(buf)); + } + + /** + * Builds a ready-to-serialize entry from in-memory `data`, compressing it + * with the chosen method (falling back to store when that does not shrink + * it) and recording the CRC-32 and sizes. When the entry ends up stored + * (explicitly or via the fallback) it retains `data`'s memory rather than + * copying it, and the CRC-32 is recorded now - mutating `data` afterwards + * would corrupt the entry on write. + * @param {string} filename + * @param {Buffer | TypedArray | DataView | ArrayBuffer} data + * @param {{ + * comment?: string, + * mode?: number, + * modified?: Date, + * method?: 'deflate' | 'store' | 'zstd', + * }} [options] + * @returns {Promise} + */ + // Shared head of create()/createSync(): validate, snapshot the CRC-32 and + // uncompressed size, and pick the compression method (store is forced for + // directories and empty content). The compression pass itself is the only + // thing the async/sync builders do differently. + static #prepareCreate(filename, data, options) { + const meta = createEntryMeta(filename, options); + const content = toBuffer(data, 'data'); + const isDirectory = StringPrototypeEndsWith(filename, '/'); + if (isDirectory && content.length) { + throw new ERR_INVALID_ARG_VALUE('data', data, 'must be empty for a directory entry'); + } + meta.crc = crc32Native(content, 0); + meta.uncompressedSize = content.length; + const method = + isDirectory || content.length === 0 || options?.method === 'store' ? METHOD_STORE : + options?.method === 'zstd' ? METHOD_ZSTD : METHOD_DEFLATE; + return { meta, content, method }; + } + + // Shared tail of create()/createSync(): keep the compressed bytes only + // when the pass actually shrank the content (otherwise store the original, + // which also retains the caller's memory - see create()'s JSDoc), fill in + // the final sizes, and construct the entry. `compressed` is null when + // `method` is store. + static #finishCreate(meta, content, method, compressed) { + let finalContent = content; + if (compressed !== null && compressed.length < content.length) { + finalContent = compressed; + } else if (compressed !== null) { + method = METHOD_STORE; // Compression did not help; fall back to storing + } + meta.method = method; + meta.compressedSize = finalContent.length; + meta.pending = false; + const entry = new ZipEntry(null, null, finalContent); + entry.#meta = meta; + return entry; + } + + static async create(filename, data, options) { + const { meta, content, method } = ZipEntry.#prepareCreate(filename, data, options); + const compressed = + method === METHOD_DEFLATE ? await deflateRawAsync(content) : + method === METHOD_ZSTD ? await zstdCompressAsync(content) : null; + return ZipEntry.#finishCreate(meta, content, method, compressed); + } + + /** + * The synchronous counterpart of `create()`. Blocks the event loop and + * further JavaScript execution until done (including the deflate pass); + * see `contentSync()`. Retains `data`'s memory when storing, same as + * `create()`. + * @param {string} filename + * @param {Buffer | TypedArray | DataView | ArrayBuffer} data + * @param {{ + * comment?: string, + * mode?: number, + * modified?: Date, + * method?: 'deflate' | 'store' | 'zstd', + * }} [options] + * @returns {ZipEntry} + */ + static createSync(filename, data, options) { + const { meta, content, method } = ZipEntry.#prepareCreate(filename, data, options); + const compressed = + method === METHOD_DEFLATE ? deflateRawSync(content) : + method === METHOD_ZSTD ? zstdCompressSync(content) : null; + return ZipEntry.#finishCreate(meta, content, method, compressed); + } + + /** + * Builds a write-streaming entry whose content is drained from `source` + * only at serialization time; its sizes/CRC are unknown until then, so it + * sets the data-descriptor flag (bit 3, sec. 4.3.9) and stays pending. + * @param {string} filename + * @param {AsyncIterable} source + * @param {{ comment?: string, mode?: number, modified?: Date, method?: 'deflate' | 'store' | 'zstd' }} [options] + * @returns {ZipEntry} + */ + static createStream(filename, source, options) { + const meta = createEntryMeta(filename, options); + if (StringPrototypeEndsWith(filename, '/')) { + throw new ERR_INVALID_ARG_VALUE('filename', filename, 'a directory entry cannot be streamed'); + } + meta.flags |= FLAG_DATA_DESCRIPTOR; + meta.method = options?.method === 'store' ? METHOD_STORE : + options?.method === 'zstd' ? METHOD_ZSTD : METHOD_DEFLATE; + meta.pending = true; + const entry = new ZipEntry(null, null, null); + entry.#meta = meta; + entry.#source = source; + return entry; + } + + /** + * Creates a symbolic-link entry: a stored entry whose content is the link + * target and whose Unix mode type bits are `S_IFLNK`. + * @param {string} filename + * @param {string} target The link target path. + * @param {{ comment?: string, mode?: number, modified?: Date }} [options] + * @returns {ZipEntry} + */ + static createSymlink(filename, target, options) { + validateString(target, 'target'); + const meta = createEntryMeta(filename, { + __proto__: null, + comment: options?.comment, + mode: options?.mode, + modified: options?.modified, + symlink: true, + }); + const content = Buffer.from(target, 'utf8'); + meta.crc = crc32Native(content, 0); + meta.uncompressedSize = content.length; + meta.method = METHOD_STORE; + meta.compressedSize = content.length; + meta.pending = false; + const entry = new ZipEntry(null, null, content); + entry.#meta = meta; + return entry; + } +} + +// Walk the central directory described by `end` (a findArchiveEnd() result +// for `buf`), yielding one read-only ZipEntry per member. Split from +// `ZipEntry.read()` so `ZipBuffer` - which also needs the archive-end record +// for its comment - can locate the archive end once and share the result +// instead of scanning for it twice. All records are parsed and their member +// ranges cross-checked before the first entry is yielded: members must be +// disjoint and precede the central directory, or one small file could quote +// the same data region from N records - the "quoted overlap" zip-bomb shape +// (CVE-2024-0450 in other implementations). Here the local headers have +// been read, so the check uses each member's exact end (`ZipFile` applies +// the same rule with a lower bound; see `validateMemberBounds()`). +function* readArchiveEntries(buf, end) { + let pos = end.centralDirectoryOffset; + const cdEnd = end.centralDirectoryOffset + end.centralDirectorySize; + const parsed = []; + for (let index = 0; index < end.totalRecords; index++) { + const central = new CentralFileHeader(buf, pos); + if (pos + central.byteLength > cdEnd) { + throw new ERR_ZIP_INVALID_ARCHIVE('central directory header is out of bounds'); + } + if (central.diskNumber !== 0) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported'); + } + const localOffset = central.localFileHeaderOffset + end.prefix; + const local = new LocalFileHeader(buf, localOffset); + const dataStart = localOffset + local.byteLength; + const length = central.compressedSize; + validateArchiveRange(buf, dataStart, length, 'entry data'); + const content = length ? buf.subarray(dataStart, dataStart + length) : EMPTY_BUFFER; + ArrayPrototypePush(parsed, { + entry: new ZipEntry(central, local, content), + start: localOffset, + dataEnd: dataStart + length, + }); + pos = central.byteOffset + central.byteLength; + } + // Sort a separate range list; entries themselves are yielded in central + // directory order. + const ranges = ArrayPrototypeSort(ArrayPrototypeSlice(parsed), (a, b) => a.start - b.start); + for (let i = 0; i < ranges.length; i++) { + const bound = i + 1 < ranges.length ? ranges[i + 1].start : end.centralDirectoryOffset; + if (ranges[i].dataEnd > bound) { + throw new ERR_ZIP_INVALID_ARCHIVE( + `entry ${JSONStringify(ranges[i].entry.name)} overlaps the next ` + + 'entry or the central directory (possible zip bomb)'); + } + } + for (let i = 0; i < parsed.length; i++) yield parsed[i].entry; +} + +module.exports = { + createEntryMeta, + readArchiveEntries, + ZipEntry, +}; diff --git a/lib/internal/zip/extra-fields.js b/lib/internal/zip/extra-fields.js new file mode 100644 index 000000000000..0816268411f1 --- /dev/null +++ b/lib/internal/zip/extra-fields.js @@ -0,0 +1,199 @@ +'use strict'; + +// TLV extra-field parsing and building (sec. 4.5): the generic record walker, +// the Zip64 extended-information field, modification-time extras +// (NTFS/UT/UX), the round-trip-preserving strip of Zip64 records, and the +// extended-timestamp builder used on the write path. + +const { + ArrayPrototypePush, + Date, + Number, +} = primordials; + +const { + codes: { + ERR_ZIP_INVALID_ARCHIVE, + }, +} = require('internal/errors'); +const { Buffer } = require('buffer'); +const { + EMPTY_BUFFER, + ZIP64_EXTRA_ID, + EXTRA_ID_NTFS, + EXTRA_ID_EXT_TIMESTAMP, + EXTRA_ID_UNIX_OLD, +} = require('internal/zip/constants'); +const { readSafeUint64 } = require('internal/zip/binary'); + +// Zip64 extended information extra field (sec. 4.5.3). Walks the TLV records +// itself, and unlike forEachExtraField() below it throws on a malformed +// record: it only runs when a classic header field holds an overflow +// sentinel, so the Zip64 record is required and a malformed extra field +// hides required data. +function parseZip64Extra(extra, want) { + const wanted = + want.uncompressedSize || + want.compressedSize || + want.localFileHeaderOffset || + want.diskNumber; + if (!wanted) return {}; + let pos = 0; + while (pos + 4 <= extra.length) { + const id = extra.readUInt16LE(pos); + const size = extra.readUInt16LE(pos + 2); + if (pos + 4 + size > extra.length) { + throw new ERR_ZIP_INVALID_ARCHIVE('extra field is malformed'); + } + if (id === ZIP64_EXTRA_ID) { + const result = {}; + const body = pos + 4; + const end = body + size; + // APPNOTE 4.5.3: the field order is fixed - uncompressed size (8), + // compressed size (8), local header offset (8), disk number (4) - and + // each field MUST appear only when the corresponding classic field + // holds its overflow sentinel. When the record length matches exactly + // the fields the sentinels call for, parse them packed per spec. + // Real-world writers violate the "only" rule and emit fields for + // non-sentinel values too (commonly all four); such a record is longer + // than the wanted set, and is instead parsed positionally against the + // full layout - the fixed order makes both readings unambiguous. + const wantedSize = + (want.uncompressedSize ? 8 : 0) + + (want.compressedSize ? 8 : 0) + + (want.localFileHeaderOffset ? 8 : 0) + + (want.diskNumber ? 4 : 0); + const packed = size === wantedSize; + let cursor = body; + const take = (fullLayoutOffset, bytes) => { + const at = packed ? cursor : body + fullLayoutOffset; + if (at + bytes > end) { + throw new ERR_ZIP_INVALID_ARCHIVE( + 'the Zip64 extended information extra field is truncated'); + } + cursor += bytes; + return bytes === 8 ? readSafeUint64(extra, at) : extra.readUInt32LE(at); + }; + if (want.uncompressedSize) result.uncompressedSize = take(0, 8); + if (want.compressedSize) result.compressedSize = take(8, 8); + if (want.localFileHeaderOffset) result.localFileHeaderOffset = take(16, 8); + if (want.diskNumber) result.diskNumber = take(24, 4); + return result; + } + pos += 4 + size; + } + throw new ERR_ZIP_INVALID_ARCHIVE( + 'a field is 0xFFFFFFFF but the Zip64 extended information extra field is missing'); +} + +// Walk TLV extra-field records - id(2), size(2), body(size) - invoking `cb` +// per record. Stops silently at the first malformed/overrunning record - +// deliberately laxer than parseZip64Extra() above: this walker only feeds +// advisory metadata (timestamps, Unicode names), and a malformed extra +// should not make the archive unreadable. +function forEachExtraField(extra, cb) { + let pos = 0; + while (pos + 4 <= extra.length) { + const id = extra.readUInt16LE(pos); + const size = extra.readUInt16LE(pos + 2); + if (pos + 4 + size > extra.length) break; + cb(id, extra.subarray(pos + 4, pos + 4 + size)); + pos += 4 + size; + } +} + +// The mtime from an NTFS extra field (id 0x000a, sec. 4.5.5): a reserved +// dword then tagged sub-records; tag 1 carries the FILETIME mtime/atime/ctime. +function parseNtfsMtime(body) { + let result = null; + let pos = 4; // Skip the reserved dword. + while (pos + 4 <= body.length) { + const tag = body.readUInt16LE(pos); + const size = body.readUInt16LE(pos + 2); + if (pos + 4 + size > body.length) break; + if (tag === 1 && size >= 8) { + // Windows FILETIME: 100 ns ticks since 1601-01-01 UTC. + const ticks = body.readBigUInt64LE(pos + 4); + result = new Date(Number(ticks / 10000n) - 11644473600000); + } + pos += 4 + size; + } + return result; +} + +// The mtime from an Info-ZIP extended timestamp extra field ("UT", 0x5455; +// listed in APPNOTE's third-party ID table sec. 4.6.1, format defined in +// Info-ZIP's extrafld.txt). +function parseExtTimestampMtime(body) { + // flags(1) then present times; bit 0 => mtime present (signed Unix seconds). + if (body.length < 5 || (body[0] & 1) === 0) return null; + return new Date(body.readInt32LE(1) * 1000); +} + +// The mtime from an Info-ZIP original Unix extra field ("UX", id 0x5855). +function parseUnixOldMtime(body) { + // atime(4), mtime(4) as signed Unix seconds (uid/gid follow only locally). + if (body.length < 8) return null; + return new Date(body.readInt32LE(4) * 1000); +} + +// The highest-fidelity modification time carried in the given extra fields, or +// null when none is present. NTFS (100 ns) beats the extended timestamp (1 s, +// UTC) beats Info-ZIP Unix (1 s); all are absolute instants, unlike the coarse +// local-time DOS date/time fields. +function extraFieldMtime(...extras) { + let ntfs = null; + let ext = null; + let unix = null; + for (const extra of extras) { + if (!extra?.length) continue; + forEachExtraField(extra, (id, body) => { + if (id === EXTRA_ID_NTFS) ntfs ??= parseNtfsMtime(body); + else if (id === EXTRA_ID_EXT_TIMESTAMP) ext ??= parseExtTimestampMtime(body); + else if (id === EXTRA_ID_UNIX_OLD) unix ??= parseUnixOldMtime(body); + }); + } + return ntfs ?? ext ?? unix; +} + +// A copy of `extra` without any Zip64 record (id 0x0001): Zip64 data is +// regenerated from the final sizes/offsets on serialization, but every other +// record (Unicode Path, NTFS/UT timestamps, ...) must survive a round trip - +// dropping them silently renames entries whose display name lives in the +// Unicode Path extra and discards high-fidelity timestamps. +function stripZip64Extra(extra) { + if (!extra.length) return EMPTY_BUFFER; + const parts = []; + let total = 0; + forEachExtraField(extra, (id, body) => { + if (id === ZIP64_EXTRA_ID) return; + const record = Buffer.allocUnsafe(4 + body.length); + record.writeUInt16LE(id, 0); + record.writeUInt16LE(body.length, 2); + body.copy(record, 4); + ArrayPrototypePush(parts, record); + total += record.length; + }); + if (parts.length === 0) return EMPTY_BUFFER; + return Buffer.concat(parts, total); +} + +// Info-ZIP extended timestamp extra field ("UT", 0x5455; see +// parseExtTimestampMtime() above for the format's provenance): a flags byte +// (bit 0 = modification time present) followed by the whole (UTC) second. +function buildExtTimestampExtra(seconds) { + const buffer = Buffer.allocUnsafe(9); + buffer.writeUInt16LE(EXTRA_ID_EXT_TIMESTAMP, 0); + buffer.writeUInt16LE(5, 2); + buffer.writeUInt8(0x01, 4); + buffer.writeInt32LE(seconds, 5); + return buffer; +} + +module.exports = { + parseZip64Extra, + forEachExtraField, + extraFieldMtime, + stripZip64Extra, + buildExtTimestampExtra, +}; diff --git a/lib/internal/zip/file.js b/lib/internal/zip/file.js new file mode 100644 index 000000000000..b43c091e8630 --- /dev/null +++ b/lib/internal/zip/file.js @@ -0,0 +1,780 @@ +'use strict'; + +// `ZipFile`: random-access, in-place-writable view over an archive on disk +// (a raw fd), plus `buildCentralDirectoryChunks()` which rebuilds the central +// directory (and its Zip64/EOCD trailer) for the in-place rewrite path. + +const { + ArrayPrototypePush, + ArrayPrototypeSort, + FunctionPrototypeCall, + JSONStringify, + Map, + MapPrototypeClear, + MapPrototypeDelete, + MapPrototypeEntries, + MapPrototypeGet, + MapPrototypeGetSize, + MapPrototypeHas, + MapPrototypeKeys, + MapPrototypeSet, + MathMin, + PromisePrototypeThen, + PromiseResolve, + SymbolAsyncDispose, + SymbolAsyncIterator, + SymbolDispose, + SymbolIterator, + SymbolToStringTag, +} = primordials; + +const { + codes: { + ERR_INVALID_ARG_TYPE, + ERR_INVALID_STATE, + ERR_ZIP_ARCHIVE_TOO_LARGE, + ERR_ZIP_ENTRY_NOT_FOUND, + ERR_ZIP_INVALID_ARCHIVE, + ERR_ZIP_NOT_WRITABLE, + }, +} = require('internal/errors'); +const { + validateBoolean, + validateFunction, + validateString, +} = require('internal/validators'); +const { Buffer, kMaxLength } = require('buffer'); +const { Readable } = require('stream'); +const fs = require('fs'); +const { + SIG_LOCAL_FILE_HEADER, + TAIL_LENGTH, + kFinalize, + kPromote, +} = require('internal/zip/constants'); +const { + buildArchiveTrailer, +} = require('internal/zip/header-builders'); +const { + CentralFileHeader, + LocalFileHeader, + findArchiveEnd, + readCentralDirectory, +} = require('internal/zip/headers'); +const { decodeZipText } = require('internal/zip/dos'); +const { + fsOpenAsync, + fsCloseAsync, + fsFstatAsync, + fsFtruncateAsync, + readFdFully, + readFdFullySync, + writeFdFully, + writeFdFullySync, +} = require('internal/zip/fs-util'); +const { ZipEntry } = require('internal/zip/entry'); +const { + createZipArchive, + createZipArchiveSync, +} = require('internal/zip/archive'); + +/** + * Builds a fresh central directory (sec. 4.3.12) plus its trailer (the Zip64 + * end record/locator when needed and the end-of-central-directory record; + * see `buildArchiveTrailer()`) for `records`, an array of + * `{ entry, localOffset }` pairs already in their final order and at their + * final (possibly pre-existing, possibly freshly written) offsets. + * @param {Array<{ entry: ZipEntry, localOffset: number }>} records + * @param {number} centralDirectoryOffset + * @param {Buffer} comment + * @returns {{ centralHeaders: Buffer[], chunks: Buffer[] }} + */ +function buildCentralDirectoryChunks(records, centralDirectoryOffset, comment) { + const centralHeaders = []; + let centralDirectorySize = 0; + for (let i = 0; i < records.length; i++) { + // `true`: this rewrite leaves local headers on disk untouched, so an + // entry whose local header advertises a data descriptor (bit 3) must + // keep advertising it in the rebuilt central header; see [kFinalize]. + const header = records[i].entry[kFinalize](records[i].localOffset, true); + ArrayPrototypePush(centralHeaders, header); + centralDirectorySize += header.length; + } + const count = records.length; + const chunks = []; + for (let i = 0; i < centralHeaders.length; i++) ArrayPrototypePush(chunks, centralHeaders[i]); + const trailer = buildArchiveTrailer(count, centralDirectorySize, centralDirectoryOffset, comment); + for (let i = 0; i < trailer.length; i++) ArrayPrototypePush(chunks, trailer[i]); + return { centralHeaders, chunks }; +} + +// Shared post-location validation for open()/openSync(): the archive tail +// has been found; make sure the central directory it describes can actually +// be buffered and lies inside the file. +function checkArchiveEnd(end, size) { + if (end.centralDirectorySize > kMaxLength) { + throw new ERR_ZIP_ARCHIVE_TOO_LARGE('the central directory is too large to buffer'); + } + if (end.centralDirectoryOffset + end.centralDirectorySize > size) { + throw new ERR_ZIP_INVALID_ARCHIVE('central directory is out of bounds'); + } +} + +// Given the fixed 30-byte local header read at `offset`, return where the +// member's data ends. The data starts after the full local header (fixed + +// file name + extra field), whose length lives only in the local header, not +// the central directory - so the exact end can only be measured by reading it. +// A malformed local header (bad signature) is not rejected here: fall back to +// the 30-byte minimum so the archive stays openable (a read of that entry will +// surface the error) while the member is still bounded by a safe lower bound. +// Well-formed headers - the case an overlap attack must use to be readable - +// are measured exactly, matching the read path and the in-memory reader. +function localHeaderEnd(fixed, offset, compressedSize) { + if (fixed.readUInt32LE(0) !== SIG_LOCAL_FILE_HEADER) { + return offset + 30 + compressedSize; + } + return offset + LocalFileHeader.length(fixed, 0) + compressedSize; +} + +// Reject any member whose actual compressed bytes cannot lie inside the file, +// and any pair of members whose data ranges overlap each other or the central +// directory. The buffered read paths (`ZipEntry.prototype.content()` and +// friends) allocate `compressedSize` bytes before reading, so without the +// bounds check a tiny file whose central directory lies about a member's size +// could force an allocation of up to `kMaxLength` bytes. The overlap check +// counters the "quoted overlap" zip-bomb shape (CVE-2024-0450 in other +// implementations): N records quoting the same data region turn one small file +// into N full-size extractions, while real archives lay members out +// disjointly. The exact data range depends on the local header's length, which +// is read here (one small read per member) so the check matches the read path +// and the in-memory reader (`readArchiveEntries()`) rather than trusting a +// looser lower bound. +async function validateMemberBounds(fd, headers, prefix, size, centralDirectoryOffset) { + const members = []; + for (let i = 0; i < headers.length; i++) { + const header = headers[i]; + const offset = header.localFileHeaderOffset + prefix; + const fixed = Buffer.allocUnsafe(30); + await readFdFully(fd, fixed, offset); + const dataEnd = localHeaderEnd(fixed, offset, header.compressedSize); + checkMemberRange(members, header.fileName, offset, dataEnd, size); + } + checkMemberOverlap(members, centralDirectoryOffset); +} + +// Sync counterpart of validateMemberBounds(). +function validateMemberBoundsSync(fd, headers, prefix, size, centralDirectoryOffset) { + const members = []; + for (let i = 0; i < headers.length; i++) { + const header = headers[i]; + const offset = header.localFileHeaderOffset + prefix; + const fixed = Buffer.allocUnsafe(30); + readFdFullySync(fd, fixed, offset); + const dataEnd = localHeaderEnd(fixed, offset, header.compressedSize); + checkMemberRange(members, header.fileName, offset, dataEnd, size); + } + checkMemberOverlap(members, centralDirectoryOffset); +} + +// Record a member's [offset, dataEnd) range, rejecting one that runs past the +// end of the file. +function checkMemberRange(members, fileName, offset, dataEnd, size) { + if (dataEnd > size) { + throw new ERR_ZIP_INVALID_ARCHIVE( + `entry ${JSONStringify(fileName)} data is out of bounds`); + } + ArrayPrototypePush(members, { fileName, offset, dataEnd }); +} + +// Sorted by local-header start, reject any member whose data runs into the +// next member's local header or into the central directory. +function checkMemberOverlap(members, centralDirectoryOffset) { + ArrayPrototypeSort(members, (a, b) => a.offset - b.offset); + for (let i = 0; i < members.length; i++) { + const bound = i + 1 < members.length ? members[i + 1].offset : centralDirectoryOffset; + if (members[i].dataEnd > bound) { + throw new ERR_ZIP_INVALID_ARCHIVE( + `entry ${JSONStringify(members[i].fileName)} overlaps the next ` + + 'entry or the central directory (possible zip bomb)'); + } + } +} + +/** + * A random-access view over the entries of a ZIP archive on disk. Only the + * archive tail and central directory are read up front; individual member + * content is read lazily and on demand. Writable when opened with + * `{ writable: true }`: adding or deleting an entry rewrites the central + * directory in place, appending new entry content where the old central + * directory used to be. + * + * Every method has a `*Sync` counterpart. The synchronous methods block the + * Node.js event loop and further JavaScript execution until the operation + * completes - use them only where synchronous I/O is appropriate (for + * example, short-lived scripts or startup code), never in code that must + * stay responsive. A synchronous method throws `ERR_INVALID_STATE` if called + * while an asynchronous `addEntry()`/`add()`/`delete()`/`close()` on the same + * `ZipFile` has not settled yet, since letting the two interleave could + * corrupt the archive. + */ +class ZipFile { + // Shared descriptor handle ({ fd, closed }) handed to every ZipEntry this + // archive produces, so close() can invalidate them all at once. `#closing` + // is set synchronously the moment close()/closeSync() is called, gating any + // further public call; `handle.closed` is set once the fd is actually gone, + // gating reads through already-handed-out entries. Neither read ever falls + // through to a bare (possibly OS-reused) descriptor number. + #handle; + #closing = false; + #closePromise = null; + #writable; + #comment; + #centralDirectoryOffset; + #entries = new Map(); + #queue = PromiseResolve(); + #pendingAsyncOps = 0; + + /** + * Builds the by-name entry map from the already-parsed central headers, + * recording each member's local-header offset (adjusted by any prefix). + * @private + */ + constructor(fd, centralHeaders, prefix, centralDirectoryOffset, comment, writable) { + this.#handle = { fd, closed: false }; + this.#writable = writable; + this.#comment = comment; + this.#centralDirectoryOffset = centralDirectoryOffset; + for (let i = 0; i < centralHeaders.length; i++) { + const central = centralHeaders[i]; + MapPrototypeSet(this.#entries, central.fileName, { + central, + entry: undefined, + localOffset: central.localFileHeaderOffset + prefix, + }); + } + } + get writable() { return this.#writable; } + // The EOCD comment has no encoding flag; apply the same UTF-8/CP437 + // heuristic as unflagged member names and comments. + get comment() { return decodeZipText(this.#comment, 0); } + // Guard: throw unless this archive was opened writable. + #assertWritable() { + if (!this.#writable) throw new ERR_ZIP_NOT_WRITABLE(); + } + // Guard: reject any operation once close() has been initiated, so a + // request never reaches a closed (or reused) descriptor. Set synchronously + // by close()/closeSync() so an add() issued after them cannot slip through. + #assertOpen() { + if (this.#closing) { + throw new ERR_INVALID_STATE('the ZipFile has been closed'); + } + } + // Guard: reject a synchronous call while an async mutation is still in + // flight, since interleaving the two could corrupt the archive. + #assertNotBusy() { + if (this.#pendingAsyncOps > 0) { + throw new ERR_INVALID_STATE( + 'cannot call a synchronous ZipFile method while an asynchronous ' + + 'add(), addEntry(), delete(), or close() call has not settled yet'); + } + } + // Serialize async mutations (add/delete/close) through a promise chain so + // they never overlap and corrupt the archive; the in-flight counter backs + // #assertNotBusy(). Failures do not break the chain (both arms continue it). + #enqueue(fn) { + this.#pendingAsyncOps++; + const run = async () => { + try { + return await fn(); + } finally { + this.#pendingAsyncOps--; + } + }; + const result = PromisePrototypeThen(this.#queue, run, run); + this.#queue = PromisePrototypeThen(result, () => undefined, () => undefined); + return result; + } + has(name) { + this.#assertOpen(); + validateString(name, 'name'); + return MapPrototypeHas(this.#entries, name); + } + // Return the lazy, file-backed ZipEntry handle for `info`, creating and + // caching it on first access. The handle stores only a descriptor and the + // local-header offset - never the member's content - so repeated `get()`s + // return the same lightweight object and no content buffer is retained by + // the ZipFile. Any read (`content()`, `contentIterator()`) goes to disk. + #handleFor(info) { + info.entry ??= new ZipEntry(info.central, null, null, this.#handle, info.localOffset); + return info.entry; + } + /** + * Returns a lazy, file-backed `ZipEntry` for `name`. Nothing is read from + * disk here and no content is buffered; the entry reads (and, for + * `content()`, decompresses) straight from the file on each access. The + * returned entry is valid only while this `ZipFile` is open. + * @param {string} name + * @returns {Promise} + */ + async get(name) { + this.#assertOpen(); + validateString(name, 'name'); + const info = MapPrototypeGet(this.#entries, name); + if (info === undefined) throw new ERR_ZIP_ENTRY_NOT_FOUND(name); + return this.#handleFor(info); + } + /** + * The synchronous counterpart of `get()`. Like `get()`, it reads nothing + * up front and buffers no content - it only builds the lazy handle - so it + * does not itself block on I/O; see the class-level note on synchronous + * methods for reads performed later through the returned entry. + * @param {string} name + * @returns {ZipEntry} + */ + getSync(name) { + this.#assertOpen(); + this.#assertNotBusy(); + validateString(name, 'name'); + const info = MapPrototypeGet(this.#entries, name); + if (info === undefined) throw new ERR_ZIP_ENTRY_NOT_FOUND(name); + return this.#handleFor(info); + } + /** + * Streams a member's decoded content without buffering the whole member, + * as a `Readable` (verifying CRC-32 by default; `{ verify: false }` to opt + * out). Sugar for wrapping `get(name).contentIterator(options)`; the + * compressed bytes are read from disk as the stream is consumed. + * @param {string} name + * @param {{ verify?: boolean, maxSize?: number }} [options] + * @returns {Promise} + */ + async stream(name, options) { + const entry = await this.get(name); + return Readable.from(entry.contentIterator(options), { objectMode: false }); + } + /** + * Writes `entry`'s serialized bytes where the central directory currently + * starts, then rewrites the central directory to include it. Replaces any + * existing entry of the same name (its bytes become dead space, reclaimed + * by `compact()`). + * @param {ZipEntry} entry + * @returns {Promise} + */ + async addEntry(entry) { + this.#assertWritable(); + this.#assertOpen(); + if (!(entry instanceof ZipEntry)) { + throw new ERR_INVALID_ARG_TYPE('entry', 'ZipEntry', entry); + } + return this.#enqueue(() => this.#doAdd(entry)); + } + /** + * Builds an entry from in-memory `data` and appends it; see `addEntry()`. + * @param {string} filename + * @param {Buffer | TypedArray | DataView | ArrayBuffer} data + * @param {{ comment?: string, mode?: number, modified?: Date, method?: 'deflate' | 'store' | 'zstd' }} [options] + * @returns {Promise} + */ + async add(filename, data, options) { + this.#assertWritable(); + this.#assertOpen(); + // Reserve the mutation synchronously (before the async ZipEntry.create()), + // so a close()/closeSync() issued right after cannot slip in front of it + // and tear down the descriptor this write depends on. + return this.#enqueue(async () => + this.#doAdd(await ZipEntry.create(filename, data, options))); + } + // Append the entry's bytes where the central directory currently starts, + // then rewrite the directory to include it. On write failure, restore the + // original directory (the partial write may have clobbered it) and rethrow; + // on success, promote a spent stream entry to its on-disk copy. + async #doAdd(entry) { + const localOffset = this.#centralDirectoryOffset; + let written = 0; + try { + for await (const chunk of entry) { + await writeFdFully(this.#handle.fd, chunk, localOffset + written); + written += chunk.length; + } + } catch (err) { + // The failed entry's bytes start where the old central directory + // started, so part of it may already be overwritten - while the EOCD + // still points at it. Nothing has been adopted into memory yet, so + // rebuild and rewrite the directory (and EOCD) at its original offset + // to leave the archive exactly as it was before the call. + try { + await this.#rewriteCentralDirectory(); + } catch { + // Restoring failed too (the device is likely full or gone); the + // original error is the actionable one. + } + throw err; + } + this.#centralDirectoryOffset = localOffset + written; + MapPrototypeSet(this.#entries, entry.name, { central: null, entry, localOffset }); + await this.#rewriteCentralDirectory(); + // The entry now has a stable home in this archive; if it was a spent + // streaming entry, rebind it to that on-disk copy so it stays readable. + entry[kPromote](this.#handle, localOffset); + return entry; + } + /** + * The synchronous counterpart of `addEntry()`. `entry` must not be a + * pending streaming entry (one created with `ZipEntry.createStream()`) - + * there is no synchronous way to drain its asynchronous source. Blocks the + * event loop until done; see the class-level note on synchronous methods. + * @param {ZipEntry} entry + * @returns {ZipEntry} + */ + addEntrySync(entry) { + this.#assertWritable(); + this.#assertOpen(); + this.#assertNotBusy(); + if (!(entry instanceof ZipEntry)) { + throw new ERR_INVALID_ARG_TYPE('entry', 'ZipEntry', entry); + } + const localOffset = this.#centralDirectoryOffset; + let written = 0; + try { + for (const chunk of entry) { + writeFdFullySync(this.#handle.fd, chunk, localOffset + written); + written += chunk.length; + } + } catch (err) { + // See #doAdd(): restore the (partially overwritten) central directory + // before surfacing the failure. + try { + this.#rewriteCentralDirectorySync(); + } catch { + // Restoring failed too; the original error is the actionable one. + } + throw err; + } + this.#centralDirectoryOffset = localOffset + written; + MapPrototypeSet(this.#entries, entry.name, { central: null, entry, localOffset }); + this.#rewriteCentralDirectorySync(); + entry[kPromote](this.#handle, localOffset); + return entry; + } + /** + * The synchronous counterpart of `add()`. Blocks the event loop until + * done (including the deflate pass); see the class-level note on + * synchronous methods. + * @param {string} filename + * @param {Buffer | TypedArray | DataView | ArrayBuffer} data + * @param {{ comment?: string, mode?: number, modified?: Date, method?: 'deflate' | 'store' | 'zstd' }} [options] + * @returns {ZipEntry} + */ + addSync(filename, data, options) { + this.#assertWritable(); + return this.addEntrySync(ZipEntry.createSync(filename, data, options)); + } + /** + * Removes an entry by name. The central directory is rewritten in place + * (no new content is written, so the archive does not grow); the removed + * entry's bytes become dead space, reclaimed by `compact()`. + * @param {string} name + * @returns {Promise} + */ + async delete(name) { + this.#assertWritable(); + this.#assertOpen(); + validateString(name, 'name'); + return this.#enqueue(() => this.#doDelete(name)); + } + // Drop the named entry and rewrite the central directory (no member bytes + // move, so the file does not grow); reports whether it existed. + async #doDelete(name) { + const existed = MapPrototypeDelete(this.#entries, name); + if (existed) await this.#rewriteCentralDirectory(); + return existed; + } + /** + * The synchronous counterpart of `delete()`. Blocks the event loop until + * done; see the class-level note on synchronous methods. + * @param {string} name + * @returns {boolean} + */ + deleteSync(name) { + this.#assertWritable(); + this.#assertOpen(); + this.#assertNotBusy(); + validateString(name, 'name'); + const existed = MapPrototypeDelete(this.#entries, name); + if (existed) this.#rewriteCentralDirectorySync(); + return existed; + } + // Snapshot the live entries as ordered { entry, localOffset } records for a + // central-directory rebuild, materializing a plain ZipEntry for any member + // not yet handed out as a lazy handle. + #liveRecords() { + const records = []; + const names = []; + for (const { 0: name, 1: value } of MapPrototypeEntries(this.#entries)) { + ArrayPrototypePush(records, { + entry: value.entry ?? new ZipEntry(value.central, null, null), + localOffset: value.localOffset, + }); + ArrayPrototypePush(names, name); + } + return { records, names }; + } + // Rebuild the central directory and its trailer (sec. 4.3.12/4.3.16) for the + // current live set and overwrite it in place at its current offset, + // truncating any leftover tail, then adopt the freshly written headers. + async #rewriteCentralDirectory() { + const { records, names } = this.#liveRecords(); + const { centralHeaders, chunks } = buildCentralDirectoryChunks( + records, this.#centralDirectoryOffset, this.#comment); + let pos = this.#centralDirectoryOffset; + for (let i = 0; i < chunks.length; i++) { + await writeFdFully(this.#handle.fd, chunks[i], pos); + pos += chunks[i].length; + } + await fsFtruncateAsync(this.#handle.fd, pos); + this.#adoptRewrittenCentralDirectory(names, centralHeaders, records); + } + // Sync counterpart of #rewriteCentralDirectory(). + #rewriteCentralDirectorySync() { + const { records, names } = this.#liveRecords(); + const { centralHeaders, chunks } = buildCentralDirectoryChunks( + records, this.#centralDirectoryOffset, this.#comment); + let pos = this.#centralDirectoryOffset; + for (let i = 0; i < chunks.length; i++) { + writeFdFullySync(this.#handle.fd, chunks[i], pos); + pos += chunks[i].length; + } + fs.ftruncateSync(this.#handle.fd, pos); + this.#adoptRewrittenCentralDirectory(names, centralHeaders, records); + } + // Re-derives fresh, disk-backed central headers from what was just + // written, so every entry - original or freshly added - is uniformly + // readable by offset from now on, regardless of whether its in-memory + // ZipEntry (e.g. a streaming entry, whose source can only be consumed + // once) is still around. + #adoptRewrittenCentralDirectory(names, centralHeaders, records) { + for (let i = 0; i < names.length; i++) { + MapPrototypeSet(this.#entries, names[i], { + central: new CentralFileHeader(centralHeaders[i], 0), + entry: undefined, + localOffset: records[i].localOffset, + }); + } + } + /** + * Serializes the currently live entries into a fresh archive stream, + * leaving behind any dead space left by prior `addEntry()`/`delete()` + * calls. Does not modify the open file; pipe the result into a new one. + * @param {string} [comment] + * @returns {import('stream').Readable} + */ + compact(comment) { + this.#assertOpen(); + // Snapshot the live set now: a later addEntry()/delete() must not error + // out (or change) an archive stream that is already being produced. + // Reading the snapshot stays valid regardless of later mutations - + // neither addEntry() nor delete() moves existing member bytes. + const entries = []; + for (const { 1: info } of MapPrototypeEntries(this.#entries)) { + ArrayPrototypePush(entries, this.#handleFor(info)); + } + return createZipArchive(entries, { comment: comment ?? this.#comment }); + } + /** + * The synchronous counterpart of `compact()`. Blocks the event loop until + * the whole archive has been read and re-serialized; see the class-level + * note on synchronous methods. + * @param {string} [comment] + * @returns {Buffer} + */ + compactSync(comment) { + this.#assertOpen(); + this.#assertNotBusy(); + const entries = []; + for (const { 1: info } of MapPrototypeEntries(this.#entries)) { + ArrayPrototypePush(entries, this.#handleFor(info)); + } + const chunks = []; + for (const chunk of createZipArchiveSync(entries, { comment: comment ?? this.#comment })) { + ArrayPrototypePush(chunks, chunk); + } + return Buffer.concat(chunks); + } + keys() { this.#assertOpen(); return MapPrototypeKeys(this.#entries); } + *values() { + for (const name of this.keys()) yield this.get(name); + } + /** + * The synchronous counterpart of `values()`, yielding resolved `ZipEntry` + * values instead of `Promise`s. + * @yields {ZipEntry} + */ + *valuesSync() { + for (const name of this.keys()) yield this.getSync(name); + } + *entries() { + for (const name of this.keys()) yield [name, this.get(name)]; + } + /** + * The synchronous counterpart of `entries()`, yielding resolved `ZipEntry` + * values instead of `Promise`s. + * @yields {[string, ZipEntry]} + */ + *entriesSync() { + for (const name of this.keys()) yield [name, this.getSync(name)]; + } + // Async iteration yields each resolved ZipEntry (awaiting the lazy handles). + async *[SymbolAsyncIterator]() { + for (const promise of this.values()) yield await promise; + } + get size() { this.#assertOpen(); return MapPrototypeGetSize(this.#entries); } + [SymbolIterator]() { return this.entries(); } + get [SymbolToStringTag]() { return 'ZipFile'; } + forEach(callback, thisArg) { + validateFunction(callback, 'callback'); + for (const { 0: key, 1: value } of this.entries()) { + FunctionPrototypeCall(callback, thisArg === undefined ? this : thisArg, value, key, this); + } + } + /** + * The synchronous counterpart of `forEach()`, invoking `callback` with a + * resolved `ZipEntry` instead of a `Promise`. + * @param {Function} callback + * @param {*} [thisArg] + */ + forEachSync(callback, thisArg) { + validateFunction(callback, 'callback'); + for (const { 0: key, 1: value } of this.entriesSync()) { + FunctionPrototypeCall(callback, thisArg === undefined ? this : thisArg, value, key, this); + } + } + // Drop all entries and close the fd, queued behind any pending async ops. + // Idempotent: a second close() is a safe no-op, never a double-close of a + // (possibly reused) descriptor. `#closing` is set synchronously so any + // operation issued after this point is rejected rather than racing the fd. + close() { + if (this.#closing) return this.#closePromise ?? PromiseResolve(); + this.#closing = true; + this.#closePromise = this.#enqueue(async () => { + this.#handle.closed = true; + MapPrototypeClear(this.#entries); + await fsCloseAsync(this.#handle.fd); + }); + return this.#closePromise; + } + /** + * The synchronous counterpart of `close()`; see the class-level note on + * synchronous methods. Idempotent. + */ + closeSync() { + if (this.#closing) return; + this.#assertNotBusy(); + this.#closing = true; + this.#handle.closed = true; + MapPrototypeClear(this.#entries); + fs.closeSync(this.#handle.fd); + } + async [SymbolAsyncDispose]() { + await this.close(); + } + [SymbolDispose]() { + this.closeSync(); + } + /** + * Opens an archive, reading only its tail and central directory up front + * (member content stays on disk). Pass `{ writable: true }` for in-place + * editing. + * @param {string} filename + * @param {{ writable?: boolean }} [options] + * @returns {Promise} + */ + static async open(filename, options) { + validateString(filename, 'filename'); + const writable = options?.writable ?? false; + validateBoolean(writable, 'options.writable'); + const fd = await fsOpenAsync(filename, writable ? 'r+' : 'r'); + try { + const stat = await fsFstatAsync(fd); + const size = stat.size; + const tailLength = MathMin(size, TAIL_LENGTH); + const tail = Buffer.allocUnsafe(tailLength); + await readFdFully(fd, tail, size - tailLength); + let end = findArchiveEnd(tail, size - tailLength); + if (end.needTailFrom !== undefined) { + // The Zip64 EOCD record's extensible data sector pushes the record + // start beyond the fixed-size tail; re-read from the recorded + // offset (bounded by ZIP64_EOCD_MAX_LENGTH inside findArchiveEnd). + const retry = Buffer.allocUnsafe(size - end.needTailFrom); + await readFdFully(fd, retry, end.needTailFrom); + end = findArchiveEnd(retry, end.needTailFrom); + if (end.needTailFrom !== undefined) { + throw new ERR_ZIP_INVALID_ARCHIVE( + 'Zip64 end of central directory record not found'); + } + } + checkArchiveEnd(end, size); + const directory = Buffer.allocUnsafe(end.centralDirectorySize); + await readFdFully(fd, directory, end.centralDirectoryOffset); + const headers = readCentralDirectory(directory, end.totalRecords); + await validateMemberBounds(fd, headers, end.prefix, size, end.centralDirectoryOffset); + return new ZipFile(fd, headers, end.prefix, end.centralDirectoryOffset, end.comment, writable); + } catch (err) { + try { + await fsCloseAsync(fd); + } catch { + // The archive failed to parse; the close error is not actionable. + } + throw err; + } + } + /** + * The synchronous counterpart of `open()`. Blocks the event loop and + * further JavaScript execution until the archive's tail and central + * directory have been read; see the class-level note on synchronous + * methods. + * @param {string} filename + * @param {{ writable?: boolean }} [options] + * @returns {ZipFile} + */ + static openSync(filename, options) { + validateString(filename, 'filename'); + const writable = options?.writable ?? false; + validateBoolean(writable, 'options.writable'); + const fd = fs.openSync(filename, writable ? 'r+' : 'r'); + try { + const size = fs.fstatSync(fd).size; + const tailLength = MathMin(size, TAIL_LENGTH); + const tail = Buffer.allocUnsafe(tailLength); + readFdFullySync(fd, tail, size - tailLength); + let end = findArchiveEnd(tail, size - tailLength); + if (end.needTailFrom !== undefined) { + // See open(): the Zip64 EOCD record starts before the fixed-size + // tail; re-read from the recorded offset. + const retry = Buffer.allocUnsafe(size - end.needTailFrom); + readFdFullySync(fd, retry, end.needTailFrom); + end = findArchiveEnd(retry, end.needTailFrom); + if (end.needTailFrom !== undefined) { + throw new ERR_ZIP_INVALID_ARCHIVE( + 'Zip64 end of central directory record not found'); + } + } + checkArchiveEnd(end, size); + const directory = Buffer.allocUnsafe(end.centralDirectorySize); + readFdFullySync(fd, directory, end.centralDirectoryOffset); + const headers = readCentralDirectory(directory, end.totalRecords); + validateMemberBoundsSync(fd, headers, end.prefix, size, end.centralDirectoryOffset); + return new ZipFile(fd, headers, end.prefix, end.centralDirectoryOffset, end.comment, writable); + } catch (err) { + try { + fs.closeSync(fd); + } catch { + // The archive failed to parse; the close error is not actionable. + } + throw err; + } + } +} + +module.exports = { + ZipFile, +}; diff --git a/lib/internal/zip/fs-util.js b/lib/internal/zip/fs-util.js new file mode 100644 index 000000000000..cfd20069d007 --- /dev/null +++ b/lib/internal/zip/fs-util.js @@ -0,0 +1,150 @@ +'use strict'; + +// Promise wrappers over the callback `fs` primitives (`ZipFile` works on a +// raw fd so one instance can serve both async and `Sync` methods), plus the +// short-read/short-write loops that guarantee a full transfer. + +const { + Promise, +} = primordials; + +const { + codes: { + ERR_INVALID_STATE, + ERR_ZIP_INVALID_ARCHIVE, + }, +} = require('internal/errors'); +const fs = require('fs'); + +// Promisified adapters over the callback `fs` primitives; individually trivial, +// they only exist so the async archive paths can `await` plain fd operations. +// +// `ZipFile` operates on a plain numeric file descriptor (rather than an +// `fs.promises` `FileHandle`) so that a single instance can support both the +// async and the `Sync` methods: `fs.read`/`fs.write`/`fs.fstat`/ +// `fs.ftruncate`/`fs.close` all accept a raw fd directly, same as their +// `*Sync` counterparts, so both call sites share one open file underneath. +function fsOpenAsync(path, flag) { + return new Promise((resolve, reject) => { + fs.open(path, flag, (err, fd) => (err ? reject(err) : resolve(fd))); + }); +} + +function fsStatAsync(path) { + return new Promise((resolve, reject) => { + fs.stat(path, (err, stats) => (err ? reject(err) : resolve(stats))); + }); +} + +function fsLstatAsync(path) { + return new Promise((resolve, reject) => { + fs.lstat(path, (err, stats) => (err ? reject(err) : resolve(stats))); + }); +} + +function fsReadlinkAsync(path) { + return new Promise((resolve, reject) => { + fs.readlink(path, 'utf8', (err, target) => (err ? reject(err) : resolve(target))); + }); +} + +function fsCloseAsync(fd) { + return new Promise((resolve, reject) => { + fs.close(fd, (err) => (err ? reject(err) : resolve())); + }); +} + +function fsFstatAsync(fd) { + return new Promise((resolve, reject) => { + fs.fstat(fd, (err, stats) => (err ? reject(err) : resolve(stats))); + }); +} + +function fsReadAsync(fd, buffer, offset, length, position) { + return new Promise((resolve, reject) => { + fs.read(fd, buffer, offset, length, position, (err, bytesRead) => (err ? reject(err) : resolve(bytesRead))); + }); +} + +function fsWriteAsync(fd, buffer, offset, length, position) { + return new Promise((resolve, reject) => { + fs.write(fd, buffer, offset, length, position, (err, bytesWritten) => (err ? reject(err) : resolve(bytesWritten))); + }); +} + +function fsFtruncateAsync(fd, len) { + return new Promise((resolve, reject) => { + fs.ftruncate(fd, len, (err) => (err ? reject(err) : resolve())); + }); +} + +// `read(2)` may return fewer bytes than requested without hitting EOF, so loop +// until `buffer` is entirely filled; a genuine short read (0 bytes) means the +// archive is truncated where a full header/record was expected. +async function readFdFully(fd, buffer, position) { + let done = 0; + while (done < buffer.length) { + const bytesRead = await fsReadAsync(fd, buffer, done, buffer.length - done, position + done); + if (bytesRead <= 0) { + throw new ERR_ZIP_INVALID_ARCHIVE('unexpected end of file'); + } + done += bytesRead; + } +} + +// Synchronous counterpart of `readFdFully()`; same short-read loop. +function readFdFullySync(fd, buffer, position) { + let done = 0; + while (done < buffer.length) { + const bytesRead = fs.readSync(fd, buffer, done, buffer.length - done, position + done); + if (bytesRead <= 0) { + throw new ERR_ZIP_INVALID_ARCHIVE('unexpected end of file'); + } + done += bytesRead; + } +} + +// `write(2)` may write fewer bytes than asked - most notably when an error +// (ENOSPC, EIO, a full NFS commit) strikes after partial progress, which +// surfaces as a short, error-free count. Advancing archive offsets by the +// intended length would then silently corrupt the file, so every archive +// write loops until the buffer is fully on disk; retrying the remainder +// re-encounters and surfaces the underlying error. +async function writeFdFully(fd, buffer, position) { + let done = 0; + while (done < buffer.length) { + const bytesWritten = + await fsWriteAsync(fd, buffer, done, buffer.length - done, position + done); + if (bytesWritten <= 0) { + throw new ERR_INVALID_STATE('a write to the archive made no progress'); + } + done += bytesWritten; + } +} + +// Synchronous counterpart of `writeFdFully()`; same short-write loop. +function writeFdFullySync(fd, buffer, position) { + let done = 0; + while (done < buffer.length) { + const bytesWritten = + fs.writeSync(fd, buffer, done, buffer.length - done, position + done); + if (bytesWritten <= 0) { + throw new ERR_INVALID_STATE('a write to the archive made no progress'); + } + done += bytesWritten; + } +} + +module.exports = { + fsOpenAsync, + fsStatAsync, + fsLstatAsync, + fsReadlinkAsync, + fsCloseAsync, + fsFstatAsync, + fsFtruncateAsync, + readFdFully, + readFdFullySync, + writeFdFully, + writeFdFullySync, +}; diff --git a/lib/internal/zip/header-builders.js b/lib/internal/zip/header-builders.js new file mode 100644 index 000000000000..4b48fbc53180 --- /dev/null +++ b/lib/internal/zip/header-builders.js @@ -0,0 +1,251 @@ +'use strict'; + +// Write-path header builders: local/central file headers, the Zip64 data +// descriptor, and the (Zip64) end-of-central-directory records, plus the +// version-needed and extra-length helpers they share. + +const { + ArrayPrototypePush, + MathMax, + MathMin, +} = primordials; + +const { + codes: { + ERR_ZIP_ENTRY_TOO_LARGE, + }, +} = require('internal/errors'); +const { Buffer } = require('buffer'); +const { + EMPTY_BUFFER, + SIG_LOCAL_FILE_HEADER, + SIG_DATA_DESCRIPTOR, + SIG_CENTRAL_FILE_HEADER, + SIG_ZIP64_EOCD_RECORD, + SIG_ZIP64_EOCD_LOCATOR, + SIG_EOCD, + MADE_BY_UNIX, + ZIP64_EXTRA_ID, + SENTINEL16, + SENTINEL32, + FLAG_DATA_DESCRIPTOR, + METHOD_ZSTD, + VERSION_DEFAULT, + VERSION_ZIP64, + VERSION_ZSTD, +} = require('internal/zip/constants'); +const { encodeDosDateTime } = require('internal/zip/dos'); +const { writeSafeUint64 } = require('internal/zip/binary'); +const { buildExtTimestampExtra } = require('internal/zip/extra-fields'); + +// The "version needed to extract" (sec. 4.4.3): the highest version any +// feature of the member demands - 6.3 for Zstandard, 4.5 for Zip64 +// structures, 2.0 otherwise. +function versionNeeded(meta, zip64) { + return MathMax( + zip64 ? VERSION_ZIP64 : VERSION_DEFAULT, + meta.method === METHOD_ZSTD ? VERSION_ZSTD : 0); +} + +// Guard the combined extra-field length against the 16-bit field it is stored +// in (sec. 4.4.11). +function checkExtraLength(extraLength) { + if (extraLength > SENTINEL16) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + 'the entry extra fields must not exceed 65535 bytes'); + } +} + +// Build a local file header (sec. 4.3.7). Streamed entries (unknown sizes/CRC) +// zero those fields and always carry a Zip64 extra so the trailing data +// descriptor can hold 64-bit sizes; oversized non-streamed entries get one too. +function buildLocalHeader(meta) { + const streaming = (meta.flags & FLAG_DATA_DESCRIPTOR) !== 0; + const zip64 = + streaming || + meta.compressedSize >= SENTINEL32 || + meta.uncompressedSize >= SENTINEL32; + const ts = meta.extendedMtime !== null ? buildExtTimestampExtra(meta.extendedMtime) : EMPTY_BUFFER; + const extraLength = (zip64 ? 20 : 0) + ts.length + meta.extra.length; + checkExtraLength(extraLength); + const buffer = Buffer.allocUnsafe(30 + meta.name.length + extraLength); + buffer.writeUInt32LE(SIG_LOCAL_FILE_HEADER, 0); + buffer.writeUInt16LE(versionNeeded(meta, zip64), 4); + buffer.writeUInt16LE(meta.flags, 6); + buffer.writeUInt16LE(meta.method, 8); + const { time, date } = encodeDosDateTime(meta.modified); + buffer.writeUInt16LE(time, 10); + buffer.writeUInt16LE(date, 12); + buffer.writeUInt32LE(streaming ? 0 : meta.crc, 14); + buffer.writeUInt32LE(zip64 ? SENTINEL32 : meta.compressedSize, 18); + buffer.writeUInt32LE(zip64 ? SENTINEL32 : meta.uncompressedSize, 22); + buffer.writeUInt16LE(meta.name.length, 26); + buffer.writeUInt16LE(extraLength, 28); + meta.name.copy(buffer, 30); + let pos = 30 + meta.name.length; + if (zip64) { + buffer.writeUInt16LE(ZIP64_EXTRA_ID, pos); + buffer.writeUInt16LE(16, pos + 2); + writeSafeUint64(buffer, pos + 4, streaming ? 0 : meta.uncompressedSize); + writeSafeUint64(buffer, pos + 12, streaming ? 0 : meta.compressedSize); + pos += 20; + } + ts.copy(buffer, pos); + pos += ts.length; + meta.extra.copy(buffer, pos); + return buffer; +} + +// Build a central directory file header (sec. 4.3.12). Each of the +// uncompressed size, compressed size, and local-header offset that overflows +// 32 bits is moved into the Zip64 extra field (sec. 4.5.3) in that order. +function buildCentralHeader(meta, localOffset) { + const zip64Streaming = (meta.flags & FLAG_DATA_DESCRIPTOR) !== 0; + const u64 = meta.uncompressedSize >= SENTINEL32; + const c64 = meta.compressedSize >= SENTINEL32; + const o64 = localOffset >= SENTINEL32; + const zip64Fields = (u64 ? 1 : 0) + (c64 ? 1 : 0) + (o64 ? 1 : 0); + const ts = meta.extendedMtime !== null ? buildExtTimestampExtra(meta.extendedMtime) : EMPTY_BUFFER; + const extraLength = (zip64Fields ? 4 + 8 * zip64Fields : 0) + ts.length + meta.extra.length; + checkExtraLength(extraLength); + const zip64 = zip64Streaming || zip64Fields > 0; + const version = versionNeeded(meta, zip64); + const buffer = Buffer.allocUnsafe( + 46 + meta.name.length + extraLength + meta.comment.length); + buffer.writeUInt32LE(SIG_CENTRAL_FILE_HEADER, 0); + // "Version made by" (sec. 4.4.2): the upper byte is the creator's host + // system, which determines how the external attributes (sec. 4.4.15) are + // interpreted. Preserve the original host for round-tripped entries - + // stamping everything as Unix would turn, say, a DOS entry's zeroed high + // bits into Unix mode 0000. Entries built by `createEntryMeta()` are Unix. + buffer.writeUInt16LE((meta.madeBy << 8) | version, 4); + buffer.writeUInt16LE(version, 6); + buffer.writeUInt16LE(meta.flags, 8); + buffer.writeUInt16LE(meta.method, 10); + const { time, date } = encodeDosDateTime(meta.modified); + buffer.writeUInt16LE(time, 12); + buffer.writeUInt16LE(date, 14); + buffer.writeUInt32LE(meta.crc, 16); + buffer.writeUInt32LE(c64 ? SENTINEL32 : meta.compressedSize, 20); + buffer.writeUInt32LE(u64 ? SENTINEL32 : meta.uncompressedSize, 24); + buffer.writeUInt16LE(meta.name.length, 28); + buffer.writeUInt16LE(extraLength, 30); + buffer.writeUInt16LE(meta.comment.length, 32); + buffer.writeUInt16LE(0, 34); // disk number + buffer.writeUInt16LE(meta.internal, 36); + buffer.writeUInt32LE(meta.external, 38); + buffer.writeUInt32LE(o64 ? SENTINEL32 : localOffset, 42); + meta.name.copy(buffer, 46); + let pos = 46 + meta.name.length; + if (zip64Fields) { + buffer.writeUInt16LE(ZIP64_EXTRA_ID, pos); + buffer.writeUInt16LE(8 * zip64Fields, pos + 2); + pos += 4; + if (u64) { + writeSafeUint64(buffer, pos, meta.uncompressedSize); + pos += 8; + } + if (c64) { + writeSafeUint64(buffer, pos, meta.compressedSize); + pos += 8; + } + if (o64) { + writeSafeUint64(buffer, pos, localOffset); + pos += 8; + } + } + ts.copy(buffer, pos); + pos += ts.length; + meta.extra.copy(buffer, pos); + pos += meta.extra.length; + meta.comment.copy(buffer, pos); + return buffer; +} + +// Zip64 data descriptor (sec. 4.3.9): emitted after a streamed entry, whose +// local header always carries a Zip64 extra field. +function buildDataDescriptor64(crc, compressedSize, uncompressedSize) { + const buffer = Buffer.allocUnsafe(24); + buffer.writeUInt32LE(SIG_DATA_DESCRIPTOR, 0); + buffer.writeUInt32LE(crc, 4); + writeSafeUint64(buffer, 8, compressedSize); + writeSafeUint64(buffer, 16, uncompressedSize); + return buffer; +} + +// Build the end of central directory record (sec. 4.3.16); fields that +// overflow their 16/32-bit slots are written as sentinels and the true values +// live in the Zip64 EOCD record. +function buildEndOfCentralDirectory(count, size, offset, comment) { + const buffer = Buffer.allocUnsafe(22 + comment.length); + buffer.writeUInt32LE(SIG_EOCD, 0); + buffer.writeUInt16LE(0, 4); // disk number + buffer.writeUInt16LE(0, 6); // Central directory disk number + buffer.writeUInt16LE(MathMin(count, SENTINEL16), 8); + buffer.writeUInt16LE(MathMin(count, SENTINEL16), 10); + buffer.writeUInt32LE(MathMin(size, SENTINEL32), 12); + buffer.writeUInt32LE(MathMin(offset, SENTINEL32), 16); + buffer.writeUInt16LE(comment.length, 20); + comment.copy(buffer, 22); + return buffer; +} + +// Build the Zip64 end of central directory record (sec. 4.3.14): the 64-bit +// counterpart to the EOCD, holding the real record count/size/offset. +function buildZip64EndRecord(count, size, offset) { + const buffer = Buffer.allocUnsafe(56); + buffer.writeUInt32LE(SIG_ZIP64_EOCD_RECORD, 0); + writeSafeUint64(buffer, 4, 44); // Size of the remainder of this record + buffer.writeUInt16LE((MADE_BY_UNIX << 8) | VERSION_ZIP64, 12); + buffer.writeUInt16LE(VERSION_ZIP64, 14); + buffer.writeUInt32LE(0, 16); // disk number + buffer.writeUInt32LE(0, 20); // Central directory disk number + writeSafeUint64(buffer, 24, count); + writeSafeUint64(buffer, 32, count); + writeSafeUint64(buffer, 40, size); + writeSafeUint64(buffer, 48, offset); + return buffer; +} + +// Build the Zip64 end of central directory locator (sec. 4.3.15): points the +// reader from just before the EOCD to the Zip64 EOCD record. +function buildZip64EndLocator(recordOffset) { + const buffer = Buffer.allocUnsafe(20); + buffer.writeUInt32LE(SIG_ZIP64_EOCD_LOCATOR, 0); + buffer.writeUInt32LE(0, 4); // Disk with the Zip64 EOCD record + writeSafeUint64(buffer, 8, recordOffset); + buffer.writeUInt32LE(1, 16); // total disks + return buffer; +} + +// The archive trailer: the Zip64 EOCD record and locator +// (sec. 4.3.14/4.3.15) when the record count, central directory offset, or +// central directory size overflows its classic 16-/32-bit field, followed +// by the end of central directory record (sec. 4.3.16). Shared by the +// streaming serializers and ZipFile's in-place rewrite so the Zip64 +// switchover rule lives in exactly one place. +function buildArchiveTrailer(count, centralDirectorySize, centralDirectoryOffset, comment) { + const chunks = []; + const zip64 = + count >= SENTINEL16 || + centralDirectoryOffset >= SENTINEL32 || + centralDirectorySize >= SENTINEL32; + if (zip64) { + const recordOffset = centralDirectoryOffset + centralDirectorySize; + ArrayPrototypePush(chunks, buildZip64EndRecord(count, centralDirectorySize, centralDirectoryOffset)); + ArrayPrototypePush(chunks, buildZip64EndLocator(recordOffset)); + } + ArrayPrototypePush(chunks, + buildEndOfCentralDirectory(count, centralDirectorySize, centralDirectoryOffset, comment)); + return chunks; +} + +module.exports = { + buildLocalHeader, + buildCentralHeader, + buildDataDescriptor64, + buildEndOfCentralDirectory, + buildZip64EndRecord, + buildZip64EndLocator, + buildArchiveTrailer, +}; diff --git a/lib/internal/zip/headers.js b/lib/internal/zip/headers.js new file mode 100644 index 000000000000..aadfa1ca3cde --- /dev/null +++ b/lib/internal/zip/headers.js @@ -0,0 +1,511 @@ +'use strict'; + +// Reader-side header structures (sec. 4.3): the end-of-central-directory +// record, the Zip64 EOCD record/locator, and the central/local file headers, +// plus `findArchiveEnd()` which locates them and `readCentralDirectory()` +// which walks the central directory into an array of headers. + +const { + ArrayPrototypePush, + MathMax, + Number, +} = primordials; + +const { + codes: { + ERR_ZIP_INVALID_ARCHIVE, + ERR_ZIP_UNSUPPORTED_FEATURE, + }, +} = require('internal/errors'); +const { + BIGINT_MAX_SAFE_INTEGER, + SIG_LOCAL_FILE_HEADER, + SIG_CENTRAL_FILE_HEADER, + SIG_ZIP64_EOCD_RECORD, + SIG_ZIP64_EOCD_LOCATOR, + SIG_EOCD, + MADE_BY_UNIX, + SENTINEL16, + SENTINEL32, + ZIP64_EOCD_MAX_LENGTH, + S_IFLNK, + S_IFMT, +} = require('internal/zip/constants'); +const { + validateArchiveRange, + readSafeUint64, +} = require('internal/zip/binary'); +const { parseZip64Extra } = require('internal/zip/extra-fields'); +const { + decodeDosDateTime, + decodeZipName, + decodeZipText, +} = require('internal/zip/dos'); + +// End of central directory record (sec. 4.3.16). +// Offset Bytes Description +// 0 4 Signature = 0x06054b50 +// 4 2 Number of this disk +// 6 2 Disk where central directory starts +// 8 2 Number of central directory records on this disk +// 10 2 Total number of central directory records +// 12 4 Size of central directory (bytes) +// 16 4 Offset of start of central directory +// 20 2 Comment length (n) +// 22 n Comment +class CentralEndHeader { + #buffer; + #offset; + constructor(buffer, offset = 0) { + validateArchiveRange(buffer, offset, 22, 'end of central directory record'); + if (buffer.readUInt32LE(offset) !== SIG_EOCD) { + throw new ERR_ZIP_INVALID_ARCHIVE('end of central directory signature is invalid'); + } + this.#buffer = buffer; + this.#offset = offset; + if (offset + this.byteLength > buffer.length) { + throw new ERR_ZIP_INVALID_ARCHIVE('end of central directory record is truncated'); + } + } + get byteLength() { return 22 + this.commentLength; } + get diskNumber() { return this.#buffer.readUInt16LE(this.#offset + 4); } + get centralDirectoryDiskNumber() { return this.#buffer.readUInt16LE(this.#offset + 6); } + get centralDirectoryDiskRecords() { return this.#buffer.readUInt16LE(this.#offset + 8); } + get centralDirectoryTotalRecords() { return this.#buffer.readUInt16LE(this.#offset + 10); } + get centralDirectorySize() { return this.#buffer.readUInt32LE(this.#offset + 12); } + get centralDirectoryOffset() { return this.#buffer.readUInt32LE(this.#offset + 16); } + get commentLength() { return this.#buffer.readUInt16LE(this.#offset + 20); } + get commentBuffer() { + const start = this.#offset + 22; + return this.#buffer.subarray(start, start + this.commentLength); + } +} + +// Zip64 end of central directory record (sec. 4.3.14). +// 0 4 Signature = 0x06064b50 +// 4 8 Size of remainder of this record +// 12 2 Version made by +// 14 2 Version needed to extract +// 16 4 Number of this disk +// 20 4 Disk where central directory starts +// 24 8 Number of central directory records on this disk +// 32 8 Total number of central directory records +// 40 8 Size of central directory +// 48 8 Offset of start of central directory +class Zip64EndRecord { + #buffer; + #offset; + constructor(buffer, offset = 0) { + validateArchiveRange(buffer, offset, 56, 'Zip64 end of central directory record'); + if (buffer.readUInt32LE(offset) !== SIG_ZIP64_EOCD_RECORD) { + throw new ERR_ZIP_INVALID_ARCHIVE( + 'Zip64 end of central directory signature is invalid'); + } + this.#buffer = buffer; + this.#offset = offset; + } + get diskNumber() { return this.#buffer.readUInt32LE(this.#offset + 16); } + get centralDirectoryDiskNumber() { return this.#buffer.readUInt32LE(this.#offset + 20); } + get centralDirectoryDiskRecords() { return readSafeUint64(this.#buffer, this.#offset + 24); } + get centralDirectoryTotalRecords() { return readSafeUint64(this.#buffer, this.#offset + 32); } + get centralDirectorySize() { return readSafeUint64(this.#buffer, this.#offset + 40); } + get centralDirectoryOffset() { return readSafeUint64(this.#buffer, this.#offset + 48); } +} + +// Zip64 end of central directory locator (sec. 4.3.15). +// 0 4 Signature = 0x07064b50 +// 4 4 Disk with the Zip64 end of central directory record +// 8 8 Offset of the Zip64 end of central directory record +// 16 4 Total number of disks +class Zip64EndLocator { + #buffer; + #offset; + constructor(buffer, offset = 0) { + validateArchiveRange(buffer, offset, 20, 'Zip64 end of central directory locator'); + if (buffer.readUInt32LE(offset) !== SIG_ZIP64_EOCD_LOCATOR) { + throw new ERR_ZIP_INVALID_ARCHIVE( + 'Zip64 end of central directory locator signature is invalid'); + } + this.#buffer = buffer; + this.#offset = offset; + } + get recordDiskNumber() { return this.#buffer.readUInt32LE(this.#offset + 4); } + // Spec field (sec. 4.3.15); not consumed as a getter - findArchiveEnd() + // reads the offset leniently via readBigUInt64LE instead, so a locator + // signature that is really comment data cannot turn into a hard parse + // error on an out-of-range value. + // get recordOffset() { return readSafeUint64(this.#buffer, this.#offset + 8); } + get totalDisks() { return this.#buffer.readUInt32LE(this.#offset + 16); } +} + +// Central directory file header (sec. 4.3.12). +// 0 4 Signature = 0x02014b50 +// 4 2 Version made by +// 6 2 Version needed to extract +// 8 2 General purpose bit flag +// 10 2 Compression method +// 12 2 Last modification time +// 14 2 Last modification date +// 16 4 CRC-32 +// 20 4 Compressed size +// 24 4 Uncompressed size +// 28 2 File name length (n) +// 30 2 Extra field length (m) +// 32 2 File comment length (k) +// 34 2 Disk number where file starts +// 36 2 Internal file attributes +// 38 4 External file attributes +// 42 4 Relative offset of local file header +// 46 n File name +// 46+n m Extra field +// 46+n+m k File comment +class CentralFileHeader { + #buffer; + #offset; + #zip64 = null; + constructor(buffer, offset = 0) { + validateArchiveRange(buffer, offset, 46, 'central directory header'); + if (buffer.readUInt32LE(offset) !== SIG_CENTRAL_FILE_HEADER) { + throw new ERR_ZIP_INVALID_ARCHIVE('central directory header signature is invalid'); + } + this.#buffer = buffer; + this.#offset = offset; + if (offset + this.byteLength > buffer.length) { + throw new ERR_ZIP_INVALID_ARCHIVE('central directory header is truncated'); + } + } + get byteOffset() { return this.#offset; } + get byteLength() { + return 46 + this.fileNameLength + this.extraFieldLength + this.fileCommentLength; + } + get version() { return this.#buffer.readUInt16LE(this.#offset + 4); } + // Spec field "version needed to extract" (sec. 4.4.3); not consumed today. + // get versionNeeded() { return this.#buffer.readUInt16LE(this.#offset + 6); } + get flags() { return this.#buffer.readUInt16LE(this.#offset + 8); } + get compressionMethod() { return this.#buffer.readUInt16LE(this.#offset + 10); } + get lastModified() { + return decodeDosDateTime( + this.#buffer.readUInt16LE(this.#offset + 12), + this.#buffer.readUInt16LE(this.#offset + 14)); + } + get crc32() { return this.#buffer.readUInt32LE(this.#offset + 16); } + // Lazily parse the Zip64 extended-information extra field (sec. 4.5.3), + // supplying the true 64-bit values only for the classic fields that hold an + // overflow sentinel. Cached across getters. + #resolveZip64() { + if (this.#zip64 === null) { + this.#zip64 = parseZip64Extra(this.extraField, { + uncompressedSize: this.#buffer.readUInt32LE(this.#offset + 24) === SENTINEL32, + compressedSize: this.#buffer.readUInt32LE(this.#offset + 20) === SENTINEL32, + localFileHeaderOffset: this.#buffer.readUInt32LE(this.#offset + 42) === SENTINEL32, + diskNumber: this.#buffer.readUInt16LE(this.#offset + 34) === SENTINEL16, + }); + } + return this.#zip64; + } + get compressedSize() { + const value = this.#buffer.readUInt32LE(this.#offset + 20); + return value === SENTINEL32 ? this.#resolveZip64().compressedSize : value; + } + get uncompressedSize() { + const value = this.#buffer.readUInt32LE(this.#offset + 24); + return value === SENTINEL32 ? this.#resolveZip64().uncompressedSize : value; + } + get fileNameLength() { return this.#buffer.readUInt16LE(this.#offset + 28); } + get extraFieldLength() { return this.#buffer.readUInt16LE(this.#offset + 30); } + get fileCommentLength() { return this.#buffer.readUInt16LE(this.#offset + 32); } + get diskNumber() { + const value = this.#buffer.readUInt16LE(this.#offset + 34); + return value === SENTINEL16 ? this.#resolveZip64().diskNumber : value; + } + get internalFileAttributes() { return this.#buffer.readUInt16LE(this.#offset + 36); } + get externalFileAttributes() { return this.#buffer.readUInt32LE(this.#offset + 38); } + get localFileHeaderOffset() { + const value = this.#buffer.readUInt32LE(this.#offset + 42); + return value === SENTINEL32 ? this.#resolveZip64().localFileHeaderOffset : value; + } + get fileNameBuffer() { + const start = this.#offset + 46; + return this.#buffer.subarray(start, start + this.fileNameLength); + } + get fileName() { return decodeZipName(this.fileNameBuffer, this.flags, this.extraField); } + get extraField() { + const start = this.#offset + 46 + this.fileNameLength; + return this.#buffer.subarray(start, start + this.extraFieldLength); + } + get fileCommentBuffer() { + const start = this.#offset + 46 + this.fileNameLength + this.extraFieldLength; + return this.#buffer.subarray(start, start + this.fileCommentLength); + } + get fileComment() { return decodeZipText(this.fileCommentBuffer, this.flags); } + get isUnixMode() { return (this.version >>> 8) === MADE_BY_UNIX; } + get mode() { + // Low 12 bits: permissions plus setuid/setgid/sticky (sec. 4.4.15). + return this.isUnixMode ? (this.externalFileAttributes >>> 16) & 0o7777 : 0; + } + get isSymlink() { + return this.isUnixMode && + ((this.externalFileAttributes >>> 16) & S_IFMT) === S_IFLNK; + } +} + +// Local file header (sec. 4.3.7). +// 0 4 Signature = 0x04034b50 +// 4 2 Version needed to extract +// 6 2 General purpose bit flag +// 8 2 Compression method +// 10 2 Last modification time +// 12 2 Last modification date +// 14 4 CRC-32 +// 18 4 Compressed size +// 22 4 Uncompressed size +// 26 2 File name length (n) +// 28 2 Extra field length (m) +// 30 n File name +// 30+n m Extra field +class LocalFileHeader { + #buffer; + #offset; + constructor(buffer, offset = 0) { + validateArchiveRange(buffer, offset, 30, 'local file header'); + if (buffer.readUInt32LE(offset) !== SIG_LOCAL_FILE_HEADER) { + throw new ERR_ZIP_INVALID_ARCHIVE('local file header signature is invalid'); + } + this.#buffer = buffer; + this.#offset = offset; + if (offset + this.byteLength > buffer.length) { + throw new ERR_ZIP_INVALID_ARCHIVE('local file header is truncated'); + } + } + get byteLength() { return 30 + this.fileNameLength + this.extraFieldLength; } + get flags() { return this.#buffer.readUInt16LE(this.#offset + 6); } + // Spec field (sec. 4.4.5); the central directory's method is authoritative, + // so the local copy is not consumed today. + // get compressionMethod() { return this.#buffer.readUInt16LE(this.#offset + 8); } + get fileNameLength() { return this.#buffer.readUInt16LE(this.#offset + 26); } + get extraFieldLength() { return this.#buffer.readUInt16LE(this.#offset + 28); } + get extraField() { + const start = this.#offset + 30 + this.fileNameLength; + return this.#buffer.subarray(start, start + this.extraFieldLength); + } + // The local header also carries the name and modification time (sec. 4.3.7), + // but the central directory is authoritative for both - a mismatching local + // name is ignored by design - so only the flags and the extra field (which + // may hold a higher-resolution timestamp) are consumed here. + // get fileName() { + // const start = this.#offset + 30; + // return decodeZipName( + // this.#buffer.subarray(start, start + this.fileNameLength), this.flags, this.extraField); + // } + // get lastModified() { + // return decodeDosDateTime(this.#buffer.readUInt16LE(this.#offset + 10), + // this.#buffer.readUInt16LE(this.#offset + 12)); + // } + // Total on-disk length of the local header at `offset` (sec. 4.3.7), read + // straight from the length fields without constructing a header; 0 if the + // fixed part does not fit. Lets the reader skip to the entry data. + static length(buffer, offset) { + if (offset + 30 > buffer.length) return 0; + return 30 + buffer.readUInt16LE(offset + 26) + buffer.readUInt16LE(offset + 28); + } +} + +/** + * Locates and validates the end-of-archive structures (EOCD, and the Zip64 + * EOCD locator/record when present) in `buffer`. `base` is the absolute + * offset of `buffer[0]` when `buffer` is only the tail of a larger file; all + * returned offsets are absolute. `buffer` must extend to the end of the + * archive. + * + * When a required Zip64 EOCD record starts before `buffer[0]` (its + * extensible data sector can push it beyond any fixed-size tail read), + * returns `{ needTailFrom }` instead: the caller must retry with a buffer + * that starts at that absolute offset (still ending at the end of the + * archive). Never returned when `base` is 0. + * @returns {{ + * prefix: number, + * totalRecords: number, + * centralDirectoryOffset: number, + * centralDirectorySize: number, + * comment: Buffer, + * } | { needTailFrom: number }} + */ +function findArchiveEnd(buffer, base = 0) { + if (buffer.length < 22) { + throw new ERR_ZIP_INVALID_ARCHIVE('no end of central directory record found'); + } + const min = MathMax(0, buffer.length - (22 + SENTINEL16)); + let eocdPos = -1; + // Pass 1: the comment must reach exactly to the end of the buffer (this + // rejects a stray EOCD-looking signature inside an earlier comment). + for (let pos = buffer.length - 22; pos >= min; pos--) { + if (buffer.readUInt32LE(pos) !== SIG_EOCD) continue; + if (pos + 22 + buffer.readUInt16LE(pos + 20) !== buffer.length) continue; + eocdPos = pos; + break; + } + if (eocdPos < 0) { + // Pass 2: tolerate trailing padding after the EOCD (some streaming + // writers pad their output to a fixed block size); take the last + // candidate found. + for (let pos = buffer.length - 22; pos >= min; pos--) { + if (buffer.readUInt32LE(pos) !== SIG_EOCD) continue; + if (pos + 22 + buffer.readUInt16LE(pos + 20) > buffer.length) continue; + eocdPos = pos; + break; + } + } + if (eocdPos < 0) { + throw new ERR_ZIP_INVALID_ARCHIVE('no end of central directory record found'); + } + const eocd = new CentralEndHeader(buffer, eocdPos); + let totalRecords = eocd.centralDirectoryTotalRecords; + let centralDirectorySize = eocd.centralDirectorySize; + let centralDirectoryOffset = eocd.centralDirectoryOffset; + let prefix; + // A classic field at its maximum is an overflow sentinel that makes the + // Zip64 record mandatory. Otherwise the classic fields are authoritative, + // and a Zip64 locator signature in the preceding bytes is not proof of a + // Zip64 archive - it may be the tail of a file comment that happens to + // contain those four bytes - so a failed Zip64 lookup falls back to the + // classic fields instead of rejecting the archive. + const needsZip64 = + eocd.diskNumber === SENTINEL16 || + eocd.centralDirectoryDiskNumber === SENTINEL16 || + eocd.centralDirectoryDiskRecords === SENTINEL16 || + totalRecords === SENTINEL16 || + centralDirectorySize === SENTINEL32 || + centralDirectoryOffset === SENTINEL32; + let zip64 = null; + let recordPos = -1; + const locatorPos = eocdPos - 20; + if ( + locatorPos >= 0 && + buffer.readUInt32LE(locatorPos) === SIG_ZIP64_EOCD_LOCATOR + ) { + const locator = new Zip64EndLocator(buffer, locatorPos); + // Read the recorded offset leniently: on a coincidental signature these + // eight bytes are arbitrary comment data, which must not turn into a + // hard parse error. + const rawRecordOffset = buffer.readBigUInt64LE(locatorPos + 8); + const recordOffset = + rawRecordOffset <= BIGINT_MAX_SAFE_INTEGER ? Number(rawRecordOffset) : -1; + recordPos = recordOffset >= 0 ? recordOffset - base : -1; + if ( + !(recordPos >= 0 && + recordPos + 56 <= locatorPos && + buffer.readUInt32LE(recordPos) === SIG_ZIP64_EOCD_RECORD) + ) { + // Data was prepended to the archive, shifting the record; scan + // backward from the locator instead of trusting its recorded offset. + recordPos = -1; + const floor = MathMax(0, locatorPos - 56 - SENTINEL16); + for (let pos = locatorPos - 56; pos >= floor; pos--) { + if (buffer.readUInt32LE(pos) !== SIG_ZIP64_EOCD_RECORD) continue; + const size = buffer.readBigUInt64LE(pos + 4); + if (size >= 44n && pos + 12 + Number(size) === locatorPos) { + recordPos = pos; + break; + } + } + } + if (recordPos < 0 && needsZip64) { + // The record is required but does not lie inside `buffer`: its + // extensible data sector may extend it beyond any fixed-size tail + // read. When the locator points (plausibly) before the bytes at + // hand, ask the caller for a longer tail instead of failing. + if ( + recordOffset >= 0 && + recordOffset < base && + base + locatorPos - recordOffset <= ZIP64_EOCD_MAX_LENGTH + ) { + return { needTailFrom: recordOffset }; + } + throw new ERR_ZIP_INVALID_ARCHIVE('Zip64 end of central directory record not found'); + } + if (recordPos >= 0) { + if (locator.totalDisks > 1 || locator.recordDiskNumber !== 0) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported'); + } + zip64 = new Zip64EndRecord(buffer, recordPos); + } + } + // The `prefix` math assumes nothing sits between the central directory and + // the archive-end records. APPNOTE sec. 4.3.13 allows a digital-signature + // record there; such an archive shifts `prefix` by the signature's length + // and fails with a central-directory signature error rather than a targeted + // message - signed archives are essentially extinct, so none is emitted. + if (zip64 !== null) { + if (zip64.diskNumber !== 0 || zip64.centralDirectoryDiskNumber !== 0) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported'); + } + if (zip64.centralDirectoryDiskRecords !== zip64.centralDirectoryTotalRecords) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported'); + } + // A classic field either holds the overflow sentinel (its real value lives + // in the Zip64 record) or its own real value, which must then match the + // Zip64 record. A non-sentinel classic field that disagrees with Zip64 is a + // parser differential - a classic-only reader and this one would see + // different archives - so reject it rather than silently preferring Zip64. + if ( + (totalRecords !== SENTINEL16 && + totalRecords !== zip64.centralDirectoryTotalRecords) || + (centralDirectorySize !== SENTINEL32 && + centralDirectorySize !== zip64.centralDirectorySize) || + (centralDirectoryOffset !== SENTINEL32 && + centralDirectoryOffset !== zip64.centralDirectoryOffset) + ) { + throw new ERR_ZIP_INVALID_ARCHIVE( + 'Zip64 and classic end-of-central-directory records disagree'); + } + totalRecords = zip64.centralDirectoryTotalRecords; + centralDirectorySize = zip64.centralDirectorySize; + centralDirectoryOffset = zip64.centralDirectoryOffset; + prefix = base + recordPos - (centralDirectoryOffset + centralDirectorySize); + } else { + if (eocd.diskNumber !== 0 || eocd.centralDirectoryDiskNumber !== 0) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported'); + } + if (eocd.centralDirectoryDiskRecords !== totalRecords) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported'); + } + prefix = base + eocdPos - (centralDirectoryOffset + centralDirectorySize); + } + if (prefix < 0) { + throw new ERR_ZIP_INVALID_ARCHIVE('central directory does not fit inside the archive'); + } + if (totalRecords * 46 > centralDirectorySize) { + throw new ERR_ZIP_INVALID_ARCHIVE( + 'central directory record count is inconsistent with its size'); + } + return { + prefix, + totalRecords, + centralDirectoryOffset: centralDirectoryOffset + prefix, + centralDirectorySize, + comment: eocd.commentBuffer, + }; +} + +// Walk the contiguous run of `count` central directory file headers +// (sec. 4.3.12) into an array, rejecting multi-disk archives. +function readCentralDirectory(buffer, count) { + const result = []; + let pos = 0; + for (let index = 0; index < count; index++) { + const header = new CentralFileHeader(buffer, pos); + if (header.diskNumber !== 0) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported'); + } + ArrayPrototypePush(result, header); + pos += header.byteLength; + } + return result; +} + +module.exports = { + CentralFileHeader, + LocalFileHeader, + findArchiveEnd, + readCentralDirectory, +}; diff --git a/lib/zlib.js b/lib/zlib.js index e6c5c420d551..29c61b51fc6f 100644 --- a/lib/zlib.js +++ b/lib/zlib.js @@ -31,7 +31,9 @@ const { ObjectFreeze, ObjectKeys, ObjectSetPrototypeOf, + ReflectApply, Symbol, + SymbolHasInstance, Uint32Array, Uint8Array, } = primordials; @@ -51,6 +53,7 @@ const { Transform, finished } = require('stream'); const { assignFunctionName, deprecateInstantiation, + emitExperimentalWarning, } = require('internal/util'); const { isArrayBufferView, @@ -73,6 +76,16 @@ const { validateFiniteNumber, } = require('internal/validators'); const { FastBuffer } = require('internal/buffer'); +const { + ZipEntry, + ZipFile, + ZipBuffer, + createZipArchive, + createZipArchiveSync, + zipFiles, + getMaxZipContentSize, + setMaxZipContentSize, +} = require('internal/zip'); const kFlushFlag = Symbol('kFlushFlag'); const kError = Symbol('kError'); @@ -995,6 +1008,62 @@ function createProperty(ctor) { }; } +// ZIP archive support is experimental. The warning fires when the API is +// *used*, not when node:zlib is imported: the ESM facade reads every export to +// build its bindings (see BuiltinModule.syncExports), so warning on property +// access would fire on a bare `import 'node:zlib'`. Each public class is a thin +// subclass whose factory methods (and, for ZipBuffer, its constructor) warn; +// each function is a warn-on-call wrapper. A `[Symbol.hasInstance]` override +// keeps `instanceof` matching instances produced by the internal (unwrapped) +// implementation. The rest of node:zlib is stable and never warns. +function emitZipExperimentalWarning() { + emitExperimentalWarning('The zlib ZIP archive API'); +} + +function experimentalZipFunction(fn, thisArg) { + return function(...args) { + emitZipExperimentalWarning(); + return ReflectApply(fn, thisArg, args); + }; +} + +function experimentalZipProperty(value) { + return { __proto__: null, configurable: true, enumerable: true, value }; +} + +// Thin subclasses that gate *use* of the experimental ZIP API behind the +// warning while leaving `instanceof` (and the public class name) intact. +class ExperimentalZipEntry extends ZipEntry { + static [SymbolHasInstance](instance) { return instance instanceof ZipEntry; } +} + +class ExperimentalZipFile extends ZipFile { + static [SymbolHasInstance](instance) { return instance instanceof ZipFile; } +} + +class ExperimentalZipBuffer extends ZipBuffer { + static [SymbolHasInstance](instance) { return instance instanceof ZipBuffer; } + constructor(buffer) { emitZipExperimentalWarning(); super(buffer); } +} + +// Shadow each public factory with a warn-on-call wrapper, and restore the +// public class name that subclassing changed. +for (const { 0: Wrapper, 1: Raw, 2: factories } of [ + [ExperimentalZipEntry, ZipEntry, ['read', 'create', 'createSync', 'createStream', 'createSymlink']], + [ExperimentalZipFile, ZipFile, ['open', 'openSync']], + [ExperimentalZipBuffer, ZipBuffer, []], +]) { + for (const name of factories) { + ObjectDefineProperty(Wrapper, name, { + __proto__: null, + configurable: true, + writable: true, + value: experimentalZipFunction(Raw[name], Raw), + }); + } + ObjectDefineProperty(Wrapper, 'name', { __proto__: null, value: Raw.name }); +} + function crc32(data, value = 0) { if (typeof data !== 'string' && !isArrayBufferView(data)) { throw new ERR_INVALID_ARG_TYPE('data', ['Buffer', 'TypedArray', 'DataView', 'string'], data); @@ -1077,6 +1146,16 @@ ObjectDefineProperties(module.exports, { writable: false, value: ObjectFreeze(codes), }, + + // ZIP archive support (experimental). + ZipEntry: experimentalZipProperty(ExperimentalZipEntry), + ZipFile: experimentalZipProperty(ExperimentalZipFile), + ZipBuffer: experimentalZipProperty(ExperimentalZipBuffer), + createZipArchive: experimentalZipProperty(experimentalZipFunction(createZipArchive)), + createZipArchiveSync: experimentalZipProperty(experimentalZipFunction(createZipArchiveSync)), + zipFiles: experimentalZipProperty(experimentalZipFunction(zipFiles)), + getMaxZipContentSize: experimentalZipProperty(experimentalZipFunction(getMaxZipContentSize)), + setMaxZipContentSize: experimentalZipProperty(experimentalZipFunction(setMaxZipContentSize)), }); // These should be considered deprecated diff --git a/test/parallel/test-zlib-zip-coverage.js b/test/parallel/test-zlib-zip-coverage.js new file mode 100644 index 000000000000..437630df95cd --- /dev/null +++ b/test/parallel/test-zlib-zip-coverage.js @@ -0,0 +1,1318 @@ +'use strict'; + +require('../common'); + +const assert = require('node:assert'); +const tmpdir = require('../common/tmpdir'); +const fs = require('node:fs/promises'); +const zlib = require('node:zlib'); +const { test } = require('node:test'); + +tmpdir.refresh(); + +// Additional zip.js coverage beyond the other test-zlib-zip-*.js files: +// DOS date/time edge cases, the Zip64 extra-field parser's normal and +// out-of-range paths, the "data was prepended to the archive" central +// directory recovery scan, buffer-coercion variants, entry-metadata +// validation, streaming-entry state guards, the ZipBuffer/ZipFile +// iteration protocols, and several ZipFile (on-disk) error paths that +// aren't reachable through ZipBuffer alone. + +async function buildArchive(entries, comment) { + const chunks = []; + for await (const chunk of zlib.createZipArchive(entries, comment)) chunks.push(chunk); + return Buffer.concat(chunks); +} + +async function drain(iterable) { + const chunks = []; + for await (const chunk of iterable) chunks.push(chunk); + return Buffer.concat(chunks); +} + +// -- DOS date/time ----------------------------------------------------------- + +test('a zeroed DOS date/time field decodes to the 1980-01-01 epoch', async () => { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hi'), { method: 'store' }); + const archive = await buildArchive([entry]); + const tampered = Buffer.from(archive); + tampered.writeUInt16LE(0, 10); // local time + tampered.writeUInt16LE(0, 12); // local date + const centralStart = 30 + 'f.txt'.length + 'hi'.length; + tampered.writeUInt16LE(0, centralStart + 12); // central time + tampered.writeUInt16LE(0, centralStart + 14); // central date + + const [read] = zlib.ZipEntry.read(tampered); + assert.strictEqual(read.modified.getTime(), new Date(1980, 0, 1, 0, 0, 0).getTime()); +}); + +test('serializing an entry with an invalid modified Date is rejected', async () => { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hi'), { modified: new Date(NaN) }); + await assert.rejects(buildArchive([entry]), { code: 'ERR_INVALID_ARG_VALUE' }); +}); + +// -- Zip64 structures --------------------------------------------------------- + +function buildZip64Record({ diskNumber = 0, cdDiskNumber = 0, cdDiskRecords = 0n, + cdTotalRecords = 0n, cdSize = 0n, cdOffset = 0n } = {}) { + const buf = Buffer.allocUnsafe(56); + buf.writeUInt32LE(0x06064b50, 0); + buf.writeBigUInt64LE(44n, 4); + buf.writeUInt16LE((3 << 8) | 45, 12); // Made by Unix, version 4.5 + buf.writeUInt16LE(45, 14); + buf.writeUInt32LE(diskNumber, 16); + buf.writeUInt32LE(cdDiskNumber, 20); + buf.writeBigUInt64LE(cdDiskRecords, 24); + buf.writeBigUInt64LE(cdTotalRecords, 32); + buf.writeBigUInt64LE(cdSize, 40); + buf.writeBigUInt64LE(cdOffset, 48); + return buf; +} + +function buildZip64Locator({ recordDiskNumber = 0, recordOffset = 0n, totalDisks = 1 } = {}) { + const buf = Buffer.allocUnsafe(20); + buf.writeUInt32LE(0x07064b50, 0); + buf.writeUInt32LE(recordDiskNumber, 4); + buf.writeBigUInt64LE(recordOffset, 8); + buf.writeUInt32LE(totalDisks, 16); + return buf; +} + +function buildEocd({ diskNumber = 0, cdDiskNumber = 0, cdDiskRecords = 0, + totalRecords = 0, cdSize = 0, cdOffset = 0, comment = Buffer.alloc(0) } = {}) { + const buf = Buffer.allocUnsafe(22 + comment.length); + buf.writeUInt32LE(0x06054b50, 0); + buf.writeUInt16LE(diskNumber, 4); + buf.writeUInt16LE(cdDiskNumber, 6); + buf.writeUInt16LE(cdDiskRecords, 8); + buf.writeUInt16LE(totalRecords, 10); + buf.writeUInt32LE(cdSize, 12); + buf.writeUInt32LE(cdOffset, 16); + buf.writeUInt16LE(comment.length, 20); + comment.copy(buf, 22); + return buf; +} + +// A minimal (zero-entry) Zip64 archive: record + locator + classic EOCD, +// with the locator pointing directly at the record. +function buildMinimalZip64Archive({ record, locator, eocd } = {}) { + return Buffer.concat([ + buildZip64Record(record), + buildZip64Locator({ recordOffset: 0n, ...locator }), + buildEocd(eocd), + ]); +} + +test('a well-formed minimal Zip64 archive round-trips its comment', () => { + const buf = buildMinimalZip64Archive({ eocd: { comment: Buffer.from('hi') } }); + const zip = new zlib.ZipBuffer(buf); + assert.strictEqual(zip.size, 0); + assert.strictEqual(zip.comment, 'hi'); +}); + +test('a Zip64 locator declaring more than one disk is rejected', () => { + const buf = buildMinimalZip64Archive({ locator: { totalDisks: 2 } }); + assert.throws(() => [...zlib.ZipEntry.read(buf)], { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); +}); + +test('a Zip64 locator pointing at another disk is rejected', () => { + const buf = buildMinimalZip64Archive({ locator: { recordDiskNumber: 1 } }); + assert.throws(() => [...zlib.ZipEntry.read(buf)], { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); +}); + +test('a Zip64 record on another disk is rejected', () => { + const buf = buildMinimalZip64Archive({ record: { diskNumber: 1 } }); + assert.throws(() => [...zlib.ZipEntry.read(buf)], { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); +}); + +test('a Zip64 record with inconsistent disk record counts is rejected', () => { + const buf = buildMinimalZip64Archive({ record: { cdDiskRecords: 1n, cdTotalRecords: 2n } }); + assert.throws(() => [...zlib.ZipEntry.read(buf)], { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); +}); + +test('a Zip64 record is found by scanning backward when data was prepended', () => { + // The locator's declared offset is wrong (as if the archive had been + // prepended with extra bytes, e.g. a self-extractor stub, after the + // record/locator were written), but the record still physically sits + // immediately before the locator; findArchiveEnd() must recover it. + const buf = buildMinimalZip64Archive({ + locator: { recordOffset: 999_999n }, + eocd: { comment: Buffer.from('recovered') }, + }); + const zip = new zlib.ZipBuffer(buf); + assert.strictEqual(zip.comment, 'recovered'); +}); + +test('a required Zip64 record that cannot be found anywhere is rejected', () => { + // An EOCD overflow sentinel makes the Zip64 record mandatory; with the + // record's signature corrupted and the locator pointing nowhere useful, + // the archive is unreadable. + const buf = buildMinimalZip64Archive({ + locator: { recordOffset: 999_999n }, + eocd: { totalRecords: 0xffff, cdDiskRecords: 0xffff }, + }); + buf.writeUInt32LE(0xdeadbeef, 0); // Also corrupt the record actually present + assert.throws(() => [...zlib.ZipEntry.read(buf)], { + code: 'ERR_ZIP_INVALID_ARCHIVE', + message: /Zip64 end of central directory record not found/, + }); +}); + +test('a missing Zip64 record falls back to valid classic EOCD fields', () => { + // Same corrupted record, but no EOCD field carries an overflow sentinel: + // the classic fields fully describe the (empty) archive, so the stray + // locator signature - indistinguishable from comment bytes that happen to + // contain it - must not reject an otherwise valid archive. + const buf = buildMinimalZip64Archive({ locator: { recordOffset: 999_999n } }); + buf.writeUInt32LE(0xdeadbeef, 0); + assert.strictEqual([...zlib.ZipEntry.read(buf)].length, 0); +}); + +// Patches a single-entry, single-record archive's central header to carry a +// synthetic Zip64 extra field for whichever of {uncompressedSize, +// compressedSize, localFileHeaderOffset, diskNumber} are provided, setting +// the corresponding 32-/16-bit field to the sentinel value that tells +// CentralFileHeader to resolve it from the extra field instead. A leading, +// unrelated TLV record is always included ahead of the Zip64 one, so the +// "skip past a foreign record" loop iteration is exercised too. +function injectZip64Extra(archive, name, contentLength, fields) { + const centralHeaderStart = 30 + name.length + contentLength; + const nameStart = centralHeaderStart + 46; + const extraLengthOffset = centralHeaderStart + 30; + assert.strictEqual(archive.readUInt16LE(extraLengthOffset), 0); // sanity: no existing extra + + const dummyTlv = Buffer.from([0x99, 0x99, 0x04, 0x00, 0xde, 0xad, 0xbe, 0xef]); + const order = ['uncompressedSize', 'compressedSize', 'localFileHeaderOffset', 'diskNumber']; + const dataLength = order.reduce( + (total, key) => total + (key in fields ? (key === 'diskNumber' ? 4 : 8) : 0), 0); + const zip64Tlv = Buffer.allocUnsafe(4 + dataLength); + zip64Tlv.writeUInt16LE(0x0001, 0); + zip64Tlv.writeUInt16LE(dataLength, 2); + let pos = 4; + for (const key of order) { + if (!(key in fields)) continue; + if (key === 'diskNumber') { + zip64Tlv.writeUInt32LE(Number(fields[key]), pos); + pos += 4; + } else { + zip64Tlv.writeBigUInt64LE(BigInt(fields[key]), pos); + pos += 8; + } + } + const extra = Buffer.concat([dummyTlv, zip64Tlv]); + + const before = archive.subarray(0, nameStart + name.length); + const after = archive.subarray(nameStart + name.length); // comment + EOCD + const patched = Buffer.concat([before, extra, after]); + + patched.writeUInt16LE(extra.length, extraLengthOffset); + if ('uncompressedSize' in fields) patched.writeUInt32LE(0xffffffff, centralHeaderStart + 24); + if ('compressedSize' in fields) patched.writeUInt32LE(0xffffffff, centralHeaderStart + 20); + if ('localFileHeaderOffset' in fields) patched.writeUInt32LE(0xffffffff, centralHeaderStart + 42); + if ('diskNumber' in fields) patched.writeUInt16LE(0xffff, centralHeaderStart + 34); + + const eocdOffset = patched.length - 22; + assert.strictEqual(patched.readUInt32LE(eocdOffset), 0x06054b50); + const oldCdSize = patched.readUInt32LE(eocdOffset + 12); + patched.writeUInt32LE(oldCdSize + extra.length, eocdOffset + 12); + + return patched; +} + +test('a foreign Zip64 extra field naming only the fields it needs still resolves', async () => { + const name = 'f.txt'; + const content = Buffer.from('hello'); + const entry = await zlib.ZipEntry.create(name, content, { method: 'store' }); + const archive = await buildArchive([entry]); + + const patched = injectZip64Extra(archive, name, content.length, { + uncompressedSize: content.length, + compressedSize: content.length, + localFileHeaderOffset: 0, + diskNumber: 0, + }); + + const [read] = zlib.ZipEntry.read(patched); + assert.strictEqual(read.size, content.length); + assert.strictEqual(read.compressedSize, content.length); + assert.strictEqual((await read.content()).toString(), 'hello'); +}); + +test('a foreign Zip64 extra field carrying every field regardless of sentinels still resolves', async () => { + const name = 'f.txt'; + const content = Buffer.from('hello'); + const entry = await zlib.ZipEntry.create(name, content, { method: 'store' }); + const archive = await buildArchive([entry]); + + // APPNOTE 4.5.3 says Zip64 fields MUST appear only for the classic fields + // holding the overflow sentinel, but plenty of real writers emit all four + // regardless. Only the compressed size is a sentinel here, so a strictly + // packed parse would misread the uncompressed-size slot as the compressed + // size; the parser must fall back to the full fixed layout instead. + const centralHeaderStart = 30 + name.length + content.length; + const nameStart = centralHeaderStart + 46; + const tlv = Buffer.allocUnsafe(4 + 28); + tlv.writeUInt16LE(0x0001, 0); + tlv.writeUInt16LE(28, 2); + tlv.writeBigUInt64LE(0x1111n, 4); // Uncompressed size (classic field wins) + tlv.writeBigUInt64LE(BigInt(content.length), 12); // Compressed size + tlv.writeBigUInt64LE(0n, 20); // Local header offset (classic field wins) + tlv.writeUInt32LE(0, 28); // Disk number (classic field wins) + const before = archive.subarray(0, nameStart + name.length); + const after = archive.subarray(nameStart + name.length); + const patched = Buffer.concat([before, tlv, after]); + patched.writeUInt16LE(tlv.length, centralHeaderStart + 30); + patched.writeUInt32LE(0xffffffff, centralHeaderStart + 20); // compressedSize sentinel only + const eocdOffset = patched.length - 22; + patched.writeUInt32LE(patched.readUInt32LE(eocdOffset + 12) + tlv.length, eocdOffset + 12); + + const [read] = zlib.ZipEntry.read(patched); + assert.strictEqual(read.compressedSize, content.length); + assert.strictEqual(read.size, content.length); + assert.strictEqual((await read.content()).toString(), 'hello'); + + // Re-serialization drops the (now stale) Zip64 record - Zip64 data is + // regenerated from the final sizes - and the entry stays readable. + const rewritten = await buildArchive([read]); + const [reread] = zlib.ZipEntry.read(rewritten); + assert.strictEqual((await reread.content()).toString(), 'hello'); +}); + +test('a Zip64 extra field value beyond Number.MAX_SAFE_INTEGER is rejected', async () => { + const name = 'f.txt'; + const content = Buffer.from('hello'); + const entry = await zlib.ZipEntry.create(name, content, { method: 'store' }); + const archive = await buildArchive([entry]); + + const patched = injectZip64Extra(archive, name, content.length, { + uncompressedSize: 0xffffffffffffffffn, + }); + + const [read] = zlib.ZipEntry.read(patched); + assert.throws(() => read.size, { + code: 'ERR_ZIP_INVALID_ARCHIVE', + message: /exceeds the safe integer range/, + }); +}); + +// Injects a raw, already-built Zip64 extra-field TLV (rather than one built +// via injectZip64Extra()'s field map), to exercise the parser's own +// malformed/truncated-input rejections. +function injectRawZip64Extra(archive, name, contentLength, extraBytes) { + const centralHeaderStart = 30 + name.length + contentLength; + const nameStart = centralHeaderStart + 46; + const extraLengthOffset = centralHeaderStart + 30; + assert.strictEqual(archive.readUInt16LE(extraLengthOffset), 0); + const before = archive.subarray(0, nameStart + name.length); + const after = archive.subarray(nameStart + name.length); + const patched = Buffer.concat([before, extraBytes, after]); + patched.writeUInt16LE(extraBytes.length, extraLengthOffset); + patched.writeUInt32LE(0xffffffff, centralHeaderStart + 24); // uncompressedSize sentinel + const eocdOffset = patched.length - 22; + const oldCdSize = patched.readUInt32LE(eocdOffset + 12); + patched.writeUInt32LE(oldCdSize + extraBytes.length, eocdOffset + 12); + return patched; +} + +test('a Zip64 extra-field TLV whose declared size overflows the extra field is rejected', async () => { + const name = 'f.txt'; + const content = Buffer.from('hello'); + const entry = await zlib.ZipEntry.create(name, content, { method: 'store' }); + const archive = await buildArchive([entry]); + + const tlv = Buffer.allocUnsafe(8); + tlv.writeUInt16LE(0x0001, 0); + tlv.writeUInt16LE(100, 2); // claims 100 bytes of data, but none follow + const patched = injectRawZip64Extra(archive, name, content.length, tlv); + + const [read] = zlib.ZipEntry.read(patched); + assert.throws(() => read.size, { + code: 'ERR_ZIP_INVALID_ARCHIVE', + message: /extra field is malformed/, + }); +}); + +test('a Zip64 extra-field TLV too short for the field it claims to carry is rejected', async () => { + const name = 'f.txt'; + const content = Buffer.from('hello'); + const entry = await zlib.ZipEntry.create(name, content, { method: 'store' }); + const archive = await buildArchive([entry]); + + // Declares only 4 bytes of data, but a sentinel uncompressedSize needs 8. + const tlv = Buffer.allocUnsafe(8); + tlv.writeUInt16LE(0x0001, 0); + tlv.writeUInt16LE(4, 2); + tlv.writeUInt32LE(123, 4); + const patched = injectRawZip64Extra(archive, name, content.length, tlv); + + const [read] = zlib.ZipEntry.read(patched); + assert.throws(() => read.size, { + code: 'ERR_ZIP_INVALID_ARCHIVE', + message: /Zip64 extended information extra field is truncated/, + }); +}); + +// -- buffer coercion ----------------------------------------------------------- + +test('create() accepts a DataView, a non-Uint8Array TypedArray, and an ArrayBuffer', async () => { + const ab = new ArrayBuffer(4); + new Uint8Array(ab).set([1, 2, 3, 4]); + + const dv = new DataView(ab, 1, 2); + const fromDataView = await zlib.ZipEntry.create('dv.bin', dv); + assert.strictEqual((await fromDataView.content()).length, 2); + + const i32 = new Int32Array([10, 20, 30]); + const fromTypedArray = await zlib.ZipEntry.create('i32.bin', i32); + assert.strictEqual((await fromTypedArray.content()).length, 12); + + const fromArrayBuffer = await zlib.ZipEntry.create('ab.bin', ab); + assert.strictEqual((await fromArrayBuffer.content()).length, 4); +}); + +// -- entry-metadata validation ------------------------------------------------- + +test('create() validates comment length, modified type, and method value', async () => { + await assert.rejects( + zlib.ZipEntry.create('f.txt', Buffer.alloc(0), { comment: 'x'.repeat(70000) }), + { code: 'ERR_ZIP_ENTRY_TOO_LARGE' }, + ); + await assert.rejects( + zlib.ZipEntry.create('f.txt', Buffer.alloc(0), { modified: 123 }), + { code: 'ERR_INVALID_ARG_TYPE' }, + ); + await assert.rejects( + zlib.ZipEntry.create('f.txt', Buffer.alloc(0), { method: 'bogus' }), + { code: 'ERR_INVALID_ARG_VALUE' }, + ); +}); + +test('a directory entry must have empty content, for create(), createSync(), and createStream()', async () => { + await assert.rejects( + zlib.ZipEntry.create('dir/', Buffer.from('x')), { code: 'ERR_INVALID_ARG_VALUE' }); + assert.throws( + () => zlib.ZipEntry.createSync('dir/', Buffer.from('x')), { code: 'ERR_INVALID_ARG_VALUE' }); + assert.throws( + () => zlib.ZipEntry.createStream('dir/', (async function* () {})()), { code: 'ERR_INVALID_ARG_VALUE' }); +}); + +test('createZipArchive()/createZipArchiveSync() validate the archive comment length', async () => { + await assert.rejects(drain(zlib.createZipArchive([], 'x'.repeat(70000))), + { code: 'ERR_ZIP_ARCHIVE_TOO_LARGE' }); + assert.throws(() => [...zlib.createZipArchiveSync([], 'x'.repeat(70000))], + { code: 'ERR_ZIP_ARCHIVE_TOO_LARGE' }); +}); + +// -- streaming-entry state guards ---------------------------------------------- + +test('a pending streaming entry rejects size/crc32/compressedSize/content access', () => { + const streaming = zlib.ZipEntry.createStream( + 'big.bin', (async function* () { yield Buffer.from('x'); })()); + assert.throws(() => streaming.size, { code: 'ERR_INVALID_STATE' }); + assert.throws(() => streaming.crc32, { code: 'ERR_INVALID_STATE' }); + assert.throws(() => streaming.compressedSize, { code: 'ERR_INVALID_STATE' }); + assert.throws(() => streaming.contentIterator(), { code: 'ERR_INVALID_STATE' }); + assert.throws(() => streaming.contentSync(), { code: 'ERR_INVALID_STATE' }); +}); + +// Once a streaming entry has been serialized on its own (via createZipArchive, +// not into a writable ZipFile that would promote it), its source is spent and +// there is nothing to read back. Reads must fail with a clean state error, not +// silently decode an empty buffer and report ERR_ZIP_ENTRY_CORRUPT. +test('a spent (serialized-but-unpromoted) streaming entry rejects reads cleanly', async () => { + const entry = zlib.ZipEntry.createStream('s.txt', (async function* () { yield Buffer.from('hello'); })()); + await drain(entry); + await assert.rejects(entry.content(), { code: 'ERR_INVALID_STATE' }); + assert.throws(() => entry.contentSync(), { code: 'ERR_INVALID_STATE' }); + assert.throws(() => entry.contentIterator(), { code: 'ERR_INVALID_STATE' }); +}); + +test('a streaming entry can only be serialized once', async () => { + const entry = zlib.ZipEntry.createStream('a.bin', (async function* () { yield Buffer.from('x'); })()); + await drain(entry); + await assert.rejects(drain(entry), { code: 'ERR_INVALID_STATE' }); +}); + +test('a streaming entry rejects a non-Uint8Array chunk from its source', async () => { + async function* badSource() { + yield Buffer.alloc(0); // An empty chunk is silently skipped + yield 'not a buffer'; + } + const entry = zlib.ZipEntry.createStream('b.bin', badSource()); + await assert.rejects(drain(entry), { code: 'ERR_INVALID_ARG_TYPE' }); +}); + +test('a streaming entry can use zstd compression end-to-end', async () => { + const payload = 'zstd stream content '.repeat(50); + async function* source() { yield Buffer.from(payload); } + const entry = zlib.ZipEntry.createStream('c.bin', source(), { method: 'zstd' }); + const archive = await buildArchive([entry]); + + const [read] = zlib.ZipEntry.read(archive); + assert.strictEqual(read.method, 93); + assert.strictEqual((await read.content()).toString(), payload); +}); + +test('an error from a streaming entry\'s source propagates and cleans up (deflate and zstd)', async () => { + for (const method of ['deflate', 'zstd']) { + async function* badSource() { + yield Buffer.from('some data before the error'); + throw new Error(`source blew up (${method})`); + } + const entry = zlib.ZipEntry.createStream('big.bin', badSource(), { method }); + await assert.rejects(drain(entry), { message: `source blew up (${method})` }); + } +}); + +// -- contentIterator() / decodeMemberStream() error paths ------------------------ + +test('contentIterator() enforces the same guards as content() and contentSync()', async () => { + // Encrypted. + { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('secret'), { method: 'store' }); + const archive = await buildArchive([entry]); + const tampered = Buffer.from(archive); + tampered.writeUInt16LE(tampered.readUInt16LE(6) | 0x0001, 6); + const centralStart = 30 + 'f.txt'.length + 'secret'.length; + tampered.writeUInt16LE(tampered.readUInt16LE(centralStart + 8) | 0x0001, centralStart + 8); + const [read] = zlib.ZipEntry.read(tampered); + await assert.rejects(drain(read.contentIterator()), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); + } + // Unsupported compression method. + { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hi'), { method: 'store' }); + const archive = await buildArchive([entry]); + const tampered = Buffer.from(archive); + tampered.writeUInt16LE(1, 8); + const centralStart = 30 + 'f.txt'.length + 'hi'.length; + tampered.writeUInt16LE(1, centralStart + 10); + const [read] = zlib.ZipEntry.read(tampered); + await assert.rejects(drain(read.contentIterator()), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); + } + // maxSize enforced up front. + { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello world')); + await assert.rejects(drain(entry.contentIterator({ maxSize: 1 })), { code: 'ERR_ZIP_ENTRY_TOO_LARGE' }); + } + // Declared-size mismatch. + { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello world'), { method: 'store' }); + const archive = await buildArchive([entry]); + const tampered = Buffer.from(archive); + const centralStart = 30 + 'f.txt'.length + 'hello world'.length; + tampered.writeUInt32LE(1, centralStart + 24); + const [read] = zlib.ZipEntry.read(tampered); + await assert.rejects(drain(read.contentIterator()), { code: 'ERR_ZIP_ENTRY_CORRUPT' }); + } + // CRC-32 mismatch, and disabling verification. + { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello world'), { method: 'store' }); + const archive = await buildArchive([entry]); + const tampered = Buffer.from(archive); + tampered[30 + 'f.txt'.length] ^= 0xff; + const [read] = zlib.ZipEntry.read(tampered); + await assert.rejects(drain(read.contentIterator()), { code: 'ERR_ZIP_ENTRY_CORRUPT' }); + const unverified = await drain(read.contentIterator({ verify: false })); + assert.strictEqual(unverified.length, 'hello world'.length); + } +}); + +// -- content()/contentSync() zstd-specific branches ---------------------------- + +test('content() and contentSync() enforce maxSize and detect corruption for zstd entries', async () => { + { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('y'.repeat(5000)), { method: 'zstd' }); + const archive = await buildArchive([entry]); + const [read] = zlib.ZipEntry.read(archive); + assert.strictEqual(read.method, 93); + await assert.rejects(read.content({ maxSize: 10 }), { code: 'ERR_ZIP_ENTRY_TOO_LARGE' }); + } + { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('z'.repeat(200)), { method: 'zstd' }); + const archive = await buildArchive([entry]); + const tampered = Buffer.from(archive); + const contentStart = 30 + 'f.txt'.length; + tampered.fill(0xff, contentStart, contentStart + 4); // Break the zstd frame itself + const [read] = zlib.ZipEntry.read(tampered); + await assert.rejects(read.content(), { code: 'ERR_ZIP_ENTRY_CORRUPT' }); + assert.throws(() => read.contentSync(), { code: 'ERR_ZIP_ENTRY_CORRUPT' }); + } +}); + +// `decodeMemberSync()` (used by `ZipEntry.prototype.contentSync()`) duplicates +// `decodeMemberStream()`'s guards for its own, separate synchronous code +// path; exercise them through a disk-backed ZipFile. +test('ZipFile getSync().contentSync() enforces the same guards via decodeMemberSync()', async () => { + async function writeTempArchive(archive, suffix) { + const filePath = tmpdir.resolve(`zip-coverage-contentsync-${suffix}.zip`); + await fs.writeFile(filePath, archive); + return filePath; + } + + // Encrypted. + { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('secret'), { method: 'store' }); + const archive = await buildArchive([entry]); + const tampered = Buffer.from(archive); + tampered.writeUInt16LE(tampered.readUInt16LE(6) | 0x0001, 6); + const centralStart = 30 + 'f.txt'.length + 'secret'.length; + tampered.writeUInt16LE(tampered.readUInt16LE(centralStart + 8) | 0x0001, centralStart + 8); + const filePath = await writeTempArchive(tampered, 'encrypted'); + const zf = zlib.ZipFile.openSync(filePath); + assert.throws(() => zf.getSync('f.txt').contentSync(), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); + zf.closeSync(); + await fs.unlink(filePath); + } + // maxSize, for both the deflate and the zstd decode branch. + for (const method of ['deflate', 'zstd']) { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('y'.repeat(5000)), { method }); + const archive = await buildArchive([entry]); + const filePath = await writeTempArchive(archive, `maxsize-${method}`); + const zf = zlib.ZipFile.openSync(filePath); + assert.throws(() => zf.getSync('f.txt').contentSync({ maxSize: 10 }), { code: 'ERR_ZIP_ENTRY_TOO_LARGE' }); + zf.closeSync(); + await fs.unlink(filePath); + } + // A genuine decompression failure (not just a CRC mismatch after a + // successful decode). + { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('y'.repeat(500)), { method: 'deflate' }); + const archive = await buildArchive([entry]); + const tampered = Buffer.from(archive); + const contentStart = 30 + 'f.txt'.length; + tampered.fill(0xff, contentStart, contentStart + 4); + const filePath = await writeTempArchive(tampered, 'deflate-corrupt'); + const zf = zlib.ZipFile.openSync(filePath); + assert.throws(() => zf.getSync('f.txt').contentSync(), { code: 'ERR_ZIP_ENTRY_CORRUPT' }); + zf.closeSync(); + await fs.unlink(filePath); + } + // Declared-size mismatch ("produced N bytes, expected M"). + { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello world'), { method: 'store' }); + const archive = await buildArchive([entry]); + const tampered = Buffer.from(archive); + const centralStart = 30 + 'f.txt'.length + 'hello world'.length; + tampered.writeUInt32LE(1, centralStart + 24); + const filePath = await writeTempArchive(tampered, 'size-mismatch'); + const zf = zlib.ZipFile.openSync(filePath); + assert.throws(() => zf.getSync('f.txt').contentSync(), { code: 'ERR_ZIP_ENTRY_CORRUPT' }); + zf.closeSync(); + await fs.unlink(filePath); + } +}); + +test('contentIterator() rejects an entry that inflates to less than its declared size', async () => { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hi'), { method: 'store' }); + const archive = await buildArchive([entry]); + const tampered = Buffer.from(archive); + const centralStart = 30 + 'f.txt'.length + 'hi'.length; + tampered.writeUInt32LE(1000, centralStart + 24); // Declared size grown beyond reality + const [read] = zlib.ZipEntry.read(tampered); + await assert.rejects(drain(read.contentIterator()), { + code: 'ERR_ZIP_ENTRY_CORRUPT', + message: /is truncated/, + }); +}); + +// -- ZipBuffer / ZipFile iteration protocols ----------------------------------- + +test('ZipBuffer exposes Map-like forEach/values/entries/iteration/toStringTag', async () => { + const archive = await buildArchive([ + await zlib.ZipEntry.create('a.txt', Buffer.from('1')), + await zlib.ZipEntry.create('b.txt', Buffer.from('2')), + ]); + const zip = new zlib.ZipBuffer(archive); + + const seen = []; + zip.forEach((entry, key, self) => { + seen.push(key); + assert.strictEqual(self, zip); + assert.strictEqual(entry.name, key); + }); + assert.deepStrictEqual(seen.sort(), ['a.txt', 'b.txt']); + + assert.deepStrictEqual([...zip.values()].map((e) => e.name).sort(), ['a.txt', 'b.txt']); + assert.deepStrictEqual([...zip.entries()].map(([k]) => k).sort(), ['a.txt', 'b.txt']); + assert.deepStrictEqual([...zip].map(([k]) => k).sort(), ['a.txt', 'b.txt']); + assert.strictEqual(Object.prototype.toString.call(zip), '[object ZipBuffer]'); + assert.throws(() => zip.addEntry({}), { code: 'ERR_INVALID_ARG_TYPE' }); +}); + +test('ZipFile exposes the same iteration protocol, plus its Sync counterparts', async () => { + const archive = await buildArchive([ + await zlib.ZipEntry.create('a.txt', Buffer.from('1')), + await zlib.ZipEntry.create('b.txt', Buffer.from('2')), + ]); + const filePath = tmpdir.resolve('zip-coverage-iteration.zip'); + await fs.writeFile(filePath, archive); + + const zf = await zlib.ZipFile.open(filePath); + try { + const pending = []; + zf.forEach((valuePromise) => pending.push(valuePromise)); + await Promise.all(pending); // Let every dangling get() settle before closing + + zf.forEachSync(() => {}); + assert.deepStrictEqual([...zf.valuesSync()].map((e) => e.name).sort(), ['a.txt', 'b.txt']); + assert.deepStrictEqual([...zf.entriesSync()].map(([k]) => k).sort(), ['a.txt', 'b.txt']); + assert.deepStrictEqual([...zf.keys()].sort(), ['a.txt', 'b.txt']); + assert.strictEqual(zf.size, 2); + assert.strictEqual(Object.prototype.toString.call(zf), '[object ZipFile]'); + + const names = []; + for await (const entry of zf) names.push(entry.name); + assert.deepStrictEqual(names.sort(), ['a.txt', 'b.txt']); + + // Synchronous iteration (Symbol.iterator) yields [name, Promise]. + const syncPairs = [...zf]; + assert.deepStrictEqual(syncPairs.map(([k]) => k).sort(), ['a.txt', 'b.txt']); + const resolved = await Promise.all(syncPairs.map(([, v]) => v)); + assert.deepStrictEqual(resolved.map((e) => e.name).sort(), ['a.txt', 'b.txt']); + } finally { + await zf[Symbol.asyncDispose](); + } + + const writable = await zlib.ZipFile.open(filePath, { writable: true }); + await assert.rejects(writable.addEntry({}), { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => writable.addEntrySync({}), { code: 'ERR_INVALID_ARG_TYPE' }); + await writable.close(); + + const zf2 = zlib.ZipFile.openSync(filePath); + zf2[Symbol.dispose](); + + await fs.unlink(filePath); +}); + +// -- ZipFile (on-disk) error paths --------------------------------------------- + +test('ZipFile.open()/openSync() reject a file with no end-of-central-directory record', async () => { + const filePath = tmpdir.resolve('zip-coverage-garbage.zip'); + await fs.writeFile(filePath, Buffer.from('not a zip file, just garbage bytes')); + try { + await assert.rejects(zlib.ZipFile.open(filePath), { code: 'ERR_ZIP_INVALID_ARCHIVE' }); + assert.throws(() => zlib.ZipFile.openSync(filePath), { code: 'ERR_ZIP_INVALID_ARCHIVE' }); + } finally { + await fs.unlink(filePath); + } +}); + +test('ZipFile get()/getSync()/stream() reject a missing entry name', async () => { + const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]); + const filePath = tmpdir.resolve('zip-coverage-notfound.zip'); + await fs.writeFile(filePath, archive); + const zf = await zlib.ZipFile.open(filePath); + try { + await assert.rejects(zf.get('missing'), { code: 'ERR_ZIP_ENTRY_NOT_FOUND' }); + assert.throws(() => zf.getSync('missing'), { code: 'ERR_ZIP_ENTRY_NOT_FOUND' }); + await assert.rejects(zf.stream('missing'), { code: 'ERR_ZIP_ENTRY_NOT_FOUND' }); + } finally { + await zf.close(); + await fs.unlink(filePath); + } +}); + +test('a local file header offset pointing into the central directory is rejected at open', async () => { + const name = 'a.txt'; + const content = Buffer.from('hello'); + const archive = await buildArchive([await zlib.ZipEntry.create(name, content, { method: 'store' })]); + const centralHeaderStart = 30 + name.length + content.length; + const tampered = Buffer.from(archive); + // Point the local file header offset at the central directory itself: the + // member's claimed data range then crosses the directory, which the + // open-time overlap check rejects before anything is read. + tampered.writeUInt32LE(centralHeaderStart, centralHeaderStart + 42); + const filePath = tmpdir.resolve('zip-coverage-cd-offset.zip'); + await fs.writeFile(filePath, tampered); + try { + await assert.rejects(zlib.ZipFile.open(filePath), + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /overlaps/ }); + assert.throws(() => zlib.ZipFile.openSync(filePath), + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /overlaps/ }); + } finally { + await fs.unlink(filePath); + } +}); + +test('a declared compressed size reaching past the end of the file is rejected', async () => { + const name = 'a.txt'; + const content = Buffer.from('hello'); + const archive = await buildArchive([await zlib.ZipEntry.create(name, content, { method: 'store' })]); + const centralHeaderStart = 30 + name.length + content.length; + const tampered = Buffer.from(archive); + tampered.writeUInt32LE(archive.length * 10, centralHeaderStart + 20); // compressedSize + const filePath = tmpdir.resolve('zip-coverage-eof.zip'); + await fs.writeFile(filePath, tampered); + + try { + // Member bounds are validated against the file size up front, so the lie + // is caught at open time - before any buffered read path could allocate + // `compressedSize` bytes for it. + await assert.rejects(zlib.ZipFile.open(filePath), + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /out of bounds/ }); + assert.throws(() => zlib.ZipFile.openSync(filePath), + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /out of bounds/ }); + } finally { + await fs.unlink(filePath); + } +}); + +test('a Zip64-declared compressed size of kMaxLength is rejected at open', async () => { + const name = 'f.txt'; + const content = Buffer.from('hello'); + const archive = await buildArchive([await zlib.ZipEntry.create(name, content, { method: 'store' })]); + + // A Zip64-declared compressedSize equal to kMaxLength is the largest value + // that still parses (readSafeUint64 caps at the safe-integer ceiling, which + // is kMaxLength on 64-bit), but such a member cannot lie inside this tiny + // file, so the open-time member bounds check rejects the archive before + // the buffering read paths (whose own kMaxLength guard remains as defense + // in depth for 32-bit, where kMaxLength is smaller than real file sizes) + // could ever allocate for it. + const centralHeaderStart = 30 + name.length + content.length; + const nameStart = centralHeaderStart + 46; + const tlv = Buffer.allocUnsafe(4 + 8); + tlv.writeUInt16LE(0x0001, 0); + tlv.writeUInt16LE(8, 2); + tlv.writeBigUInt64LE(BigInt(require('node:buffer').kMaxLength), 4); + const before = archive.subarray(0, nameStart + name.length); + const after = archive.subarray(nameStart + name.length); + const patched = Buffer.concat([before, tlv, after]); + patched.writeUInt16LE(tlv.length, centralHeaderStart + 30); + patched.writeUInt32LE(0xffffffff, centralHeaderStart + 20); // compressedSize sentinel + const eocdOffset = patched.length - 22; + patched.writeUInt32LE(patched.readUInt32LE(eocdOffset + 12) + tlv.length, eocdOffset + 12); + + const toolargeFilePath = tmpdir.resolve('zip-coverage-toolarge.zip'); + await fs.writeFile(toolargeFilePath, patched); + try { + await assert.rejects(zlib.ZipFile.open(toolargeFilePath), + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /out of bounds/ }); + assert.throws(() => zlib.ZipFile.openSync(toolargeFilePath), + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /out of bounds/ }); + } finally { + await fs.unlink(toolargeFilePath); + } +}); + +// -- forcing Zip64 structures without a multi-gigabyte archive ----------------- + +test('createZipArchiveSync() also switches to Zip64 structures at 0xFFFF entries', () => { + const ZIP64_EOCD_SIGNATURE = Buffer.from([0x50, 0x4b, 0x06, 0x06]); + const entries = []; + for (let i = 0; i < 0x10000; i++) { + entries.push(zlib.ZipEntry.createSync(`entry-${i}`, Buffer.alloc(0), { method: 'store' })); + } + const chunks = []; + for (const chunk of zlib.createZipArchiveSync(entries)) chunks.push(chunk); + const archive = Buffer.concat(chunks); + assert.ok(archive.includes(ZIP64_EOCD_SIGNATURE)); + assert.strictEqual([...zlib.ZipEntry.read(archive)].length, 0x10000); +}, { timeout: 120_000 }); + +// -- createZipArchive()'s single options argument, baseOffset, and Readable return -- + +const CENTRAL_FILE_HEADER_SIGNATURE = Buffer.from([0x50, 0x4b, 0x01, 0x02]); + +test('createZipArchive() returns a pipeable, async-iterable, non-object-mode Readable', async () => { + const { Readable } = require('node:stream'); + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hi')); + const stream = zlib.createZipArchive([entry]); + assert.ok(stream instanceof Readable); + assert.strictEqual(stream.readableObjectMode, false); + const chunks = []; + for await (const chunk of stream) chunks.push(chunk); + assert.strictEqual([...zlib.ZipEntry.read(Buffer.concat(chunks))][0].name, 'f.txt'); +}); + +test('createZipArchive()/createZipArchiveSync() take a plain string as comment shorthand', async () => { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hi')); + const zip = new zlib.ZipBuffer(await drain(zlib.createZipArchive([entry], 'hello'))); + assert.strictEqual(zip.comment, 'hello'); + + const entrySync = zlib.ZipEntry.createSync('f.txt', Buffer.from('hi')); + const chunks = [...zlib.createZipArchiveSync([entrySync], 'hello-sync')]; + const zipSync = new zlib.ZipBuffer(Buffer.concat(chunks)); + assert.strictEqual(zipSync.comment, 'hello-sync'); +}); + +test('createZipArchive()/createZipArchiveSync() take an { comment, baseOffset } options object', async () => { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hi')); + const zip = new zlib.ZipBuffer(await drain(zlib.createZipArchive([entry], { comment: 'hi there' }))); + assert.strictEqual(zip.comment, 'hi there'); +}); + +test('createZipArchive()/createZipArchiveSync() reject a non-string, non-object options argument', async () => { + await assert.rejects(drain(zlib.createZipArchive([], 123)), { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => [...zlib.createZipArchiveSync([], 123)], { code: 'ERR_INVALID_ARG_TYPE' }); + await assert.rejects(drain(zlib.createZipArchive([], null)), { code: 'ERR_INVALID_ARG_TYPE' }); +}); + +test('createZipArchive()/createZipArchiveSync() validate options.baseOffset', async () => { + await assert.rejects(drain(zlib.createZipArchive([], { baseOffset: -1 })), { code: 'ERR_OUT_OF_RANGE' }); + await assert.rejects(drain(zlib.createZipArchive([], { baseOffset: 1.5 })), { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => [...zlib.createZipArchiveSync([], { baseOffset: -1 })], { code: 'ERR_OUT_OF_RANGE' }); +}); + +test('options.baseOffset shifts every recorded offset, so a prefixed archive is ' + + 'self-describing without relying on prefix auto-detection', async () => { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello offset')); + const prefix = Buffer.from('#!/bin/sh\nexit 0\n'); + + const shifted = await drain(zlib.createZipArchive([entry], { baseOffset: prefix.byteLength })); + const shiftedCentral = shifted.indexOf(CENTRAL_FILE_HEADER_SIGNATURE); + assert.strictEqual(shifted.readUInt32LE(shiftedCentral + 42), prefix.byteLength); + + const entryUnshifted = await zlib.ZipEntry.create('f.txt', Buffer.from('hello offset')); + const unshifted = await drain(zlib.createZipArchive([entryUnshifted])); + const unshiftedCentral = unshifted.indexOf(CENTRAL_FILE_HEADER_SIGNATURE); + assert.strictEqual(unshifted.readUInt32LE(unshiftedCentral + 42), 0); + + const combined = Buffer.concat([prefix, shifted]); + const zip = new zlib.ZipBuffer(combined); + assert.strictEqual((await zip.get('f.txt').content()).toString(), 'hello offset'); +}); + +test('zipBuffer.toBuffer()/toBufferSync() forward the same string/options-object shorthand', async () => { + const zip = new zlib.ZipBuffer(await drain(zlib.createZipArchive([]))); + await zip.add('f.txt', Buffer.from('hi')); + + assert.strictEqual(new zlib.ZipBuffer(await zip.toBuffer('a comment')).comment, 'a comment'); + assert.strictEqual(new zlib.ZipBuffer(await zip.toBuffer({ comment: 'an object comment' })).comment, + 'an object comment'); + assert.strictEqual(new zlib.ZipBuffer(zip.toBufferSync('sync comment')).comment, 'sync comment'); + + const prefix = Buffer.from('junk\n'); + const shifted = await zip.toBuffer({ baseOffset: prefix.byteLength }); + const shiftedCentral = shifted.indexOf(CENTRAL_FILE_HEADER_SIGNATURE); + assert.strictEqual(shifted.readUInt32LE(shiftedCentral + 42), prefix.byteLength); +}); + +// -- round-trip fidelity of foreign metadata ---------------------------------- + +test('a non-Unix "version made by" host survives re-serialization', async () => { + const name = 'f.txt'; + const content = Buffer.from('hello'); + const entry = await zlib.ZipEntry.create(name, content, { method: 'store' }); + const archive = Buffer.from(await buildArchive([entry])); + + // Rewrite the entry as DOS-made (host byte 0, sec. 4.4.2) with DOS-style + // external attributes (0x20, the archive bit): the high 16 bits are NOT + // Unix permissions for this host, so `mode` must read 0, and the host byte + // must survive re-serialization - stamping it as Unix would turn the + // zeroed high bits into Unix mode 0000 for downstream extractors. + const centralHeaderStart = 30 + name.length + content.length; + archive[centralHeaderStart + 5] = 0; // "version made by" host byte + archive.writeUInt32LE(0x20, centralHeaderStart + 38); // external attributes + + const [read] = zlib.ZipEntry.read(archive); + assert.strictEqual(read.mode, 0); + assert.strictEqual(read.isSymlink, false); + + const rewritten = await buildArchive([read]); + const rewrittenCentral = rewritten.indexOf(CENTRAL_FILE_HEADER_SIGNATURE); + assert.strictEqual(rewritten[rewrittenCentral + 5], 0); // host byte preserved + assert.strictEqual(rewritten.readUInt32LE(rewrittenCentral + 38), 0x20); + const [reread] = zlib.ZipEntry.read(rewritten); + assert.strictEqual(reread.mode, 0); + // The finalized entry itself now answers from its snapshotted metadata, + // which must apply the same made-by gate. + assert.strictEqual(read.mode, 0); + assert.strictEqual(read.isSymlink, false); +}); + +// -- content() ownership ------------------------------------------------------- + +test('content()/contentSync() of a stored in-memory entry return caller-owned memory', async () => { + const archive = await buildArchive( + [await zlib.ZipEntry.create('s.txt', Buffer.from('hello'), { method: 'store' })]); + const zip = new zlib.ZipBuffer(archive); + const entry = zip.get('s.txt'); + + // If content() returned a view into the archive, zeroing it would corrupt + // the entry and the next read would fail CRC-32 verification. + (await entry.content()).fill(0); + assert.deepStrictEqual(await entry.content(), Buffer.from('hello')); + entry.contentSync().fill(0); + assert.deepStrictEqual(entry.contentSync(), Buffer.from('hello')); +}); + +// Injects a raw extra-field blob into a single-entry archive's central +// header without touching any size/offset field (unlike injectRawZip64Extra +// above, which also plants an overflow sentinel). +function injectCentralExtra(archive, name, contentLength, extraBytes) { + const centralHeaderStart = 30 + name.length + contentLength; + const nameStart = centralHeaderStart + 46; + const extraLengthOffset = centralHeaderStart + 30; + assert.strictEqual(archive.readUInt16LE(extraLengthOffset), 0); + const before = archive.subarray(0, nameStart + name.length); + const after = archive.subarray(nameStart + name.length); + const patched = Buffer.concat([before, extraBytes, after]); + patched.writeUInt16LE(extraBytes.length, extraLengthOffset); + const eocdOffset = patched.length - 22; + patched.writeUInt32LE(patched.readUInt32LE(eocdOffset + 12) + extraBytes.length, eocdOffset + 12); + return patched; +} + +// -- input coercion ------------------------------------------------------------ + +test('a plain Uint8Array is accepted anywhere binary input is, and non-binary input is rejected', async () => { + const archive = await buildArchive([await zlib.ZipEntry.create('u.txt', Buffer.from('hi'))]); + const viaU8 = new zlib.ZipBuffer(new Uint8Array(archive)); + assert.strictEqual((await viaU8.get('u.txt').content()).toString(), 'hi'); + const entry = await zlib.ZipEntry.create('v.txt', new Uint8Array([1, 2, 3]), { method: 'store' }); + assert.deepStrictEqual(await entry.content(), Buffer.from([1, 2, 3])); + assert.throws(() => new zlib.ZipBuffer('not binary'), { code: 'ERR_INVALID_ARG_TYPE' }); + await assert.rejects(zlib.ZipEntry.create('w.txt', 'not binary'), { code: 'ERR_INVALID_ARG_TYPE' }); +}); + +test('forEach() passes thisArg through on ZipBuffer and ZipFile', async () => { + const archive = await buildArchive([await zlib.ZipEntry.create('t.txt', Buffer.from('x'))]); + const zip = new zlib.ZipBuffer(archive); + const ctx = { hits: 0 }; + zip.forEach(function() { this.hits++; }, ctx); + assert.strictEqual(ctx.hits, 1); + + const filePath = tmpdir.resolve('zip-coverage-foreach.zip'); + await fs.writeFile(filePath, archive); + const zf = await zlib.ZipFile.open(filePath); + try { + const fileCtx = { hits: 0 }; + zf.forEach(function() { this.hits++; }, fileCtx); + zf.forEachSync(function() { this.hits++; }, fileCtx); + assert.strictEqual(fileCtx.hits, 2); + } finally { + await zf.close(); + await fs.unlink(filePath); + } +}); + +// -- entry metadata before serialization ---------------------------------------- + +test('freshly created entries expose their metadata before serialization', async () => { + const sym = zlib.ZipEntry.createSymlink('link', 'target'); + assert.strictEqual(sym.mode, 0o777); // The symlink default + assert.strictEqual(sym.isSymlink, true); + assert.strictEqual(sym.isFile, false); + + const entry = await zlib.ZipEntry.create('m.txt', Buffer.from('x'), { + comment: 'note', mode: 0o640, modified: new Date(1700000000000), + }); + assert.deepStrictEqual(entry.nameBuffer, Buffer.from('m.txt')); + assert.strictEqual(entry.comment, 'note'); + assert.strictEqual(entry.modified.getTime(), 1700000000000); + assert.strictEqual(entry.mode, 0o640); + assert.strictEqual(entry.isSymlink, false); +}); + +// -- extra-field edge cases ------------------------------------------------------ + +test('a Unicode Path extra field with an unknown version is ignored', async () => { + const name = 'plain.txt'; + const content = Buffer.from('hi'); + const archive = await buildArchive([await zlib.ZipEntry.create(name, content, { method: 'store' })]); + const utf8Name = Buffer.from('other.txt'); + const up = Buffer.allocUnsafe(4 + 5 + utf8Name.length); + up.writeUInt16LE(0x7075, 0); + up.writeUInt16LE(5 + utf8Name.length, 2); + up.writeUInt8(2, 4); // Unsupported version: only version 1 is defined + up.writeUInt32LE(0, 5); // CRC-32 (irrelevant; the version check comes first) + utf8Name.copy(up, 9); + const patched = injectCentralExtra(archive, name, content.length, up); + const [read] = zlib.ZipEntry.read(patched); + assert.strictEqual(read.name, 'plain.txt'); +}); + +test('malformed or empty timestamp extra fields fall back to the DOS time', async () => { + const name = 't.txt'; + const content = Buffer.from('x'); + const modified = new Date(2024, 3, 5, 10, 20, 30); + const archive = await buildArchive( + [await zlib.ZipEntry.create(name, content, { method: 'store', modified })]); + const ntfs = Buffer.allocUnsafe(4 + 12); + ntfs.writeUInt16LE(0x000a, 0); + ntfs.writeUInt16LE(12, 2); + ntfs.writeUInt32LE(0, 4); // Reserved dword + ntfs.writeUInt16LE(1, 8); // Tag 1... + ntfs.writeUInt16LE(200, 10); // ...claiming 200 bytes: overruns its record + ntfs.writeUInt32LE(0, 12); + const ut = Buffer.from([0x55, 0x54, 0x01, 0x00, 0x00]); // "UT", flags 0: no mtime present + const ux = Buffer.allocUnsafe(4 + 4); + ux.writeUInt16LE(0x5855, 0); + ux.writeUInt16LE(4, 2); // Too short for the atime+mtime pair + ux.writeUInt32LE(123, 4); + const patched = injectCentralExtra( + archive, name, content.length, Buffer.concat([ntfs, ut, ux])); + const [read] = zlib.ZipEntry.read(patched); + assert.strictEqual(read.modified.getTime(), modified.getTime()); +}); + +test('a preserved timestamp extra survives re-serialization without duplication', async () => { + const odd = new Date(1700000001000); // An odd second: gets a "UT" extra + const name = 'odd.txt'; + const content = Buffer.from('x'); + const first = await buildArchive( + [await zlib.ZipEntry.create(name, content, { modified: odd, method: 'store' })]); + const [read] = zlib.ZipEntry.read(first); + const second = await buildArchive([read]); + const [reread] = zlib.ZipEntry.read(second); + assert.strictEqual(reread.modified.getTime(), odd.getTime()); + // Exactly one extended-timestamp record: the preserved one, no new copy. + const central = second.indexOf(CENTRAL_FILE_HEADER_SIGNATURE); + const nameLength = second.readUInt16LE(central + 28); + const extraLength = second.readUInt16LE(central + 30); + const extra = second.subarray( + central + 46 + nameLength, central + 46 + nameLength + extraLength); + let utRecords = 0; + for (let pos = 0; pos + 4 <= extra.length;) { + if (extra.readUInt16LE(pos) === 0x5455) utRecords++; + pos += 4 + extra.readUInt16LE(pos + 2); + } + assert.strictEqual(utRecords, 1); +}); + +// -- file-backed metadata and I/O failure paths ---------------------------------- + +test('modified falls back to the central directory when the local header is malformed', async () => { + const name = 'm.txt'; + const content = Buffer.from('hello'); + const modified = new Date(2024, 3, 5, 10, 20, 30); + const archive = Buffer.from(await buildArchive( + [await zlib.ZipEntry.create(name, content, { method: 'store', modified })])); + archive.writeUInt32LE(0xdeadbeef, 0); // Corrupt the local header signature + const filePath = tmpdir.resolve('zip-coverage-badlocal.zip'); + await fs.writeFile(filePath, archive); + const zf = await zlib.ZipFile.open(filePath); + try { + const entry = await zf.get(name); + // Metadata resolution swallows the local-header failure... + assert.strictEqual(entry.modified.getTime(), modified.getTime()); + // ...but content reads fail loudly on it. + await assert.rejects(entry.content(), { code: 'ERR_ZIP_INVALID_ARCHIVE' }); + } finally { + await zf.close(); + await fs.unlink(filePath); + } +}); + +test('a file that shrinks after open is rejected as unexpected EOF when read', async () => { + // The open-time bounds and overlap checks guarantee every member lies + // inside the file *as it was at open time*; a file truncated afterwards + // (the only way a positioned read can now hit EOF) must fail cleanly. + const name = 'a.txt'; + const content = Buffer.from('hello'); + const archive = await buildArchive( + [await zlib.ZipEntry.create(name, content, { method: 'store' })]); + const filePath = tmpdir.resolve('zip-coverage-eof-local.zip'); + await fs.writeFile(filePath, archive); + const zf = await zlib.ZipFile.open(filePath); + const zfSync = zlib.ZipFile.openSync(filePath); + try { + await fs.truncate(filePath, 10); // Cuts into the local file header + await assert.rejects((await zf.get(name)).content(), + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /unexpected end of file/ }); + assert.throws(() => zfSync.getSync(name).contentSync(), + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /unexpected end of file/ }); + } finally { + await zf.close(); + zfSync.closeSync(); + await fs.unlink(filePath); + } +}); + +test('a failed streaming addEntry() restores the archive and the queue continues', async () => { + const filePath = tmpdir.resolve('zip-coverage-rollback.zip'); + await fs.writeFile(filePath, await buildArchive( + [await zlib.ZipEntry.create('seed.txt', Buffer.from('seed'))])); + const zip = await zlib.ZipFile.open(filePath, { writable: true }); + try { + async function* failing() { + yield Buffer.from('partial data that overwrote the central directory'); + throw new Error('source failed'); + } + await assert.rejects( + zip.addEntry(zlib.ZipEntry.createStream('bad.txt', failing())), + /source failed/); + // The rewrite restored the directory in place, and the mutation queue + // keeps accepting work after a failure. + await zip.add('good.txt', Buffer.from('ok')); + } finally { + await zip.close(); + } + const reread = await zlib.ZipFile.open(filePath); + try { + assert.deepStrictEqual([...reread.keys()].sort(), ['good.txt', 'seed.txt']); + assert.strictEqual((await (await reread.get('seed.txt')).content()).toString(), 'seed'); + assert.strictEqual((await (await reread.get('good.txt')).content()).toString(), 'ok'); + } finally { + await reread.close(); + await fs.unlink(filePath); + } +}); + +test('opening a missing archive surfaces the file-system error', async () => { + const missing = tmpdir.resolve('zip-coverage-missing.zip'); + await assert.rejects(zlib.ZipFile.open(missing), { code: 'ENOENT' }); + assert.throws(() => zlib.ZipFile.openSync(missing), { code: 'ENOENT' }); + await assert.rejects( + drain(zlib.zipFiles([[tmpdir.resolve('missing-src.txt'), 'a.txt']], { followSymlinks: false })), + { code: 'ENOENT' }); +}); + +test('using a ZipFile after close() is rejected cleanly', async () => { + const filePath = tmpdir.resolve('zip-coverage-after-close.zip'); + await fs.writeFile(filePath, await buildArchive( + [await zlib.ZipEntry.create('a.txt', Buffer.from('data'))])); + + const zip = await zlib.ZipFile.open(filePath, { writable: true }); + const entry = await zip.get('a.txt'); + await zip.close(); + // A read through a retained entry is rejected by the shared closed-state, + // never by falling through to the (possibly reused) raw descriptor. + await assert.rejects(entry.content(), { code: 'ERR_INVALID_STATE' }); + // Further mutations are rejected the same way... + await assert.rejects( + zip.addEntry(await zlib.ZipEntry.create('b.txt', Buffer.from('x'))), + { code: 'ERR_INVALID_STATE' }); + // ...and a second close() is an idempotent no-op, not a double-close. + await zip.close(); + + const zipSync = zlib.ZipFile.openSync(filePath, { writable: true }); + const entrySync = zipSync.getSync('a.txt'); + zipSync.closeSync(); + assert.throws(() => entrySync.contentSync(), { code: 'ERR_INVALID_STATE' }); + assert.throws( + () => zipSync.addEntrySync(zlib.ZipEntry.createSync('b.txt', Buffer.from('x'))), + { code: 'ERR_INVALID_STATE' }); + zipSync.closeSync(); + await fs.unlink(filePath); +}); + +// -- writer-side Zip64 fields ---------------------------------------------------- + +test('an entry starting beyond 4 GiB records its offset in a Zip64 extra field', async () => { + const BASE = 0x1_0000_0000; // 4 GiB + const entry = await zlib.ZipEntry.create('far.txt', Buffer.from('hello'), { method: 'store' }); + const archive = await drain(zlib.createZipArchive([entry], { baseOffset: BASE })); + const central = archive.indexOf(CENTRAL_FILE_HEADER_SIGNATURE); + assert.notStrictEqual(central, -1); + assert.strictEqual(archive.readUInt16LE(central + 6), 45); // Version needed: Zip64 + assert.strictEqual(archive.readUInt32LE(central + 42), 0xffffffff); // Offset sentinel + const nameLength = archive.readUInt16LE(central + 28); + const extraStart = central + 46 + nameLength; + assert.strictEqual(archive.readUInt16LE(extraStart), 0x0001); + assert.strictEqual(archive.readUInt16LE(extraStart + 2), 8); + assert.strictEqual(archive.readBigUInt64LE(extraStart + 4), BigInt(BASE)); + // The trailer is promoted to Zip64 too: the directory offset overflows. + assert.notStrictEqual(archive.indexOf(Buffer.from([0x50, 0x4b, 0x06, 0x06])), -1); +}); + +test('extra fields that no longer fit their 16-bit length field are rejected on write', async () => { + const name = 'big-extra.txt'; + const content = Buffer.from('hi'); + const archive = await buildArchive( + [await zlib.ZipEntry.create(name, content, { method: 'store' })]); + // Give the entry a preserved (unknown-ID) extra field near the 65,535-byte + // cap; serializing it at an offset beyond 4 GiB must add a 12-byte Zip64 + // record, pushing the total over the cap. + const filler = Buffer.allocUnsafe(4 + 65526); + filler.writeUInt16LE(0x9999, 0); + filler.writeUInt16LE(65526, 2); + filler.fill(0xab, 4); + const patched = injectCentralExtra(archive, name, content.length, filler); + const [read] = zlib.ZipEntry.read(patched); + await assert.rejects( + drain(zlib.createZipArchive([read], { baseOffset: 0x1_0000_0000 })), + { code: 'ERR_ZIP_ENTRY_TOO_LARGE', message: /extra fields/ }); +}); + +// -- archive-end discovery edge cases -------------------------------------------- + +test('a Zip64 EOCD record pushed out of the tail by its data sector is found by re-reading', async () => { + const PAD = 80000; // Larger than the fixed-size tail read + const record = Buffer.alloc(56 + PAD); + record.writeUInt32LE(0x06064b50, 0); + record.writeBigUInt64LE(BigInt(44 + PAD), 4); // Remainder size, incl. the sector + record.writeUInt16LE((3 << 8) | 45, 12); + record.writeUInt16LE(45, 14); + const locator = Buffer.alloc(20); + locator.writeUInt32LE(0x07064b50, 0); + locator.writeBigUInt64LE(0n, 8); // The record starts at offset 0 + locator.writeUInt32LE(1, 16); + const comment = Buffer.from('sector'); + const eocd = Buffer.alloc(22 + comment.length); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt32LE(0xffffffff, 12); // Size sentinel: the Zip64 record is required + eocd.writeUInt16LE(comment.length, 20); + comment.copy(eocd, 22); + const bytes = Buffer.concat([record, locator, eocd]); + + // In one buffer the record is directly reachable... + assert.strictEqual(new zlib.ZipBuffer(bytes).size, 0); + + // ...but a tail read must notice it starts before the tail and re-read. + const filePath = tmpdir.resolve('zip-coverage-sector.zip'); + await fs.writeFile(filePath, bytes); + const zf = await zlib.ZipFile.open(filePath); + try { + assert.strictEqual(zf.size, 0); + assert.strictEqual(zf.comment, 'sector'); + } finally { + await zf.close(); + } + const zfSync = zlib.ZipFile.openSync(filePath); + try { + assert.strictEqual(zfSync.size, 0); + } finally { + zfSync.closeSync(); + } + await fs.unlink(filePath); +}); + +test('a locator offset beyond the safe-integer range falls back to classic fields', () => { + const buf = buildMinimalZip64Archive({ locator: { recordOffset: 0xffffffffffffffffn } }); + buf.writeUInt32LE(0xdeadbeef, 0); // Corrupt the record so only the fallback can succeed + assert.strictEqual([...zlib.ZipEntry.read(buf)].length, 0); +}); + +test('ZipFile rejects a central header on another disk', async () => { + const name = 'a.txt'; + const content = Buffer.from('x'); + const archive = Buffer.from(await buildArchive( + [await zlib.ZipEntry.create(name, content, { method: 'store' })])); + const centralHeaderStart = 30 + name.length + content.length; + archive.writeUInt16LE(1, centralHeaderStart + 34); // Disk number + const filePath = tmpdir.resolve('zip-coverage-disk.zip'); + await fs.writeFile(filePath, archive); + await assert.rejects(zlib.ZipFile.open(filePath), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); + assert.throws(() => zlib.ZipFile.openSync(filePath), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); + await fs.unlink(filePath); +}); + +test('zipFiles() keeps a directory mapping name that already ends in a slash', async () => { + const dir = tmpdir.resolve('zip-coverage-dir'); + await fs.mkdir(dir, { recursive: true }); + const archive = await drain(zlib.zipFiles([[dir, 'mapped/']])); + const [entry] = zlib.ZipEntry.read(archive); + assert.strictEqual(entry.name, 'mapped/'); + assert.strictEqual(entry.isDirectory, true); +}); + +test('a streamed entry with the store method round-trips', async () => { + async function* source() { + yield Buffer.from('stored '); + yield Buffer.from('stream'); + } + const entry = zlib.ZipEntry.createStream('s.txt', source(), { method: 'store' }); + const archive = await buildArchive([entry]); + const [read] = zlib.ZipEntry.read(archive); + assert.strictEqual(read.method, 0); + assert.strictEqual((await read.content()).toString(), 'stored stream'); +}); + +test('createSync() honors an explicit store method', () => { + const entry = zlib.ZipEntry.createSync('s.bin', Buffer.from([1, 2, 3]), { method: 'store' }); + assert.strictEqual(entry.method, 0); + assert.strictEqual(entry.compressed, false); + assert.deepStrictEqual(entry.contentSync(), Buffer.from([1, 2, 3])); +}); diff --git a/test/parallel/test-zlib-zip-edgecases.js b/test/parallel/test-zlib-zip-edgecases.js new file mode 100644 index 000000000000..7d77a07ed6d0 --- /dev/null +++ b/test/parallel/test-zlib-zip-edgecases.js @@ -0,0 +1,188 @@ +'use strict'; + +// Malformed / adversarial / boundary archives, drawn from the ZIP +// parser-differential and torture-test literature (Go archive/zip testdata, +// USENIX'25 semantic-gap paper, PyPI/uv ZIP-confusion advisories, Info-ZIP). +// Node core does not extract to disk, so path-traversal safety is the caller's +// job - these tests pin that our reader surfaces names verbatim and resolves +// every ambiguity from the authoritative central directory. + +require('../common'); + +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const { test } = require('node:test'); + +const SIG_LOCAL = 0x04034b50; +const SIG_CENTRAL = 0x02014b50; +const SIG_EOCD = 0x06054b50; + +async function buildArchive(entries, comment) { + const chunks = []; + for await (const c of zlib.createZipArchive(entries, comment)) chunks.push(c); + return Buffer.concat(chunks); +} + +// One-entry (stored) archive with independent control of the local vs central +// name, flags, method and sizes - the levers ZIP-confusion attacks pull. +function buildEntryArchive(opts) { + const name = opts.nameBuffer; + const localName = opts.localNameBuffer ?? name; + const content = opts.content ?? Buffer.alloc(0); + const flags = opts.flags ?? 0; + const method = opts.method ?? 0; + const localExtra = opts.localExtra ?? Buffer.alloc(0); + const centralExtra = opts.centralExtra ?? Buffer.alloc(0); + const crc = zlib.crc32(content) >>> 0; + + const local = Buffer.alloc(30); + local.writeUInt32LE(SIG_LOCAL, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(flags, 6); + local.writeUInt16LE(method, 8); + local.writeUInt16LE(0x21, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(content.length, 18); + local.writeUInt32LE(content.length, 22); + local.writeUInt16LE(localName.length, 26); + local.writeUInt16LE(localExtra.length, 28); + const localRecord = Buffer.concat([local, localName, localExtra, content]); + + const central = Buffer.alloc(46); + central.writeUInt32LE(SIG_CENTRAL, 0); + central.writeUInt16LE(0x0314, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(flags, 8); + central.writeUInt16LE(method, 10); + central.writeUInt16LE(0x21, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(content.length, 20); + central.writeUInt32LE(content.length, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt16LE(centralExtra.length, 30); + central.writeUInt32LE(0, 42); + const centralRecord = Buffer.concat([central, name, centralExtra]); + + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(SIG_EOCD, 0); + eocd.writeUInt16LE(1, 8); + eocd.writeUInt16LE(1, 10); + eocd.writeUInt32LE(centralRecord.length, 12); + eocd.writeUInt32LE(localRecord.length, 16); + return Buffer.concat([localRecord, centralRecord, eocd]); +} + +// -- parser-confusion / semantic-gap defenses -------------------------------- + +test('the central directory is authoritative when the local header disagrees', () => { + // Local header claims "fake.txt"; central directory says "real.txt". + const archive = buildEntryArchive({ + nameBuffer: Buffer.from('real.txt'), + localNameBuffer: Buffer.from('fake.txt'), + content: Buffer.from('payload'), + }); + const [entry] = zlib.ZipEntry.read(archive); + assert.strictEqual(entry.name, 'real.txt'); + assert.strictEqual(entry.contentSync().toString(), 'payload'); +}); + +test('duplicate central-directory names: read() yields all, get() takes the last', async () => { + const archive = await buildArchive([ + await zlib.ZipEntry.create('dup.txt', Buffer.from('first')), + await zlib.ZipEntry.create('dup.txt', Buffer.from('second')), + ]); + assert.deepStrictEqual([...zlib.ZipEntry.read(archive)].map((e) => e.name), ['dup.txt', 'dup.txt']); + using zip = new zlib.ZipBuffer(archive); + assert.strictEqual(zip.size, 1); + assert.strictEqual(zip.get('dup.txt').contentSync().toString(), 'second'); +}); + +test('path-unsafe names are surfaced verbatim, never normalized or rejected', () => { + for (const name of ['../../etc/passwd', '/etc/passwd', 'a\\b\\c.txt', 'C:\\evil.dll']) { + const [entry] = zlib.ZipEntry.read(buildEntryArchive({ nameBuffer: Buffer.from(name) })); + assert.strictEqual(entry.name, name); + } +}); + +// -- structural boundaries ---------------------------------------------------- + +test('data prepended before the archive (SFX stub) is tolerated via prefix detection', async () => { + const inner = await buildArchive([ + await zlib.ZipEntry.create('a.txt', Buffer.from('hello')), + await zlib.ZipEntry.create('b.txt', Buffer.from('world')), + ]); + const prefixed = Buffer.concat([Buffer.alloc(1000, 0x7f), inner]); // stub bytes + using zip = new zlib.ZipBuffer(prefixed); + assert.deepStrictEqual([...zip.keys()].sort(), ['a.txt', 'b.txt']); + assert.strictEqual(zip.get('a.txt').contentSync().toString(), 'hello'); +}); + +test('an empty archive (EOCD only) reads as zero entries', () => { + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(SIG_EOCD, 0); + assert.deepStrictEqual([...zlib.ZipEntry.read(eocd)], []); + using zip = new zlib.ZipBuffer(eocd); + assert.strictEqual(zip.size, 0); +}); + +test('directory entries and zero-byte file entries round-trip', async () => { + const archive = await buildArchive([ + await zlib.ZipEntry.create('dir/', Buffer.alloc(0)), + await zlib.ZipEntry.create('empty.txt', Buffer.alloc(0)), + ]); + using zip = new zlib.ZipBuffer(archive); + assert.strictEqual(zip.get('dir/').isDirectory, true); + const empty = zip.get('empty.txt'); + assert.strictEqual(empty.isDirectory, false); + assert.strictEqual(empty.contentSync().length, 0); + assert.strictEqual(empty.crc32, 0); +}); + +test('an archive comment at the maximum length (65535 bytes) round-trips', async () => { + const comment = 'z'.repeat(0xffff); + const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))], comment); + using zip = new zlib.ZipBuffer(archive); + assert.strictEqual(zip.comment.length, 0xffff); + assert.strictEqual(zip.get('a.txt').contentSync().toString(), 'x'); +}); + +// -- unsupported features are rejected, not misread -------------------------- + +test('a traditional-encrypted entry (bit 0) is rejected', () => { + const [entry] = zlib.ZipEntry.read(buildEntryArchive({ + nameBuffer: Buffer.from('f'), + content: Buffer.from('secret'), + flags: 0x0001, + })); + assert.throws(() => entry.contentSync(), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); +}); + +test('a WinZip-AES entry (method 99, bit 0, 0x9901 extra) is rejected as encrypted', () => { + const aes = Buffer.concat([ + Buffer.from([0x01, 0x99]), // extra id 0x9901 + Buffer.from([0x07, 0x00]), // size 7 + Buffer.from([0x01, 0x00]), // AE-1 version + Buffer.from([0x41, 0x45]), // "AE" + Buffer.from([0x03]), // AES-256 + Buffer.from([0x00, 0x00]), // Actual method (store) + ]); + const [entry] = zlib.ZipEntry.read(buildEntryArchive({ + nameBuffer: Buffer.from('f'), + content: Buffer.from('cipher'), + flags: 0x0001, + method: 99, + centralExtra: aes, + localExtra: aes, + })); + assert.throws(() => entry.contentSync(), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); +}); + +test('a Zip64 sentinel size with no Zip64 extra field is rejected', () => { + const archive = buildEntryArchive({ nameBuffer: Buffer.from('f'), content: Buffer.from('hi') }); + // Force the central uncompressed-size field to the sentinel without adding a + // Zip64 extra field, so "look in the Zip64 record" points at nothing. + const cd = archive.readUInt32LE(archive.length - 22 + 16); + archive.writeUInt32LE(0xffffffff, cd + 24); + assert.throws(() => [...zlib.ZipEntry.read(archive)][0].size, + { code: 'ERR_ZIP_INVALID_ARCHIVE' }); +}); diff --git a/test/parallel/test-zlib-zip-encoding.js b/test/parallel/test-zlib-zip-encoding.js new file mode 100644 index 000000000000..f1091ac1f745 --- /dev/null +++ b/test/parallel/test-zlib-zip-encoding.js @@ -0,0 +1,244 @@ +'use strict'; + +// Reading foreign-encoder edge cases faithfully: non-UTF-8 (CP437) and +// Unicode-Path-extra filenames, raw name bytes, full Unix mode bits and +// symlink type, and modification times carried in extra fields rather than +// the coarse DOS date/time. These synthesize the exact header bytes real +// tools (Windows Explorer, Info-ZIP, 7-Zip, WinRAR) emit, since our own +// writer only ever produces UTF-8 names and DOS timestamps. + +require('../common'); + +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const { test } = require('node:test'); + +const SIG_LOCAL = 0x04034b50; +const SIG_CENTRAL = 0x02014b50; +const SIG_EOCD = 0x06054b50; + +// Build a minimal one-entry (stored) archive with full control over every +// header field, so tests can reproduce exactly what a foreign encoder writes. +function buildEntryArchive(opts) { + const name = opts.nameBuffer; + const content = opts.content ?? Buffer.alloc(0); + const flags = opts.flags ?? 0; + const localExtra = opts.localExtra ?? Buffer.alloc(0); + const centralExtra = opts.centralExtra ?? Buffer.alloc(0); + const external = (opts.externalAttrs ?? 0) >>> 0; + const versionMadeBy = opts.versionMadeBy ?? 0x0314; // host 3 (Unix), v2.0 + const dosTime = opts.dosTime ?? 0; + const dosDate = opts.dosDate ?? 0x21; // 1980-01-01 + const crc = zlib.crc32(content) >>> 0; + + const local = Buffer.alloc(30); + local.writeUInt32LE(SIG_LOCAL, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(flags, 6); + local.writeUInt16LE(0, 8); // store + local.writeUInt16LE(dosTime, 10); + local.writeUInt16LE(dosDate, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(content.length, 18); + local.writeUInt32LE(content.length, 22); + local.writeUInt16LE(name.length, 26); + local.writeUInt16LE(localExtra.length, 28); + const localRecord = Buffer.concat([local, name, localExtra, content]); + + const central = Buffer.alloc(46); + central.writeUInt32LE(SIG_CENTRAL, 0); + central.writeUInt16LE(versionMadeBy, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(flags, 8); + central.writeUInt16LE(0, 10); + central.writeUInt16LE(dosTime, 12); + central.writeUInt16LE(dosDate, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(content.length, 20); + central.writeUInt32LE(content.length, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt16LE(centralExtra.length, 30); + central.writeUInt16LE(0, 32); // comment length + central.writeUInt16LE(0, 34); + central.writeUInt16LE(0, 36); + central.writeUInt32LE(external, 38); + central.writeUInt32LE(0, 42); // local header offset + const centralRecord = Buffer.concat([central, name, centralExtra]); + + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(SIG_EOCD, 0); + eocd.writeUInt16LE(1, 8); + eocd.writeUInt16LE(1, 10); + eocd.writeUInt32LE(centralRecord.length, 12); + eocd.writeUInt32LE(localRecord.length, 16); + return Buffer.concat([localRecord, centralRecord, eocd]); +} + +// -- filename encoding -------------------------------------------------------- + +test('a bit-11-clear name is decoded as CP437, not mangled as UTF-8', () => { + // 0x81 is "ü" in CP437; as a lone UTF-8 byte it is invalid (would be U+FFFD). + const nameBuffer = Buffer.from([0x63, 0x61, 0x66, 0x81]); // "caf" + ü + const [entry] = zlib.ZipEntry.read(buildEntryArchive({ nameBuffer })); + assert.strictEqual(entry.name, 'cafü'); + assert.deepStrictEqual(entry.nameBuffer, nameBuffer); +}); + +test('a bit-11-set name is decoded as UTF-8, with raw bytes on nameBuffer', () => { + const nameBuffer = Buffer.from('café-名前.txt', 'utf8'); + const [entry] = zlib.ZipEntry.read(buildEntryArchive({ nameBuffer, flags: 0x0800 })); + assert.strictEqual(entry.name, 'café-名前.txt'); + assert.deepStrictEqual(entry.nameBuffer, nameBuffer); +}); + +test('a valid Unicode Path extra field (0x7075) overrides the CP437 name', () => { + const cp437Name = Buffer.from([0x63, 0x61, 0x66, 0x81]); // "cafü" in CP437 + const utf8 = Buffer.from('café.txt', 'utf8'); + const up = Buffer.concat([ + Buffer.from([0x75, 0x70]), // id 0x7075 + (() => { const b = Buffer.alloc(2); b.writeUInt16LE(5 + utf8.length, 0); return b; })(), + Buffer.from([0x01]), // version 1 + (() => { const b = Buffer.alloc(4); b.writeUInt32LE(zlib.crc32(cp437Name) >>> 0, 0); return b; })(), + utf8, + ]); + const [entry] = zlib.ZipEntry.read(buildEntryArchive({ nameBuffer: cp437Name, centralExtra: up })); + assert.strictEqual(entry.name, 'café.txt'); +}); + +test('a Unicode Path extra field with a stale CRC is ignored (falls back to CP437)', () => { + const cp437Name = Buffer.from([0x63, 0x61, 0x66, 0x81]); + const utf8 = Buffer.from('renamed.txt', 'utf8'); + const up = Buffer.concat([ + Buffer.from([0x75, 0x70]), + (() => { const b = Buffer.alloc(2); b.writeUInt16LE(5 + utf8.length, 0); return b; })(), + Buffer.from([0x01]), + Buffer.from([0xde, 0xad, 0xbe, 0xef]), // wrong CRC of the standard name + utf8, + ]); + const [entry] = zlib.ZipEntry.read(buildEntryArchive({ nameBuffer: cp437Name, centralExtra: up })); + assert.strictEqual(entry.name, 'cafü'); // stale extra ignored +}); + +test('ZipBuffer.get() keys by the decoded (CP437) name', () => { + const nameBuffer = Buffer.from([0x81, 0x2e, 0x74, 0x78, 0x74]); // "ü.txt" + using zip = new zlib.ZipBuffer(buildEntryArchive({ nameBuffer, content: Buffer.from('x') })); + assert.strictEqual(zip.has('ü.txt'), true); + assert.strictEqual(zip.get('ü.txt').contentSync().toString(), 'x'); +}); + +// -- Unix mode + symlink ------------------------------------------------------ + +test('setuid/setgid/sticky bits round-trip through a read', async () => { + for (const mode of [0o4755, 0o2750, 0o1777, 0o755, 0o640]) { + const entry = await zlib.ZipEntry.create('f', Buffer.from('x'), { mode }); + const chunks = []; + for await (const c of zlib.createZipArchive([entry])) chunks.push(c); + const [read] = zlib.ZipEntry.read(Buffer.concat(chunks)); + assert.strictEqual(read.mode, mode, `0o${mode.toString(8)} -> 0o${read.mode.toString(8)}`); + } +}); + +test('a symlink entry is reported as a symlink, not a file', () => { + const target = Buffer.from('../target'); + const S_IFLNK = 0o120000; + const archive = buildEntryArchive({ + nameBuffer: Buffer.from('link'), + content: target, + externalAttrs: ((S_IFLNK | 0o777) << 16) >>> 0, + }); + const [entry] = zlib.ZipEntry.read(archive); + assert.strictEqual(entry.isSymlink, true); + assert.strictEqual(entry.isFile, false); + assert.strictEqual(entry.isDirectory, false); + assert.strictEqual(entry.mode, 0o777); + assert.strictEqual(entry.contentSync().toString(), '../target'); +}); + +test('external attributes from a non-Unix host expose no mode and no symlink', () => { + const S_IFLNK = 0o120000; + const archive = buildEntryArchive({ + nameBuffer: Buffer.from('x'), + externalAttrs: ((S_IFLNK | 0o777) << 16) >>> 0, // Looks like a symlink... + versionMadeBy: 0x0014, // ...but host 0 (FAT/DOS), so not Unix mode + }); + const [entry] = zlib.ZipEntry.read(archive); + assert.strictEqual(entry.mode, 0); + assert.strictEqual(entry.isSymlink, false); +}); + +// -- modification time from extra fields -------------------------------------- + +// 2017-10-31T21:11:57Z, deliberately not representable in the 2-second DOS +// field so an extra-field time can be told apart from the DOS fallback. +const MTIME_SECS = 1509484317; + +function extField(id, body) { + const head = Buffer.alloc(4); + head.writeUInt16LE(id, 0); + head.writeUInt16LE(body.length, 2); + return Buffer.concat([head, body]); +} + +test('the extended-timestamp extra field (0x5455) sets the modification time', () => { + const body = Buffer.alloc(5); + body.writeUInt8(0x01, 0); // mtime present + body.writeInt32LE(MTIME_SECS, 1); + const [entry] = zlib.ZipEntry.read(buildEntryArchive({ + nameBuffer: Buffer.from('f'), + centralExtra: extField(0x5455, body), + dosDate: 0x4a21, // A different (2017) DOS date, to prove the extra wins + })); + assert.strictEqual(entry.modified.getTime(), MTIME_SECS * 1000); +}); + +test('the NTFS extra field (0x000a) sets a high-resolution modification time', () => { + const ns100 = (BigInt(MTIME_SECS) + 11644473600n) * 10000000n; + const times = Buffer.alloc(24); + times.writeBigUInt64LE(ns100, 0); // mtime + times.writeBigUInt64LE(ns100, 8); // atime + times.writeBigUInt64LE(ns100, 16); // ctime + const body = Buffer.concat([ + Buffer.alloc(4), // reserved + extField(0x0001, times), // attribute tag 1 + ]); + const [entry] = zlib.ZipEntry.read(buildEntryArchive({ + nameBuffer: Buffer.from('f'), + localExtra: extField(0x000a, body), // NTFS times live in the local header + })); + assert.strictEqual(entry.modified.getTime(), MTIME_SECS * 1000); +}); + +test('the Info-ZIP Unix extra field (0x5855) sets the modification time', () => { + const body = Buffer.alloc(8); + body.writeInt32LE(1, 0); // atime + body.writeInt32LE(MTIME_SECS, 4); // mtime + const [entry] = zlib.ZipEntry.read(buildEntryArchive({ + nameBuffer: Buffer.from('f'), + centralExtra: extField(0x5855, body), + })); + assert.strictEqual(entry.modified.getTime(), MTIME_SECS * 1000); +}); + +test('NTFS time is preferred over the extended timestamp when both are present', () => { + const extBody = Buffer.alloc(5); + extBody.writeUInt8(0x01, 0); + extBody.writeInt32LE(MTIME_SECS - 3600, 1); // an hour earlier + const ns100 = (BigInt(MTIME_SECS) + 11644473600n) * 10000000n; + const times = Buffer.alloc(24); + times.writeBigUInt64LE(ns100, 0); + const ntfsBody = Buffer.concat([Buffer.alloc(4), extField(0x0001, times)]); + const [entry] = zlib.ZipEntry.read(buildEntryArchive({ + nameBuffer: Buffer.from('f'), + localExtra: Buffer.concat([extField(0x000a, ntfsBody), extField(0x5455, extBody)]), + })); + assert.strictEqual(entry.modified.getTime(), MTIME_SECS * 1000); +}); + +test('with no timestamp extra field the DOS date/time is used', () => { + // DOS date 0x4f21 = year 1980+((0x4f21>>9)&0x7f)=1980+39=2019, month 1, day 1. + const [entry] = zlib.ZipEntry.read(buildEntryArchive({ + nameBuffer: Buffer.from('f'), + dosDate: 0x4f21, + })); + assert.strictEqual(entry.modified.getFullYear(), 2019); +}); diff --git a/test/parallel/test-zlib-zip-experimental-warning.js b/test/parallel/test-zlib-zip-experimental-warning.js new file mode 100644 index 000000000000..021b8360c136 --- /dev/null +++ b/test/parallel/test-zlib-zip-experimental-warning.js @@ -0,0 +1,59 @@ +'use strict'; + +// The zlib ZIP archive API is experimental and must warn when it is *used*, +// but importing or requiring node:zlib - or merely accessing a ZIP export, +// as the ESM loader does when building its named-export bindings - must not +// warn. See lib/zlib.js. + +require('../common'); + +const assert = require('node:assert'); +const { spawnSync } = require('node:child_process'); +const { test } = require('node:test'); + +const WARNING = /ExperimentalWarning: The zlib ZIP archive API/; + +function run(...args) { + return spawnSync(process.execPath, args, { encoding: 'utf8' }); +} + +test('importing node:zlib (ESM) does not emit the ZIP experimental warning', () => { + const src = "import 'node:zlib';\n" + + "import { ZipFile, ZipBuffer, createZipArchive } from 'node:zlib';\n" + + 'void ZipFile; void ZipBuffer; void createZipArchive;'; + const r = run('--input-type=module', '-e', src); + assert.strictEqual(r.status, 0, r.stderr); + assert.doesNotMatch(r.stderr, WARNING); +}); + +test('requiring node:zlib and only reading ZIP exports does not warn', () => { + const src = "const z = require('zlib');\n" + + 'void z.ZipFile; void z.ZipEntry; void z.ZipBuffer; void z.createZipArchive;\n' + + 'void ({} instanceof z.ZipBuffer);'; // The instanceof check must not warn. + const r = run('-e', src); + assert.strictEqual(r.status, 0, r.stderr); + assert.doesNotMatch(r.stderr, WARNING); +}); + +test('using a ZIP export emits the experimental warning (CJS)', () => { + const r = run('-e', "require('zlib').createZipArchive([]);"); + assert.strictEqual(r.status, 0, r.stderr); + assert.match(r.stderr, WARNING); +}); + +test('using a ZIP export emits the experimental warning (ESM)', () => { + const src = "import { createZipArchive } from 'node:zlib';\ncreateZipArchive([]);"; + const r = run('--input-type=module', '-e', src); + assert.strictEqual(r.status, 0, r.stderr); + assert.match(r.stderr, WARNING); +}); + +test('instanceof against the public ZIP classes still matches real instances', () => { + const src = "const z = require('zlib');\n" + + 'const bytes = Buffer.concat([...z.createZipArchiveSync([])]);\n' + + 'const buf = new z.ZipBuffer(bytes);\n' + + 'console.log(buf instanceof z.ZipBuffer, typeof z.ZipFile, z.ZipFile.name);'; + const r = run('-e', src); + assert.strictEqual(r.status, 0, r.stderr); + assert.match(r.stdout, /^true function ZipFile$/m); +}); diff --git a/test/parallel/test-zlib-zip-files.js b/test/parallel/test-zlib-zip-files.js new file mode 100644 index 000000000000..0c4ed6efe88a --- /dev/null +++ b/test/parallel/test-zlib-zip-files.js @@ -0,0 +1,240 @@ +'use strict'; + +// zlib.zipFiles(): build an archive from files on disk, capturing each file's +// mode and modification time, streaming regular-file contents, and either +// following symlinks (default) or storing them as symlink entries. + +const common = require('../common'); + +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const fs = require('node:fs'); +const fsp = require('node:fs/promises'); +const tmpdir = require('../common/tmpdir'); +const path = require('node:path'); +const { test } = require('node:test'); + +tmpdir.refresh(); + +async function collect(readable) { + const chunks = []; + for await (const chunk of readable) chunks.push(chunk); + return Buffer.concat(chunks); +} + +// Disposal of a streaming source completes asynchronously - its descriptor is +// closed on the libuv threadpool - and an abandoned archive tears its queued +// sources down one after another, each waiting on the previous close. So the +// moment "every source is destroyed" can trail the destroy() call, arbitrarily +// far on a loaded machine. Wait for that real end state rather than assuming a +// fixed delay has been long enough. +async function waitForAllDestroyed(streams) { + const deadline = Date.now() + common.platformTimeout(5000); + while (streams.some((s) => !s.destroyed) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} + +// Write `count` throwaway files and return streaming entries backed by their +// (eagerly opened) read streams, so a test can observe whether each source is +// destroyed. The worst case for leaks: every descriptor is open before the +// archive starts. +async function streamingEntries(count) { + const dir = await fsp.mkdtemp(path.join(tmpdir.path, 'zlib-zip-dispose-')); + const streams = []; + const entries = []; + for (let i = 0; i < count; i++) { + const p = path.join(dir, `f${i}.bin`); + await fsp.writeFile(p, Buffer.alloc(1024 * 1024, i)); + const stream = fs.createReadStream(p); + streams.push(stream); + entries.push(zlib.ZipEntry.createStream(`f${i}.bin`, stream, { method: 'store' })); + } + return { dir, streams, entries }; +} + +async function makeTree() { + const dir = await fsp.mkdtemp(path.join(tmpdir.path, 'zlib-zipfiles-')); + await fsp.writeFile(path.join(dir, 'a.txt'), 'alpha'); + await fsp.chmod(path.join(dir, 'a.txt'), 0o640); + await fsp.mkdir(path.join(dir, 'sub')); + await fsp.writeFile(path.join(dir, 'sub', 'b.bin'), Buffer.from([1, 2, 3, 4])); + return dir; +} + +test('zipFiles archives files, directories, contents and Unix mode from disk', async () => { + const dir = await makeTree(); + try { + const files = [ + [path.join(dir, 'a.txt'), 'a.txt'], + [path.join(dir, 'sub'), 'sub'], + [path.join(dir, 'sub', 'b.bin'), 'nested/b.bin'], + ]; + using zip = new zlib.ZipBuffer(await collect(zlib.zipFiles(files))); + assert.deepStrictEqual([...zip.keys()].sort(), ['a.txt', 'nested/b.bin', 'sub/']); + assert.strictEqual(zip.get('a.txt').contentSync().toString(), 'alpha'); + // Windows has no POSIX permission bits (chmod only toggles read-only, and + // stat reports 0o666), so the exact captured mode is Unix-specific. + if (!common.isWindows) { + assert.strictEqual(zip.get('a.txt').mode, 0o640); + } + assert.strictEqual(zip.get('sub/').isDirectory, true); + assert.deepStrictEqual([...zip.get('nested/b.bin').contentSync()], [1, 2, 3, 4]); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + } +}); + +test('zipFiles accepts any iterable of [path, name] (array, Map, Object.entries)', async () => { + const dir = await makeTree(); + try { + const p = path.join(dir, 'a.txt'); + const inputs = [ + [[p, 'a.txt']], // array of pairs + new Map([[p, 'a.txt']]), // Map + Object.entries({ [p]: 'a.txt' }), // Object.entries + ]; + for (const files of inputs) { + using zip = new zlib.ZipBuffer(await collect(zlib.zipFiles(files))); + assert.strictEqual(zip.get('a.txt').contentSync().toString(), 'alpha'); + } + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + } +}); + +test('zipFiles preserves a sub-second modification time via an extra field', async () => { + const dir = await makeTree(); + try { + const p = path.join(dir, 'a.txt'); + // 1-second-and-a-half past an epoch second, so the DOS field alone can't + // represent it and the extended-timestamp extra field is what carries it. + const when = new Date(1700000000500); + await fsp.utimes(p, when, when); + using zip = new zlib.ZipBuffer(await collect(zlib.zipFiles([[p, 'a.txt']]))); + assert.strictEqual(zip.get('a.txt').modified.getTime(), 1700000000000); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + } +}); + +test('zipFiles follows symlinks by default, or stores them when disabled', { + skip: !common.canCreateSymLink() && 'insufficient privileges to create symlinks', +}, async () => { + const dir = await makeTree(); + try { + await fsp.symlink('a.txt', path.join(dir, 'link')); + const files = [[path.join(dir, 'link'), 'link']]; + + // Default: the link is resolved and archived as the target file. + using followed = new zlib.ZipBuffer(await collect(zlib.zipFiles(files))); + assert.strictEqual(followed.get('link').isSymlink, false); + assert.strictEqual(followed.get('link').isFile, true); + assert.strictEqual(followed.get('link').contentSync().toString(), 'alpha'); + + // followSymlinks: false: the link itself becomes a symlink entry. + using stored = new zlib.ZipBuffer(await collect(zlib.zipFiles(files, { followSymlinks: false }))); + const link = stored.get('link'); + assert.strictEqual(link.isSymlink, true); + assert.strictEqual(link.isFile, false); + assert.strictEqual(link.contentSync().toString(), 'a.txt'); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + } +}); + +test('zipFiles rejects when a listed path does not exist', async () => { + const dir = await makeTree(); + try { + await assert.rejects(collect(zlib.zipFiles([[path.join(dir, 'missing'), 'x']])), + { code: 'ENOENT' }); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + } +}); + +// -- entry ownership and disposal ---------------------------------------------- + +test('an abandoned createZipArchive() destroys the sources of entries it never reached', async () => { + const { dir, streams, entries } = await streamingEntries(5); + try { + const archive = zlib.createZipArchive(entries); + archive.on('error', () => {}); + let seen = 0; + for await (const chunk of archive) { + seen += chunk.length; + if (seen > 256 * 1024) break; // Bail while still inside the first member + } + archive.destroy(); + await waitForAllDestroyed(streams); + const open = streams.filter((s) => !s.destroyed); + assert.strictEqual(open.length, 0, `${open.length} source streams left open`); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + } +}); + +test('a fully consumed archive leaves a valid result and ends every source', async () => { + const { dir, streams, entries } = await streamingEntries(4); + try { + const archive = Buffer.concat(await (async () => { + const chunks = []; + for await (const chunk of zlib.createZipArchive(entries)) chunks.push(chunk); + return chunks; + })()); + using zip = new zlib.ZipBuffer(archive); + assert.strictEqual(zip.size, 4); + assert.strictEqual((await zip.get('f2.bin').content()).length, 1024 * 1024); + assert.strictEqual(streams.filter((s) => !s.destroyed).length, 0); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + } +}); + +test('zipEntry disposal destroys a streaming source, sync and async', async () => { + const { dir, streams, entries } = await streamingEntries(2); + try { + entries[0][Symbol.dispose](); + await entries[1][Symbol.asyncDispose](); + await waitForAllDestroyed(streams); + assert.strictEqual(streams[0].destroyed, true); + assert.strictEqual(streams[1].destroyed, true); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + } +}); + +test('disposing an in-memory or file-backed entry is a harmless no-op', async () => { + const mem = await zlib.ZipEntry.create('m.txt', Buffer.from('keep me')); + mem[Symbol.dispose](); + await mem[Symbol.asyncDispose](); + assert.deepStrictEqual(await mem.content(), Buffer.from('keep me')); + + // A file-backed entry borrows the ZipFile's descriptor; disposing it must + // not close that descriptor. + const dir = await fsp.mkdtemp(path.join(tmpdir.path, 'zlib-zip-dispose-fb-')); + const archivePath = path.join(dir, 'a.zip'); + try { + await fsp.writeFile(archivePath, await collect(zlib.createZipArchive( + [await zlib.ZipEntry.create('a.txt', Buffer.from('hello'))]))); + using zf = await zlib.ZipFile.open(archivePath); + const entry = await zf.get('a.txt'); + entry[Symbol.dispose](); + assert.strictEqual((await entry.content()).toString(), 'hello'); + assert.strictEqual((await (await zf.get('a.txt')).content()).toString(), 'hello'); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + } +}); + +test('createZipArchiveSync() throws on a streaming entry and disposes the rest', async () => { + const { dir, streams, entries } = await streamingEntries(3); + try { + assert.throws(() => Array.from(zlib.createZipArchiveSync(entries)), + { code: 'ERR_INVALID_STATE' }); + await waitForAllDestroyed(streams); + assert.strictEqual(streams.filter((s) => !s.destroyed).length, 0); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + } +}); diff --git a/test/parallel/test-zlib-zip-fuzz.js b/test/parallel/test-zlib-zip-fuzz.js new file mode 100644 index 000000000000..3b45f7af7dce --- /dev/null +++ b/test/parallel/test-zlib-zip-fuzz.js @@ -0,0 +1,85 @@ +'use strict'; + +require('../common'); + +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const { test } = require('node:test'); + +function mulberry32(seed) { + let state = seed >>> 0; + return function() { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +async function buildSeedArchive() { + const entries = [ + await zlib.ZipEntry.create('hello.txt', Buffer.from('Hello, world!'.repeat(30))), + await zlib.ZipEntry.create('raw.bin', Buffer.from([1, 2, 3, 4, 5]), { method: 'store' }), + await zlib.ZipEntry.create('empty/', Buffer.alloc(0)), + ]; + const chunks = []; + for await (const chunk of zlib.createZipArchive(entries, 'seed archive')) chunks.push(chunk); + return Buffer.concat(chunks); +} + +function mutate(random, seed) { + const buf = Buffer.from(seed); + const kind = Math.floor(random() * 4); + if (kind === 0) { + // Flip a handful of random bits. + const flips = 1 + Math.floor(random() * 8); + for (let i = 0; i < flips; i++) { + buf[Math.floor(random() * buf.length)] ^= 1 << Math.floor(random() * 8); + } + } else if (kind === 1) { + // Overwrite a random window with boundary-ish values. + const boundary = [0x00, 0xff, 0x50, 0x4b][Math.floor(random() * 4)]; + const start = Math.floor(random() * buf.length); + const len = Math.min(buf.length - start, 1 + Math.floor(random() * 8)); + buf.fill(boundary, start, start + len); + } else if (kind === 2) { + // Truncate. + return buf.subarray(0, Math.floor(random() * buf.length)); + } else { + // Extend with random padding. + const pad = Buffer.allocUnsafe(1 + Math.floor(random() * 16)); + for (let i = 0; i < pad.length; i++) pad[i] = Math.floor(random() * 256); + return Buffer.concat([buf, pad]); + } + return buf; +} + +test('the parser only ever throws Error on mutated archives, never crashes or hangs', async () => { + const seed = await buildSeedArchive(); + const random = mulberry32(0x5EED1234); + const iterations = 3000; + + for (let i = 0; i < iterations; i++) { + const candidate = mutate(random, seed); + try { + const entries = [...zlib.ZipEntry.read(candidate)]; + for (const entry of entries) { + try { + await entry.content(); + } catch (err) { + assert.ok(err instanceof Error, `content() threw non-Error: ${err}`); + } + } + } catch (err) { + assert.ok(err instanceof Error, `read() threw non-Error: ${err}`); + } + + try { + using zipBuffer = new zlib.ZipBuffer(candidate); + assert.ok(zipBuffer.size >= 0); + } catch (err) { + assert.ok(err instanceof Error, `ZipBuffer threw non-Error: ${err}`); + } + } +}, { timeout: 60_000 }); diff --git a/test/parallel/test-zlib-zip-hardening.js b/test/parallel/test-zlib-zip-hardening.js new file mode 100644 index 000000000000..de2a9efd4918 --- /dev/null +++ b/test/parallel/test-zlib-zip-hardening.js @@ -0,0 +1,376 @@ +'use strict'; + +require('../common'); + +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const { test } = require('node:test'); + +async function buildArchive(entries, comment) { + const chunks = []; + for await (const chunk of zlib.createZipArchive(entries, comment)) chunks.push(chunk); + return Buffer.concat(chunks); +} + +function buildEocd({ diskNumber = 0, cdDiskNumber = 0, cdDiskRecords = 0, + totalRecords = 0, cdSize = 0, cdOffset = 0, comment = Buffer.alloc(0) } = {}) { + const buf = Buffer.allocUnsafe(22 + comment.length); + buf.writeUInt32LE(0x06054b50, 0); + buf.writeUInt16LE(diskNumber, 4); + buf.writeUInt16LE(cdDiskNumber, 6); + buf.writeUInt16LE(cdDiskRecords, 8); + buf.writeUInt16LE(totalRecords, 10); + buf.writeUInt32LE(cdSize, 12); + buf.writeUInt32LE(cdOffset, 16); + buf.writeUInt16LE(comment.length, 20); + comment.copy(buf, 22); + return buf; +} + +test('an empty or tiny buffer is rejected as an invalid archive', () => { + for (const buf of [Buffer.alloc(0), Buffer.alloc(10), Buffer.from('not a zip')]) { + assert.throws(() => [...zlib.ZipEntry.read(buf)], { code: 'ERR_ZIP_INVALID_ARCHIVE' }); + } +}); + +test('garbage or truncated data is rejected', () => { + const garbage = Buffer.alloc(100, 0x41); + assert.throws(() => [...zlib.ZipEntry.read(garbage)], { code: 'ERR_ZIP_INVALID_ARCHIVE' }); + + const truncated = buildEocd({ totalRecords: 5, cdSize: 46 * 5 }).subarray(0, 10); + assert.throws(() => [...zlib.ZipEntry.read(truncated)], { code: 'ERR_ZIP_INVALID_ARCHIVE' }); +}); + +test('an EOCD-looking signature inside a trailing comment is not mistaken for the real one', async () => { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hi'), { method: 'store' }); + // The comment scan walks backward through the trailing comment bytes + // before it reaches the genuine EOCD signature; embedding 4 bytes that + // look like one partway through must not be mistaken for the real record. + const fakeSignature = String.fromCharCode(0x50, 0x4b, 0x05, 0x06); + const archive = await buildArchive([entry], `before ${fakeSignature} after`); + + const read = [...zlib.ZipEntry.read(archive)]; + assert.strictEqual(read.length, 1); + assert.strictEqual(read[0].name, 'f.txt'); +}); + +test('a declared-size mismatch is rejected as corrupt', async () => { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello world'), { method: 'store' }); + const archive = await buildArchive([entry]); + + // Shrink the *declared* uncompressed size in the central directory record + // without touching the stored bytes themselves, so the amount of data + // produced no longer matches what the header promised. + const tampered = Buffer.from(archive); + const centralHeaderStart = 30 + 'f.txt'.length + 'hello world'.length; + const uncompressedSizeOffset = centralHeaderStart + 24; + tampered.writeUInt32LE(1, uncompressedSizeOffset); + + const [tamperedEntry] = zlib.ZipEntry.read(tampered); + assert.strictEqual(tamperedEntry.size, 1); + await assert.rejects(tamperedEntry.content(), { code: 'ERR_ZIP_ENTRY_CORRUPT' }); +}); + +test('CRC-32 verification catches a single flipped byte, and can be disabled', async () => { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello world'), { method: 'store' }); + const archive = await buildArchive([entry]); + const tampered = Buffer.from(archive); + const contentStart = 30 + 'f.txt'.length; + tampered[contentStart] ^= 0xff; + + const [tamperedEntry] = zlib.ZipEntry.read(tampered); + await assert.rejects(tamperedEntry.content(), { code: 'ERR_ZIP_ENTRY_CORRUPT' }); + const unverified = await tamperedEntry.content({ verify: false }); + assert.strictEqual(unverified.length, 'hello world'.length); +}); + +test('content() enforces maxSize before allocating', async () => { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello world')); + await assert.rejects(entry.content({ maxSize: 1 }), { code: 'ERR_ZIP_ENTRY_TOO_LARGE' }); +}); + +test('a forged small header whose content inflates past its declared size is rejected', async () => { + // The up-front maxSize check trusts the declared size, so a bomb forges a + // tiny declared size to clear it; the decompressor's maxOutputLength + // backstop is capped at declared + 1 bytes, so the lie is caught (as + // corruption - the declared size is provably wrong) without ever + // materializing more than the declared size, no matter how large maxSize + // is. + for (const { method, re } of [ + { method: 'deflate', re: /inflates beyond its declared size/ }, + { method: 'zstd', re: /decompresses beyond its declared size/ }, + ]) { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('x'.repeat(5000)), { method }); + const archive = await buildArchive([entry]); + const tampered = Buffer.from(archive); + const eocd = tampered.length - 22; // No comment, so EOCD is the last 22 bytes + const cdOffset = tampered.readUInt32LE(eocd + 16); + tampered.writeUInt32LE(50, cdOffset + 24); // Forge declared uncompressedSize + + const [e] = zlib.ZipEntry.read(tampered); + assert.strictEqual(e.size, 50); // 50 <= maxSize 100 clears the up-front check + await assert.rejects(e.content({ maxSize: 100 }), { code: 'ERR_ZIP_ENTRY_CORRUPT', message: re }); + assert.throws(() => e.contentSync({ maxSize: 100 }), { code: 'ERR_ZIP_ENTRY_CORRUPT', message: re }); + } +}); + +test('getMaxZipContentSize()/setMaxZipContentSize() control the default guard', async () => { + const original = zlib.getMaxZipContentSize(); + try { + zlib.setMaxZipContentSize(1); + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello world')); + await assert.rejects(entry.content(), { code: 'ERR_ZIP_ENTRY_TOO_LARGE' }); + } finally { + zlib.setMaxZipContentSize(original); + } + assert.strictEqual(zlib.getMaxZipContentSize(), original); +}); + +test('streaming a partially-consumed entry does not hang or leak', async () => { + async function* source() { + for (let i = 0; i < 1000; i++) { + yield Buffer.alloc(1024, i & 0xff); + } + } + const entry = zlib.ZipEntry.createStream('big.bin', source()); + let count = 0; + let bytesSeen = 0; + for await (const chunk of entry) { + count++; + bytesSeen += chunk.length; + if (count > 2) break; + } + assert.ok(count > 2); + assert.ok(bytesSeen > 0); +}); + +test('an overlong file name is rejected', async () => { + await assert.rejects( + zlib.ZipEntry.create('x'.repeat(70000), Buffer.alloc(0)), + { code: 'ERR_ZIP_ENTRY_TOO_LARGE' }, + ); +}); + +test('an empty file name is rejected', async () => { + await assert.rejects( + zlib.ZipEntry.create('', Buffer.alloc(0)), + { code: 'ERR_INVALID_ARG_VALUE' }, + ); +}); + +test('a multi-disk archive is rejected', () => { + const eocd = buildEocd({ diskNumber: 1, cdDiskNumber: 1 }); + assert.throws(() => [...zlib.ZipEntry.read(eocd)], { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); +}); + +test('an encrypted entry is rejected', async () => { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('secret'), { method: 'store' }); + const archive = await buildArchive([entry]); + // Set the encrypted bit (bit 0) in both the local and central header flags. + const tampered = Buffer.from(archive); + const localFlagsOffset = 6; + tampered.writeUInt16LE(tampered.readUInt16LE(localFlagsOffset) | 0x0001, localFlagsOffset); + const centralHeaderStart = 30 + 'f.txt'.length + 'secret'.length; + const centralFlagsOffset = centralHeaderStart + 8; + tampered.writeUInt16LE(tampered.readUInt16LE(centralFlagsOffset) | 0x0001, centralFlagsOffset); + + const [tamperedEntry] = zlib.ZipEntry.read(tampered); + await assert.rejects(tamperedEntry.content(), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); +}); + +test('an entry using an unsupported compression method is rejected', async () => { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hi'), { method: 'store' }); + const archive = await buildArchive([entry]); + // Set the method to 1 (Shrunk), which this implementation does not support, + // in both the local and central headers. + const tampered = Buffer.from(archive); + const localMethodOffset = 8; + tampered.writeUInt16LE(1, localMethodOffset); + const centralHeaderStart = 30 + 'f.txt'.length + 'hi'.length; + const centralMethodOffset = centralHeaderStart + 10; + tampered.writeUInt16LE(1, centralMethodOffset); + + const [tamperedEntry] = zlib.ZipEntry.read(tampered); + assert.strictEqual(tamperedEntry.method, 1); + await assert.rejects(tamperedEntry.content(), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); + assert.throws(() => tamperedEntry.contentSync(), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); +}); + +// -- shapes borrowed from CPython's zipfile test corpus (synthesized bytes) ------ + +test('every possible truncation of an archive is rejected, deterministically', async () => { + // CPython's test_damaged_zipfile: any prefix of a valid archive must fail + // cleanly (never succeed, never throw a non-Error). The fuzz test samples + // truncation points randomly; this pins all of them for a small archive. + const archive = await buildArchive( + [await zlib.ZipEntry.create('f.txt', Buffer.from('hello world'), { method: 'store' })]); + for (let n = 0; n < archive.length; n++) { + assert.throws(() => [...zlib.ZipEntry.read(archive.subarray(0, n))], + { code: /^ERR_ZIP_/ }, `truncation at ${n} bytes`); + } +}); + +test('trailing padding after the EOCD is tolerated', async () => { + // Some streaming writers pad their output to a block size; CPython + // tolerates trailing newlines/NULs and so does the pass-2 EOCD scan. + const archive = await buildArchive( + [await zlib.ZipEntry.create('f.txt', Buffer.from('hi'), { method: 'store' })]); + const padded = Buffer.concat([archive, Buffer.from('\r\n\0\0\0')]); + const [entry] = zlib.ZipEntry.read(padded); + assert.strictEqual(entry.name, 'f.txt'); + assert.strictEqual((await entry.content()).toString(), 'hi'); +}); + +test('junk appended past a declared comment is tolerated and the comment preserved', async () => { + const archive = await buildArchive( + [await zlib.ZipEntry.create('f.txt', Buffer.from('hi'), { method: 'store' })], + 'this is a comment'); + const appended = Buffer.concat([archive, Buffer.from('abcdef\r\n')]); + const zip = new zlib.ZipBuffer(appended); + assert.strictEqual(zip.comment, 'this is a comment'); + assert.strictEqual(zip.get('f.txt').contentSync().toString(), 'hi'); +}); + +test('an EOCD comment length overrunning the end of the file is rejected', async () => { + // CPython's _EndRecData silently returns a truncated comment here; this + // implementation deliberately rejects the candidate instead (both scan + // passes require the declared comment to fit), so the archive has no + // recognizable EOCD at all. + const comment = 'padding-padding-padding'; + const archive = Buffer.from(await buildArchive([], comment)); + const eocdOffset = archive.length - 22 - comment.length; + assert.strictEqual(archive.readUInt32LE(eocdOffset), 0x06054b50); + archive.writeUInt16LE(comment.length + 10, eocdOffset + 20); + assert.throws(() => [...zlib.ZipEntry.read(archive)], + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /no end of central directory/ }); +}); + +test('a central directory that cannot fit before the EOCD is rejected', () => { + // CPython: "negative central directory offset". The declared size/offset + // put the directory past the EOCD, so the prefix computation goes + // negative. + const eocd = buildEocd({ cdDiskRecords: 1, totalRecords: 1, cdSize: 1, cdOffset: 0xffffff }); + assert.throws(() => [...zlib.ZipEntry.read(eocd)], + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /does not fit/ }); +}); + +test('a record count inconsistent with the directory size is rejected', () => { + // 100 records cannot fit in 46 bytes of central directory. Filler bytes + // ahead of the EOCD stand in for that region so the directory nominally + // fits inside the archive and the count check is what fires. + const eocd = buildEocd({ cdDiskRecords: 100, totalRecords: 100, cdSize: 46 }); + const archive = Buffer.concat([Buffer.alloc(46), eocd]); + assert.throws(() => [...zlib.ZipEntry.read(archive)], + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /inconsistent/ }); +}); + +test('a corrupted or overrunning central directory header is rejected', async () => { + const nameA = 'a.txt'; + const nameB = 'b.txt'; + const content = Buffer.from('x'); + const two = await buildArchive([ + await zlib.ZipEntry.create(nameA, content, { method: 'store' }), + await zlib.ZipEntry.create(nameB, content, { method: 'store' }), + ]); + const memberSize = 30 + nameA.length + content.length; + const centralStart = 2 * memberSize; + const centralRecord = 46 + nameA.length; + + // Zero the second central record's signature ("bad magic number"). + const badMagic = Buffer.from(two); + badMagic.writeUInt32LE(0, centralStart + centralRecord); + assert.throws(() => [...zlib.ZipEntry.read(badMagic)], + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /signature is invalid/ }); + + // Grow the first central record's name length so it overruns the declared + // central directory size ("truncated central directory"). + const overrun = Buffer.from(two); + overrun.writeUInt16LE(2000, centralStart + 28); + assert.throws(() => [...zlib.ZipEntry.read(overrun)], { code: 'ERR_ZIP_INVALID_ARCHIVE' }); +}); + +test('short or overrunning extra-field records do not make an entry unreadable', async () => { + // CPython's test_zipfile_with_short_extra_field shape: advisory extra + // metadata that is malformed is skipped, never fatal. + const name = 'f.txt'; + const content = Buffer.from('hi'); + const base = await buildArchive( + [await zlib.ZipEntry.create(name, content, { method: 'store' })]); + const centralHeaderStart = 30 + name.length + content.length; + for (const extra of [ + Buffer.from([0x99]), // Shorter than a TLV header + Buffer.from([0x99, 0x99, 0xff]), // Still shorter than a TLV header + Buffer.from([0x99, 0x99, 0xff, 0xff]), // Declared length overruns wildly + ]) { + const before = base.subarray(0, centralHeaderStart + 46 + name.length); + const after = base.subarray(centralHeaderStart + 46 + name.length); + const patched = Buffer.concat([before, extra, after]); + patched.writeUInt16LE(extra.length, centralHeaderStart + 30); + const eocdOffset = patched.length - 22; + patched.writeUInt32LE(patched.readUInt32LE(eocdOffset + 12) + extra.length, eocdOffset + 12); + const [entry] = zlib.ZipEntry.read(patched); + assert.strictEqual(entry.name, name); + assert.strictEqual((await entry.content()).toString(), 'hi'); + } +}); + +test('two central records quoting the same data are rejected as a possible zip bomb', async () => { + // The "quoted overlap" bomb shape (CVE-2024-0450 in other + // implementations): N central records pointing at one region turn a tiny + // file into N full-size extractions. + const nameA = 'a.txt'; + const nameB = 'b.txt'; + const content = Buffer.from('x'); + const two = Buffer.from(await buildArchive([ + await zlib.ZipEntry.create(nameA, content, { method: 'store' }), + await zlib.ZipEntry.create(nameB, content, { method: 'store' }), + ])); + const memberSize = 30 + nameA.length + content.length; + const centralStart = 2 * memberSize; + const centralRecord = 46 + nameA.length; + // Point the second record's local header at the first member's. + two.writeUInt32LE(0, centralStart + centralRecord + 42); + assert.throws(() => [...zlib.ZipEntry.read(two)], + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /possible zip bomb/ }); +}); + +test('a member whose data crosses into the central directory is rejected', async () => { + const name = 'f.txt'; + const content = Buffer.from('hello'); + const archive = Buffer.from(await buildArchive( + [await zlib.ZipEntry.create(name, content, { method: 'store' })])); + const centralHeaderStart = 30 + name.length + content.length; + // Lie about the compressed size so the member's data range reaches into + // the central directory (while staying inside the buffer). + archive.writeUInt32LE(content.length + 40, centralHeaderStart + 20); + assert.throws(() => [...zlib.ZipEntry.read(archive)], + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /possible zip bomb/ }); +}); + +test('a Zip64 EOCD record size-field lie is tolerated when the locator is correct', () => { + // The record's own size field is only consulted by the backward recovery + // scan; when the locator points straight at a valid record, a lying size + // field (CPython rejects 0 or 100 here) does not matter. + const record = Buffer.alloc(56); + record.writeUInt32LE(0x06064b50, 0); + record.writeBigUInt64LE(100n, 4); // Lie: the remainder is 44 bytes + record.writeUInt16LE((3 << 8) | 45, 12); + record.writeUInt16LE(45, 14); + const locator = Buffer.alloc(20); + locator.writeUInt32LE(0x07064b50, 0); + locator.writeBigUInt64LE(0n, 8); + locator.writeUInt32LE(1, 16); + const eocd = buildEocd({ cdSize: 0xffffffff }); + const zip = new zlib.ZipBuffer(Buffer.concat([record, locator, eocd])); + assert.strictEqual(zip.size, 0); +}); + +test('a NUL byte inside an entry name is preserved verbatim', async () => { + // CPython truncates the name at the NUL on read; this implementation + // surfaces names verbatim and leaves interpretation to the caller. + const name = 'foo\x00bar.txt'; + const archive = await buildArchive( + [await zlib.ZipEntry.create(name, Buffer.from('x'), { method: 'store' })]); + const [entry] = zlib.ZipEntry.read(archive); + assert.strictEqual(entry.name, name); +}); diff --git a/test/parallel/test-zlib-zip-internals.js b/test/parallel/test-zlib-zip-internals.js new file mode 100644 index 000000000000..78316ec37024 --- /dev/null +++ b/test/parallel/test-zlib-zip-internals.js @@ -0,0 +1,53 @@ +// Flags: --expose-internals +'use strict'; + +require('../common'); + +// Directly exercises defense-in-depth guards in lib/internal/zip/ that the +// public API cannot reach on this platform - they exist for 32-bit limits, +// or for hypothetical callers that skip the pre-validation every current +// call site performs - so their behavior stays pinned down. + +const assert = require('node:assert'); +const { test } = require('node:test'); +const zlib = require('node:zlib'); + +const { readSafeUint64 } = require('internal/zip/binary'); +const { parseZip64Extra } = require('internal/zip/extra-fields'); +const { LocalFileHeader } = require('internal/zip/headers'); +const { decodeMemberSync } = require('internal/zip/compression'); + +test('readSafeUint64 rejects a field extending past the buffer', () => { + // Every current caller validates the containing range first; the guard + // protects future call sites. + assert.throws(() => readSafeUint64(Buffer.alloc(7), 0), + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /out of bounds/ }); +}); + +test('parseZip64Extra returns nothing when no field is wanted', () => { + // Reachable only if a caller asks with no overflow sentinel present; + // CentralFileHeader always wants at least one field when it calls. + assert.deepStrictEqual(parseZip64Extra(Buffer.alloc(0), {}), {}); +}); + +test('LocalFileHeader.length reports 0 when the fixed part does not fit', () => { + // The only current caller passes exactly 30 bytes, so the short-buffer + // branch cannot trigger through it. + assert.strictEqual(LocalFileHeader.length(Buffer.alloc(10), 0), 0); +}); + +test('decodeMemberSync bounds output by the declared size without a caller limit', () => { + // The public wrappers always pass maxSize (defaulted); without one, the + // declared-size cap alone must stop an overrun. + const data = Buffer.alloc(4096, 0x61); + const compressed = zlib.deflateRawSync(data); + const info = { + name: 'lie.txt', + flags: 0, + method: 8, + crc32: zlib.crc32(data), + uncompressedSize: 16, // Lie: it inflates to 4096 bytes + }; + assert.throws(() => decodeMemberSync(compressed, info), + { code: 'ERR_ZIP_ENTRY_CORRUPT', message: /beyond its declared size/ }); +}); diff --git a/test/parallel/test-zlib-zip-interop.js b/test/parallel/test-zlib-zip-interop.js new file mode 100644 index 000000000000..429f7b02973e --- /dev/null +++ b/test/parallel/test-zlib-zip-interop.js @@ -0,0 +1,132 @@ +'use strict'; + +require('../common'); +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const tmpdir = require('../common/tmpdir'); +const { spawnSync } = require('node:child_process'); +const { test } = require('node:test'); + +tmpdir.refresh(); + +function hasTool(command, args = ['--help']) { + try { + const result = spawnSync(command, args, { stdio: 'ignore' }); + // Require a clean exit, not merely a successful spawn: Windows ships an + // App Execution Alias stub for `python3` that runs, prints "Python was + // not found", and exits non-zero (9009) - `error` is undefined for it, + // so a spawn-only check would wrongly report the tool as present. + return result.error === undefined && result.status === 0; + } catch { + return false; + } +} + +// Rendering a UTF-8 entry name (general-purpose bit 11, which we set) through +// Info-ZIP unzip/zipinfo needs two things that are independent of whether the +// archive is well-formed: (1) the build must be compiled with UNICODE_SUPPORT +// - macOS's bundled Info-ZIP is not, and mangles such names no matter what - +// and (2) at runtime the name is converted to the process locale, which drops +// or garbles non-ASCII bytes under the "C"/"POSIX" locale. We detect (1) and +// pick an installed UTF-8 locale for (2); when unzip lacks UNICODE_SUPPORT the +// non-ASCII name assertion is skipped (its round-trip is still covered by +// bsdtar/libarchive, which handles UTF-8 correctly regardless). +function findUtf8Locale() { + const result = spawnSync('locale', ['-a'], { encoding: 'utf8' }); + if (result.status !== 0 || !result.stdout) return null; + const locales = result.stdout.split('\n').map((line) => line.trim()); + const preferred = ['C.UTF-8', 'C.utf8', 'en_US.UTF-8', 'en_US.utf8']; + for (const name of preferred) { + if (locales.includes(name)) return name; + } + return locales.find((name) => /utf-?8$/i.test(name)) || null; +} + +test('an archive written by createZipArchive is readable by unzip, zipinfo, and bsdtar', async (t) => { + if (!hasTool('unzip', ['-v']) || !hasTool('zipinfo', ['-v']) || !hasTool('bsdtar', ['--version'])) { + t.skip('unzip, zipinfo, or bsdtar is not available'); + return; + } + const locale = findUtf8Locale(); + if (!locale) { + t.skip('no UTF-8 locale is available to render non-ASCII entry names'); + return; + } + const env = { ...process.env, LANG: locale, LC_ALL: locale }; + // Only assert non-ASCII names through zipinfo when this Info-ZIP build can + // actually render them (see the note above). + const unzipBuild = spawnSync('unzip', ['-v'], { encoding: 'utf8' }).stdout || ''; + const zipinfoUnicode = /UNICODE_SUPPORT/.test(unzipBuild); + + const dir = await fs.mkdtemp(path.join(tmpdir.path, 'zlib-zip-interop-')); + try { + const files = { + 'hello.txt': Buffer.from('Hello, world!'.repeat(50)), + 'raw.bin': Buffer.from([1, 2, 3, 4, 5]), + 'unicode-名前.txt': Buffer.from('unicode name'), + }; + const entries = []; + for (const [name, data] of Object.entries(files)) { + entries.push(await zlib.ZipEntry.create(name, data)); + } + const chunks = []; + for await (const chunk of zlib.createZipArchive(entries)) chunks.push(chunk); + const archivePath = path.join(dir, 'archive.zip'); + await fs.writeFile(archivePath, Buffer.concat(chunks)); + + const unzipTest = spawnSync('unzip', ['-t', archivePath], { env }); + assert.strictEqual(unzipTest.status, 0, unzipTest.stderr?.toString()); + + const zipinfo = spawnSync('zipinfo', [archivePath], { env }); + assert.strictEqual(zipinfo.status, 0); + for (const name of Object.keys(files)) { + if (!zipinfoUnicode && /[^\x20-\x7e]/.test(name)) continue; + assert.ok(zipinfo.stdout.toString().includes(name), `zipinfo missing ${name}`); + } + + const extractDir = path.join(dir, 'out'); + await fs.mkdir(extractDir); + const bsdtar = spawnSync('bsdtar', ['-xf', archivePath, '-C', extractDir], { env }); + assert.strictEqual(bsdtar.status, 0, bsdtar.stderr?.toString()); + for (const [name, data] of Object.entries(files)) { + const extracted = await fs.readFile(path.join(extractDir, name)); + assert.deepStrictEqual(extracted, data); + } + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('an archive written by Python\'s zipfile module is readable by ZipFile', async (t) => { + if (!hasTool('python3', ['--version'])) { + t.skip('python3 is not available'); + return; + } + + const dir = await fs.mkdtemp(path.join(tmpdir.path, 'zlib-zip-interop-')); + try { + const archivePath = path.join(dir, 'python.zip'); + const script = ` +import zipfile +with zipfile.ZipFile(${JSON.stringify(archivePath)}, 'w') as z: + z.writestr('a.txt', 'hello from python') + z.writestr('b.bin', bytes(range(256))) +`; + const result = spawnSync('python3', ['-c', script]); + assert.strictEqual(result.status, 0, result.stderr?.toString()); + + const zip = await zlib.ZipFile.open(archivePath); + try { + const a = await zip.get('a.txt'); + assert.strictEqual((await a.content()).toString(), 'hello from python'); + const b = await zip.get('b.bin'); + assert.deepStrictEqual(await b.content(), Buffer.from(Array.from({ length: 256 }, (_, i) => i))); + } finally { + await zip.close(); + } + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); diff --git a/test/parallel/test-zlib-zip-metadata.js b/test/parallel/test-zlib-zip-metadata.js new file mode 100644 index 000000000000..760b28414552 --- /dev/null +++ b/test/parallel/test-zlib-zip-metadata.js @@ -0,0 +1,96 @@ +'use strict'; + +require('../common'); + +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const { test } = require('node:test'); + +async function roundTrip(options) { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('x'), options); + const chunks = []; + for await (const chunk of zlib.createZipArchive([entry])) chunks.push(chunk); + const archive = Buffer.concat(chunks); + return [...zlib.ZipEntry.read(archive)][0]; +} + +test('modification time round-trips at 2-second resolution', async () => { + const modified = new Date(2024, 5, 15, 13, 45, 30); + const read = await roundTrip({ modified }); + assert.strictEqual(read.modified.getFullYear(), 2024); + assert.strictEqual(read.modified.getMonth(), 5); + assert.strictEqual(read.modified.getDate(), 15); + assert.strictEqual(read.modified.getHours(), 13); + assert.strictEqual(read.modified.getMinutes(), 45); + assert.strictEqual(read.modified.getSeconds(), 30); +}); + +test('a sub-second modification time is preserved to the second via an extra field', async () => { + // The DOS field is 2-second, local; a sub-second time additionally writes an + // extended-timestamp extra field, so it round-trips to the exact UTC second. + const read = await roundTrip({ modified: new Date(1700000000500) }); + assert.strictEqual(read.modified.getTime(), 1700000000000); +}); + +test('an odd whole-second modification time is preserved via an extra field', async () => { + // The DOS fields have 2-second resolution, so an odd second is just as + // unrepresentable as a sub-second part and also gets the extended-timestamp + // extra field. + const read = await roundTrip({ modified: new Date(1700000001000) }); + assert.strictEqual(read.modified.getTime(), 1700000001000); +}); + +test('a date before 1980 is clamped to the DOS epoch', async () => { + const read = await roundTrip({ modified: new Date(1970, 0, 1) }); + assert.strictEqual(read.modified.getFullYear(), 1980); + assert.strictEqual(read.modified.getMonth(), 0); + assert.strictEqual(read.modified.getDate(), 1); +}); + +test('a date after 2107 is clamped to the DOS ceiling', async () => { + const read = await roundTrip({ modified: new Date(2200, 0, 1) }); + assert.strictEqual(read.modified.getFullYear(), 2107); + assert.strictEqual(read.modified.getMonth(), 11); + assert.strictEqual(read.modified.getDate(), 31); +}); + +test('Unix mode round-trips for files and directories', async () => { + const file = await roundTrip({ mode: 0o600 }); + assert.strictEqual(file.mode, 0o600); + + const dirEntry = await zlib.ZipEntry.create('dir/', Buffer.alloc(0), { mode: 0o700 }); + const chunks = []; + for await (const chunk of zlib.createZipArchive([dirEntry])) chunks.push(chunk); + const read = [...zlib.ZipEntry.read(Buffer.concat(chunks))][0]; + assert.strictEqual(read.mode, 0o700); + assert.strictEqual(read.isDirectory, true); +}); + +test('default modes are applied when none is given', async () => { + const file = await roundTrip({}); + assert.strictEqual(file.mode, 0o644); + + const dirEntry = await zlib.ZipEntry.create('dir/', Buffer.alloc(0)); + const chunks = []; + for await (const chunk of zlib.createZipArchive([dirEntry])) chunks.push(chunk); + const read = [...zlib.ZipEntry.read(Buffer.concat(chunks))][0]; + assert.strictEqual(read.mode, 0o755); +}); + +test('an entry comment round-trips', async () => { + const read = await roundTrip({ comment: 'a comment' }); + assert.strictEqual(read.comment, 'a comment'); +}); + +test('zipEntry.compressed reports compressed storage for any method', async () => { + const big = Buffer.from('x'.repeat(1000)); // Compressible, so deflate/zstd win + const deflated = await zlib.ZipEntry.create('d.txt', big, { method: 'deflate' }); + const stored = await zlib.ZipEntry.create('s.bin', Buffer.from([1, 2, 3]), { method: 'store' }); + const zstd = await zlib.ZipEntry.create('z.txt', big, { method: 'zstd' }); + assert.strictEqual(deflated.compressed, true); + assert.strictEqual(stored.compressed, false); + assert.strictEqual(zstd.compressed, true); + + // Survives a read-back round-trip too. + assert.strictEqual((await roundTrip({ method: 'store' })).compressed, false); +}); diff --git a/test/parallel/test-zlib-zip-property.js b/test/parallel/test-zlib-zip-property.js new file mode 100644 index 000000000000..0f7cf8586fd2 --- /dev/null +++ b/test/parallel/test-zlib-zip-property.js @@ -0,0 +1,130 @@ +'use strict'; + +require('../common'); + +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const tmpdir = require('../common/tmpdir'); +const { test } = require('node:test'); + +tmpdir.refresh(); + +// A small, seeded PRNG so failures are reproducible without pulling in a +// dependency. +function mulberry32(seed) { + let state = seed >>> 0; + return function() { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function randomBuffer(random, length) { + const buf = Buffer.allocUnsafe(length); + for (let i = 0; i < length; i++) buf[i] = Math.floor(random() * 256); + return buf; +} + +function randomName(random, index) { + const unicodeBits = ['a', 'é', '日', '🙂', 'z']; + const segment = unicodeBits[Math.floor(random() * unicodeBits.length)]; + return `dir-${index}/${segment}-${index}.bin`; +} + +async function buildTree(random, count) { + const specs = []; + for (let i = 0; i < count; i++) { + const size = Math.floor(random() * 4096); + specs.push({ + name: randomName(random, i), + data: randomBuffer(random, size), + method: random() < 0.5 ? 'deflate' : 'store', + mode: random() < 0.5 ? 0o644 : 0o600, + modified: new Date(2000 + Math.floor(random() * 40), Math.floor(random() * 12), + 1 + Math.floor(random() * 27)), + }); + } + const entries = []; + for (const spec of specs) { + entries.push(await zlib.ZipEntry.create(spec.name, spec.data, { + method: spec.method, mode: spec.mode, modified: spec.modified, + })); + } + const chunks = []; + for await (const chunk of zlib.createZipArchive(entries)) chunks.push(chunk); + return { archive: Buffer.concat(chunks), specs }; +} + +test('random archives round-trip through ZipEntry.read, ZipBuffer, and ZipFile', async () => { + const random = mulberry32(0xC0FFEE); + const rounds = 8; + const dir = await fs.mkdtemp(path.join(tmpdir.path, 'zlib-zip-property-')); + try { + for (let round = 0; round < rounds; round++) { + const { archive, specs } = await buildTree(random, 6); + const byName = new Map(specs.map((spec) => [spec.name, spec])); + + // Path 1: ZipEntry.read + for (const entry of zlib.ZipEntry.read(archive)) { + const spec = byName.get(entry.name); + assert.ok(spec, `unexpected entry ${entry.name}`); + assert.deepStrictEqual(await entry.content(), spec.data); + assert.strictEqual(entry.mode, spec.mode); + } + + // Path 2: ZipBuffer + using zipBuffer = new zlib.ZipBuffer(archive); + assert.strictEqual(zipBuffer.size, specs.length); + for (const [name, entry] of zipBuffer) { + const spec = byName.get(name); + assert.deepStrictEqual(await entry.content(), spec.data); + } + + // Path 3: ZipFile (disk-backed), including one streamed read. + const filePath = path.join(dir, `round-${round}.zip`); + await fs.writeFile(filePath, archive); + const zipFile = await zlib.ZipFile.open(filePath); + try { + assert.strictEqual(zipFile.size, specs.length); + for (const spec of specs) { + const entry = await zipFile.get(spec.name); + assert.deepStrictEqual(await entry.content(), spec.data); + } + const firstName = specs[0].name; + const streamed = []; + for await (const chunk of await zipFile.stream(firstName)) streamed.push(chunk); + assert.deepStrictEqual(Buffer.concat(streamed), specs[0].data); + } finally { + await zipFile.close(); + } + } + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}, { timeout: 60_000 }); + +test('a streamed write round-trips through a streamed read', async () => { + const random = mulberry32(0xBADF00D); + const data = randomBuffer(random, 256 * 1024); + async function* source() { + for (let i = 0; i < data.length; i += 4096) { + yield data.subarray(i, Math.min(i + 4096, data.length)); + } + } + const entry = zlib.ZipEntry.createStream('streamed.bin', source()); + const chunks = []; + for await (const chunk of zlib.createZipArchive([entry])) chunks.push(chunk); + const archive = Buffer.concat(chunks); + + const [read] = zlib.ZipEntry.read(archive); + assert.strictEqual(read.name, 'streamed.bin'); + assert.strictEqual(read.size, data.length); + const streamedOut = []; + for await (const chunk of read.contentIterator()) streamedOut.push(chunk); + assert.deepStrictEqual(Buffer.concat(streamedOut), data); +}); diff --git a/test/parallel/test-zlib-zip-security.js b/test/parallel/test-zlib-zip-security.js new file mode 100644 index 000000000000..ba63af08fb2d --- /dev/null +++ b/test/parallel/test-zlib-zip-security.js @@ -0,0 +1,265 @@ +'use strict'; + +// Regression tests for the security review of the ZIP archive API. Each test +// encodes the *desired* safe behaviour; on the pre-fix code it fails, exposing +// the issue. See the review for context. + +const common = require('../common'); + +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const fs = require('node:fs'); +const fsp = require('node:fs/promises'); +const path = require('node:path'); +const tmpdir = require('../common/tmpdir'); +const { test } = require('node:test'); + +tmpdir.refresh(); + +let seq = 0; +async function tempZip(entries) { + const dir = await fsp.mkdtemp(path.join(tmpdir.path, `zip-sec-${seq++}-`)); + const chunks = []; + for await (const chunk of zlib.createZipArchive(entries)) chunks.push(chunk); + const filePath = path.join(dir, 'archive.zip'); + await fsp.writeFile(filePath, Buffer.concat(chunks)); + return { dir, filePath, cleanup: () => fsp.rm(dir, { recursive: true, force: true }) }; +} + +// -- Finding 1: no closed-state guard; operations after close() fall through +// to a raw (possibly reused) file descriptor. ------------------------------- + +test('post-close operations are rejected cleanly, not via a raw fd', async () => { + const { dir, filePath, cleanup } = await tempZip( + [await zlib.ZipEntry.create('a.txt', Buffer.from('secret-archive-data'))]); + try { + const zip = await zlib.ZipFile.open(filePath, { writable: true }); + const retained = await zip.get('a.txt'); + await zip.close(); + + // Encourage descriptor reuse: open an unrelated "victim" file right after + // close(), which typically reclaims the just-freed fd number. + const victimPath = path.join(dir, 'victim.txt'); + const VICTIM = 'do-not-touch-this-victim-file'; + await fsp.writeFile(victimPath, VICTIM); + const victimFd = fs.openSync(victimPath, 'r+'); + try { + // A retained entry must not read through the (reused) descriptor. + await assert.rejects(retained.content(), { code: 'ERR_INVALID_STATE' }); + // A post-close mutation must not truncate/overwrite whatever now owns + // the fd number. + await assert.rejects( + zip.addEntry(await zlib.ZipEntry.create('b.txt', Buffer.from('x'))), + { code: 'ERR_INVALID_STATE' }); + // A second close() must be a safe no-op, not a double-close of a reused fd. + await zip.close(); + // The victim file must be byte-for-byte intact. + assert.strictEqual(fs.readFileSync(victimPath, 'utf8'), VICTIM); + } finally { + fs.closeSync(victimFd); + } + } finally { + await cleanup(); + } +}); + +test('post-close operations are rejected cleanly (sync)', async () => { + const { dir, filePath, cleanup } = await tempZip( + [await zlib.ZipEntry.create('a.txt', Buffer.from('secret-archive-data'))]); + try { + const zip = zlib.ZipFile.openSync(filePath, { writable: true }); + const retained = zip.getSync('a.txt'); + zip.closeSync(); + + const victimPath = path.join(dir, 'victim.txt'); + const VICTIM = 'do-not-touch-this-victim-file'; + fs.writeFileSync(victimPath, VICTIM); + const victimFd = fs.openSync(victimPath, 'r+'); + try { + assert.throws(() => retained.contentSync(), { code: 'ERR_INVALID_STATE' }); + assert.throws( + () => zip.addEntrySync(zlib.ZipEntry.createSync('b.txt', Buffer.from('x'))), + { code: 'ERR_INVALID_STATE' }); + // A second closeSync() must be a safe no-op, not EBADF on a reused fd. + zip.closeSync(); + assert.strictEqual(fs.readFileSync(victimPath, 'utf8'), VICTIM); + } finally { + fs.closeSync(victimFd); + } + } finally { + await cleanup(); + } +}); + +// -- Finding 2: add() awaits ZipEntry.create() before registering the mutation, +// so a synchronous close() can slip in and close the fd underneath it. ------- + +test('closeSync() while an async add() is outstanding is rejected', async () => { + const { filePath, cleanup } = await tempZip( + [await zlib.ZipEntry.create('a.txt', Buffer.from('data'))]); + try { + const zip = await zlib.ZipFile.open(filePath, { writable: true }); + // add() runs synchronously up to `await ZipEntry.create(...)` and then + // suspends, having registered nothing yet. + const adding = zip.add('b.txt', Buffer.alloc(1 << 16, 7)); + // The mutation must already be reserved, so a synchronous method sees the + // archive as busy instead of racing the descriptor. + assert.throws(() => zip.closeSync(), { code: 'ERR_INVALID_STATE' }); + await adding; + await zip.close(); + + const check = await zlib.ZipFile.open(filePath); + try { + assert.ok(check.has('b.txt'), 'the added entry must survive'); + } finally { + await check.close(); + } + } finally { + await cleanup(); + } +}); + +test('an earlier add() completes before a later close()', async () => { + const { filePath, cleanup } = await tempZip( + [await zlib.ZipEntry.create('a.txt', Buffer.from('data'))]); + try { + const zip = await zlib.ZipFile.open(filePath, { writable: true }); + const adding = zip.add('b.txt', Buffer.alloc(1 << 16, 9)); + const closing = zip.close(); + // add() was issued first; it must land before close() tears down the fd. + await Promise.all([adding, closing]); + + const check = await zlib.ZipFile.open(filePath); + try { + assert.ok(check.has('b.txt'), 'the earlier add() must not be lost'); + } finally { + await check.close(); + } + } finally { + await cleanup(); + } +}); + +// -- Finding 3 (root cause): zipFiles() classifies via lstat() and then +// re-opens the path with createReadStream(), so it neither validates the +// opened descriptor's type nor pins it against a swap. A non-regular file +// therefore slips through as an ordinary stream source. -------------------- + +test('zipFiles() rejects a non-regular special file', { + skip: common.isWindows ? 'no /dev/null semantics on Windows' : false, +}, async () => { + const chunks = []; + await assert.rejects( + (async () => { + for await (const chunk of zlib.zipFiles([['/dev/null', 'null']])) chunks.push(chunk); + })(), + (err) => err?.code !== undefined && err.code !== 'ERR_ASSERTION', + 'archiving a character device should be rejected, not stored as an empty file'); +}); + +// -- Concern B (verdict: by design): the default decompression ceiling guards +// the buffering path (content()) against a huge allocation; streaming +// (contentIterator / ZipFile.stream) is deliberately not bound by it, so a +// legitimately large member can be read chunk by chunk. Output is still capped +// per chunk at the declared size, and a caller wanting a cap passes maxSize. +// This locks in that asymmetry (a default ceiling on streaming would break +// multi-gigabyte reads - see test/pummel/test-zlib-zip-slow.js). ------------- + +test('the default ceiling bounds content() but not streaming', async () => { + const chunks = []; + for await (const c of zlib.createZipArchive( + [await zlib.ZipEntry.create('big.txt', Buffer.alloc(4096, 1), { method: 'store' })])) { + chunks.push(c); + } + using zip = new zlib.ZipBuffer(Buffer.concat(chunks)); + const entry = zip.get('big.txt'); + + const saved = zlib.getMaxZipContentSize(); + zlib.setMaxZipContentSize(1024); // Below the entry's 4096 declared bytes. + try { + // One-shot buffering enforces the default ceiling... + await assert.rejects(entry.content(), { code: 'ERR_ZIP_ENTRY_TOO_LARGE' }); + // ...but streaming (the bounded-memory path) is not capped by it. + let total = 0; + for await (const chunk of entry.contentIterator()) total += chunk.length; + assert.strictEqual(total, 4096); + // An explicit maxSize still caps streaming when the caller wants it. + await assert.rejects((async () => { + let n = 0; + for await (const chunk of entry.contentIterator({ maxSize: 1024 })) n += chunk.length; + return n; + })(), { code: 'ERR_ZIP_ENTRY_TOO_LARGE' }); + } finally { + zlib.setMaxZipContentSize(saved); + } +}); + +// -- Concern C: when a Zip64 end record is present its values silently override +// the classic EOCD without checking they agree. A non-sentinel classic field +// that disagrees with Zip64 is a parser differential (a classic-only tool and +// Node see different archives) and must be rejected. -------------------------- + +test('open() rejects contradictory classic-EOCD vs Zip64 metadata', async () => { + // 0x10000 entries forces Zip64; the classic total-records field becomes the + // 0xFFFF overflow sentinel. + const entries = []; + for (let i = 0; i < 0x10000; i++) { + entries.push(zlib.ZipEntry.createSync(`e${i}`, Buffer.alloc(0), { method: 'store' })); + } + const bytes = Buffer.concat([...zlib.createZipArchiveSync(entries)]); + + const eocd = bytes.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06])); + assert.ok(eocd >= 0, 'classic EOCD present'); + // The classic total-records field must be the 0xFFFF overflow sentinel. + assert.strictEqual(bytes.readUInt16LE(eocd + 10), 0xFFFF); + // Rewrite the sentinel to a smaller, non-sentinel value that disagrees with + // the Zip64 record (which says 0x10000). + const tampered = Buffer.from(bytes); + tampered.writeUInt16LE(3, eocd + 10); + + assert.throws(() => new zlib.ZipBuffer(tampered), { code: 'ERR_ZIP_INVALID_ARCHIVE' }); + + const dir = await fsp.mkdtemp(path.join(tmpdir.path, `zip-sec-${seq++}-`)); + const p = path.join(dir, 'contradiction.zip'); + try { + await fsp.writeFile(p, tampered); + await assert.rejects(zlib.ZipFile.open(p), { code: 'ERR_ZIP_INVALID_ARCHIVE' }); + assert.throws(() => zlib.ZipFile.openSync(p), { code: 'ERR_ZIP_INVALID_ARCHIVE' }); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + } +}, { timeout: 120_000 }); + +// -- Finding 4: the file-backed open-time overlap check uses a 30-byte lower +// bound for each local header, while the in-memory reader uses the exact +// local-header length. A crafted "quoted overlap" archive therefore passes +// ZipFile.open() but is (correctly) rejected by ZipBuffer. ----------------- + +test('ZipFile.open() enforces the same member-overlap check as ZipBuffer', async () => { + const chunks = []; + for await (const chunk of zlib.createZipArchive([ + await zlib.ZipEntry.create('A', Buffer.alloc(40, 1), { method: 'store' }), + await zlib.ZipEntry.create('B', Buffer.alloc(40, 2), { method: 'store' }), + ])) chunks.push(chunk); + const tampered = Buffer.concat(chunks); + // Enlarge entry A's *local* extra-field length. Its real data range (which + // the read path locates via the local header) now runs past entry B's local + // header - the quoted/overlapping zip-bomb shape. The central directory is + // left untouched, so the offset+30+compressedSize open check still sees room. + tampered.writeUInt16LE(tampered.readUInt16LE(28) + 8, 28); + + // The in-memory reader rejects it outright. + assert.throws(() => new zlib.ZipBuffer(tampered), { code: 'ERR_ZIP_INVALID_ARCHIVE' }); + + // The file-backed reader must reject it too, at open time - not silently + // accept an archive whose members overlap. + const dir = await fsp.mkdtemp(path.join(tmpdir.path, `zip-sec-${seq++}-`)); + const p = path.join(dir, 'overlap.zip'); + try { + await fsp.writeFile(p, tampered); + await assert.rejects(zlib.ZipFile.open(p), { code: 'ERR_ZIP_INVALID_ARCHIVE' }); + assert.throws(() => zlib.ZipFile.openSync(p), { code: 'ERR_ZIP_INVALID_ARCHIVE' }); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + } +}); diff --git a/test/parallel/test-zlib-zip-sync.js b/test/parallel/test-zlib-zip-sync.js new file mode 100644 index 000000000000..1f7099849b9a --- /dev/null +++ b/test/parallel/test-zlib-zip-sync.js @@ -0,0 +1,247 @@ +'use strict'; + +require('../common'); + +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const fs = require('node:fs'); +const path = require('node:path'); +const tmpdir = require('../common/tmpdir'); +const { test } = require('node:test'); + +tmpdir.refresh(); + +function buildArchiveSync(entries, comment) { + const chunks = []; + for (const chunk of zlib.createZipArchiveSync(entries, comment)) chunks.push(chunk); + return Buffer.concat(chunks); +} + +function createTempZipSync(entries, comment) { + const dir = fs.mkdtempSync(path.join(tmpdir.path, 'zlib-zip-sync-')); + const filePath = path.join(dir, 'archive.zip'); + fs.writeFileSync(filePath, buildArchiveSync(entries, comment)); + return { filePath, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) }; +} + +// -- ZipEntry ----------------------------------------------------------------- + +test('ZipEntry.createSync()/contentSync() round-trip, deflate and store', () => { + const deflateEntry = zlib.ZipEntry.createSync('a.txt', Buffer.from('a'.repeat(1000))); + assert.strictEqual(deflateEntry.method, 8); + assert.strictEqual(deflateEntry.contentSync().toString(), 'a'.repeat(1000)); + + const storeEntry = zlib.ZipEntry.createSync('b.bin', Buffer.from([1, 2, 3]), { method: 'store' }); + assert.strictEqual(storeEntry.method, 0); + assert.deepStrictEqual(storeEntry.contentSync(), Buffer.from([1, 2, 3])); + + // Matches the crc32/size that the async ZipEntry.create() would produce. + const data = Buffer.from('some content to compare'); + assert.strictEqual(zlib.ZipEntry.createSync('x', data).crc32, zlib.crc32(data)); +}); + +test('ZipEntry.createSync()/contentSync() round-trip, zstd', () => { + const zstdEntry = zlib.ZipEntry.createSync('z.txt', Buffer.from('z'.repeat(1000)), { method: 'zstd' }); + assert.strictEqual(zstdEntry.method, 93); + assert.strictEqual(zstdEntry.contentSync().toString(), 'z'.repeat(1000)); +}); + +test('ZipEntry.contentSync() enforces maxSize and CRC verification like content()', () => { + const entry = zlib.ZipEntry.createSync('a.txt', Buffer.from('hello world'), { method: 'store' }); + assert.throws(() => entry.contentSync({ maxSize: 1 }), { code: 'ERR_ZIP_ENTRY_TOO_LARGE' }); + + const archive = buildArchiveSync([entry]); + const tampered = Buffer.from(archive); + tampered[30 + 'a.txt'.length] ^= 0xff; // Flip a content byte. + const [tamperedEntry] = zlib.ZipEntry.read(tampered); + assert.throws(() => tamperedEntry.contentSync(), { code: 'ERR_ZIP_ENTRY_CORRUPT' }); + assert.strictEqual(tamperedEntry.contentSync({ verify: false }).length, 'hello world'.length); +}); + +test('createZipArchiveSync() throws for a streaming (pending) entry', () => { + const streaming = zlib.ZipEntry.createStream('big.bin', (async function* () {})()); + assert.throws(() => [...zlib.createZipArchiveSync([streaming])], { code: 'ERR_INVALID_STATE' }); +}); + +// -- ZipBuffer ---------------------------------------------------------------- + +test('ZipBuffer.addSync()/toBufferSync() round-trip', () => { + const archive = buildArchiveSync([zlib.ZipEntry.createSync('a.txt', Buffer.from('a'))], 'a comment'); + using zip = new zlib.ZipBuffer(archive); + const added = zip.addSync('b.txt', Buffer.from('b')); + assert.strictEqual(added.name, 'b.txt'); + assert.strictEqual(zip.size, 2); + + const rebuilt = zip.toBufferSync(); + using reread = new zlib.ZipBuffer(rebuilt); + assert.deepStrictEqual([...reread.keys()].sort(), ['a.txt', 'b.txt']); + assert.strictEqual(reread.get('a.txt').contentSync().toString(), 'a'); + assert.strictEqual(reread.get('b.txt').contentSync().toString(), 'b'); + assert.strictEqual(reread.comment, 'a comment'); +}); + +// -- ZipFile ------------------------------------------------------------------ + +test('ZipFile.openSync() reads the same entries as open()', async () => { + const { filePath, cleanup } = createTempZipSync([ + zlib.ZipEntry.createSync('a.txt', Buffer.from('a')), + zlib.ZipEntry.createSync('b.bin', Buffer.from([1, 2, 3]), { method: 'store' }), + ]); + try { + const zip = zlib.ZipFile.openSync(filePath); + try { + assert.strictEqual(zip.writable, false); + assert.strictEqual(zip.size, 2); + assert.strictEqual(zip.getSync('a.txt').contentSync().toString(), 'a'); + assert.deepStrictEqual(zip.getSync('b.bin').contentSync(), Buffer.from([1, 2, 3])); + assert.deepStrictEqual([...zip.valuesSync()].map((e) => e.name).sort(), ['a.txt', 'b.bin']); + assert.deepStrictEqual([...zip.entriesSync()].map(([n]) => n).sort(), ['a.txt', 'b.bin']); + const seen = []; + zip.forEachSync((entry, name) => seen.push(name)); + assert.deepStrictEqual(seen.sort(), ['a.txt', 'b.bin']); + } finally { + zip.closeSync(); + } + + const asyncZip = await zlib.ZipFile.open(filePath); + try { + assert.deepStrictEqual([...asyncZip.keys()].sort(), ['a.txt', 'b.bin']); + } finally { + await asyncZip.close(); + } + } finally { + cleanup(); + } +}); + +test('ZipFile opened via openSync supports `using` (Symbol.dispose)', () => { + const { filePath, cleanup } = createTempZipSync([zlib.ZipEntry.createSync('a.txt', Buffer.from('a'))]); + try { + { + using zip = zlib.ZipFile.openSync(filePath); + assert.strictEqual(zip.getSync('a.txt').contentSync().toString(), 'a'); + } + // Disposed: the fd should be closed. Re-opening the same path must still work. + const reopened = zlib.ZipFile.openSync(filePath); + reopened.closeSync(); + } finally { + cleanup(); + } +}); + +test('ZipFile.addEntrySync()/addSync()/deleteSync() alter the file synchronously', () => { + const { filePath, cleanup } = createTempZipSync([ + zlib.ZipEntry.createSync('a.txt', Buffer.from('AAAA'.repeat(20))), + zlib.ZipEntry.createSync('b.bin', Buffer.from([1, 2, 3]), { method: 'store' }), + ]); + try { + const zip = zlib.ZipFile.openSync(filePath, { writable: true }); + try { + assert.strictEqual(zip.writable, true); + const sizeBefore = fs.statSync(filePath).size; + const added = zip.addSync('c.txt', Buffer.from('a fresh member')); + assert.strictEqual(added.name, 'c.txt'); + assert.strictEqual(zip.size, 3); + assert.ok(fs.statSync(filePath).size > sizeBefore); + assert.strictEqual(zip.getSync('c.txt').contentSync().toString(), 'a fresh member'); + // Original entries untouched. + assert.strictEqual(zip.getSync('a.txt').contentSync().toString(), 'AAAA'.repeat(20)); + + const entry = zlib.ZipEntry.createSync('d.txt', Buffer.from('d')); + assert.strictEqual(zip.addEntrySync(entry), entry); + + const sizeBeforeDelete = fs.statSync(filePath).size; + assert.strictEqual(zip.deleteSync('b.bin'), true); + assert.ok(fs.statSync(filePath).size < sizeBeforeDelete); + assert.strictEqual(zip.deleteSync('does-not-exist'), false); + } finally { + zip.closeSync(); + } + + const reread = zlib.ZipFile.openSync(filePath); + try { + assert.deepStrictEqual([...reread.keys()].sort(), ['a.txt', 'c.txt', 'd.txt']); + } finally { + reread.closeSync(); + } + } finally { + cleanup(); + } +}); + +test('ZipFile.addEntrySync() rejects a pending streaming entry', () => { + const { filePath, cleanup } = createTempZipSync([zlib.ZipEntry.createSync('a.txt', Buffer.from('a'))]); + try { + const zip = zlib.ZipFile.openSync(filePath, { writable: true }); + try { + const streaming = zlib.ZipEntry.createStream('big.bin', (async function* () {})()); + assert.throws(() => zip.addEntrySync(streaming), { code: 'ERR_INVALID_STATE' }); + } finally { + zip.closeSync(); + } + } finally { + cleanup(); + } +}); + +test('ZipFile sync mutators throw ERR_ZIP_NOT_WRITABLE when not opened writable', () => { + const { filePath, cleanup } = createTempZipSync([zlib.ZipEntry.createSync('a.txt', Buffer.from('a'))]); + try { + const zip = zlib.ZipFile.openSync(filePath); + try { + assert.throws(() => zip.addSync('b.txt', Buffer.from('b')), { code: 'ERR_ZIP_NOT_WRITABLE' }); + assert.throws(() => zip.deleteSync('a.txt'), { code: 'ERR_ZIP_NOT_WRITABLE' }); + } finally { + zip.closeSync(); + } + } finally { + cleanup(); + } +}); + +test('ZipFile.compactSync() matches compact() and does not touch the open file', () => { + const { filePath, cleanup } = createTempZipSync([ + zlib.ZipEntry.createSync('a.txt', Buffer.from('a'.repeat(1000))), + zlib.ZipEntry.createSync('b.txt', Buffer.from('b'.repeat(1000))), + ]); + try { + const zip = zlib.ZipFile.openSync(filePath, { writable: true }); + try { + zip.deleteSync('b.txt'); + const sizeWithDeadSpace = fs.statSync(filePath).size; + const compacted = zip.compactSync(); + assert.strictEqual(fs.statSync(filePath).size, sizeWithDeadSpace); + assert.ok(compacted.length < sizeWithDeadSpace); + using reread = new zlib.ZipBuffer(compacted); + assert.deepStrictEqual([...reread.keys()], ['a.txt']); + } finally { + zip.closeSync(); + } + } finally { + cleanup(); + } +}); + +test('a sync ZipFile method throws while an async mutation has not settled yet', async () => { + const { filePath, cleanup } = createTempZipSync([zlib.ZipEntry.createSync('seed.txt', Buffer.from('seed'))]); + try { + const zip = await zlib.ZipFile.open(filePath, { writable: true }); + try { + // addEntry() (unlike add()) increments the busy counter synchronously, + // at call time - before any internal awaiting - so the checks below + // are guaranteed to observe the archive as busy. + const entry = zlib.ZipEntry.createSync('slow.txt', Buffer.from('x')); + const pending = zip.addEntry(entry); + assert.throws(() => zip.getSync('seed.txt'), { code: 'ERR_INVALID_STATE' }); + assert.throws(() => zip.addSync('y.txt', Buffer.from('y')), { code: 'ERR_INVALID_STATE' }); + assert.throws(() => zip.closeSync(), { code: 'ERR_INVALID_STATE' }); + await pending; + // Once settled, sync methods work again. + assert.strictEqual(zip.getSync('seed.txt').contentSync().toString(), 'seed'); + } finally { + await zip.close(); + } + } finally { + cleanup(); + } +}); diff --git a/test/parallel/test-zlib-zip-writable.js b/test/parallel/test-zlib-zip-writable.js new file mode 100644 index 000000000000..9e036f62de31 --- /dev/null +++ b/test/parallel/test-zlib-zip-writable.js @@ -0,0 +1,347 @@ +'use strict'; + +require('../common'); + +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const tmpdir = require('../common/tmpdir'); +const { test } = require('node:test'); + +tmpdir.refresh(); + +async function buildArchive(entries, comment) { + const chunks = []; + for await (const chunk of zlib.createZipArchive(entries, comment)) chunks.push(chunk); + return Buffer.concat(chunks); +} + +async function createTempZip(entries, comment) { + const dir = await fs.mkdtemp(path.join(tmpdir.path, 'zlib-zip-writable-')); + const filePath = path.join(dir, 'archive.zip'); + await fs.writeFile(filePath, await buildArchive(entries, comment)); + return { filePath, cleanup: () => fs.rm(dir, { recursive: true, force: true }) }; +} + +// -- ZipBuffer -------------------------------------------------------------- + +test('ZipBuffer is always writable', async () => { + const archive = await buildArchive([]); + using zip = new zlib.ZipBuffer(archive); + assert.strictEqual(zip.writable, true); +}); + +test('ZipBuffer preserves the archive comment across toBuffer()', async () => { + const archive = await buildArchive([], 'original comment'); + using zip = new zlib.ZipBuffer(archive); + assert.strictEqual(zip.comment, 'original comment'); + const rebuilt = await zip.toBuffer(); + using reread = new zlib.ZipBuffer(rebuilt); + assert.strictEqual(reread.comment, 'original comment'); +}); + +test('ZipBuffer add()/addEntry()/delete()/clear() mutate the in-memory index', async () => { + const archive = await buildArchive([ + await zlib.ZipEntry.create('a.txt', Buffer.from('a')), + ]); + using zip = new zlib.ZipBuffer(archive); + assert.strictEqual(zip.size, 1); + + const added = await zip.add('b.txt', Buffer.from('b')); + assert.strictEqual(added.name, 'b.txt'); + assert.strictEqual(zip.size, 2); + assert.strictEqual(zip.has('b.txt'), true); + + const entry = await zlib.ZipEntry.create('c.txt', Buffer.from('c')); + assert.strictEqual(zip.addEntry(entry), entry); + assert.strictEqual(zip.size, 3); + + assert.strictEqual(zip.delete('a.txt'), true); + assert.strictEqual(zip.delete('a.txt'), false); + assert.strictEqual(zip.size, 2); + + zip.clear(); + assert.strictEqual(zip.size, 0); +}); + +test('ZipBuffer.toBuffer() serializes the current live set, replacing on overwrite', async () => { + const archive = await buildArchive([ + await zlib.ZipEntry.create('a.txt', Buffer.from('original')), + await zlib.ZipEntry.create('b.txt', Buffer.from('keep')), + ]); + using zip = new zlib.ZipBuffer(archive); + await zip.add('a.txt', Buffer.from('replaced')); + zip.delete('b.txt'); + await zip.add('c.txt', Buffer.from('new')); + + const rebuilt = await zip.toBuffer(); + using reread = new zlib.ZipBuffer(rebuilt); + assert.deepStrictEqual([...reread.keys()].sort(), ['a.txt', 'c.txt']); + assert.strictEqual((await reread.get('a.txt').content()).toString(), 'replaced'); + assert.strictEqual((await reread.get('c.txt').content()).toString(), 'new'); +}); + +test('ZipBuffer.addEntry() rejects a non-ZipEntry value', async () => { + const archive = await buildArchive([]); + using zip = new zlib.ZipBuffer(archive); + assert.throws(() => zip.addEntry({ name: 'fake.txt' }), { code: 'ERR_INVALID_ARG_TYPE' }); +}); + +// -- ZipFile ------------------------------------------------------------------ + +test('ZipFile defaults to read-only and rejects mutation', async () => { + const { filePath, cleanup } = await createTempZip([await zlib.ZipEntry.create('a.txt', Buffer.from('a'))]); + try { + const zip = await zlib.ZipFile.open(filePath); + try { + assert.strictEqual(zip.writable, false); + await assert.rejects(zip.add('b.txt', Buffer.from('b')), { code: 'ERR_ZIP_NOT_WRITABLE' }); + await assert.rejects(zip.delete('a.txt'), { code: 'ERR_ZIP_NOT_WRITABLE' }); + } finally { + await zip.close(); + } + } finally { + await cleanup(); + } +}); + +test('ZipFile opened writable: addEntry() appends where the old CD used to be', async () => { + const { filePath, cleanup } = await createTempZip([ + await zlib.ZipEntry.create('a.txt', Buffer.from('AAAA'.repeat(20))), + await zlib.ZipEntry.create('b.bin', Buffer.from([1, 2, 3]), { method: 'store' }), + ]); + try { + const sizeBefore = (await fs.stat(filePath)).size; + const zip = await zlib.ZipFile.open(filePath, { writable: true }); + try { + assert.strictEqual(zip.writable, true); + const added = await zip.add('c.txt', Buffer.from('a fresh member')); + assert.strictEqual(added.name, 'c.txt'); + assert.strictEqual(zip.size, 3); + + // The file must actually be altered by the time the call returns. + const sizeAfter = (await fs.stat(filePath)).size; + assert.ok(sizeAfter > sizeBefore); + + // Original entries must be untouched. + assert.strictEqual((await (await zip.get('a.txt')).content()).toString(), 'AAAA'.repeat(20)); + assert.deepStrictEqual(await (await zip.get('b.bin')).content(), Buffer.from([1, 2, 3])); + assert.strictEqual((await (await zip.get('c.txt')).content()).toString(), 'a fresh member'); + } finally { + await zip.close(); + } + + // Re-opening from scratch must see the same three entries. + const reread = await zlib.ZipFile.open(filePath); + try { + assert.deepStrictEqual([...reread.keys()].sort(), ['a.txt', 'b.bin', 'c.txt']); + } finally { + await reread.close(); + } + } finally { + await cleanup(); + } +}); + +test('ZipFile addEntry() promotes a spent streaming entry into a readable file-backed entry', async () => { + const { filePath, cleanup } = await createTempZip([ + await zlib.ZipEntry.create('seed.txt', Buffer.from('seed')), + ]); + try { + const zip = await zlib.ZipFile.open(filePath, { writable: true }); + try { + const payload = 'streamed payload'.repeat(64); + async function* source() { + yield Buffer.from(payload.slice(0, 10)); + yield Buffer.from(payload.slice(10)); + } + const streamEntry = zlib.ZipEntry.createStream('s.txt', source()); + + // Before it is written, a streaming entry has no readable content. + await assert.rejects(streamEntry.content(), { code: 'ERR_INVALID_STATE' }); + + const returned = await zip.addEntry(streamEntry); + // addEntry() returns the same object, now promoted in place. + assert.strictEqual(returned, streamEntry); + + // The once-spent entry is now readable, both buffered and streamed. + assert.strictEqual((await streamEntry.content()).toString(), payload); + const chunks = []; + for await (const chunk of streamEntry.contentIterator()) chunks.push(chunk); + assert.strictEqual(Buffer.concat(chunks).toString(), payload); + + // And re-serializable: it can be copied into a fresh archive. + const copy = new zlib.ZipBuffer(await buildArchive([streamEntry])); + assert.strictEqual(copy.get('s.txt').contentSync().toString(), payload); + assert.strictEqual(copy.get('s.txt').crc32 >>> 0, streamEntry.crc32 >>> 0); + + // An in-memory entry added alongside keeps its own buffer (not promoted). + const mem = await zlib.ZipEntry.create('m.txt', Buffer.from('in memory')); + await zip.addEntry(mem); + assert.deepStrictEqual(mem.rawContent, mem.rawContent); // still a Buffer + assert.notStrictEqual(mem.rawContent, null); + } finally { + await zip.close(); + } + + // The bytes persisted correctly and re-open sees the streamed member. + const reread = await zlib.ZipFile.open(filePath); + try { + assert.strictEqual( + (await (await reread.get('s.txt')).content()).toString(), + 'streamed payload'.repeat(64)); + } finally { + await reread.close(); + } + } finally { + await cleanup(); + } +}); + +test('an in-place rewrite keeps central and local data-descriptor flags in agreement', async () => { + const { filePath, cleanup } = await createTempZip([ + await zlib.ZipEntry.create('seed.txt', Buffer.from('seed')), + ]); + try { + const zip = await zlib.ZipFile.open(filePath, { writable: true }); + try { + async function* source() { yield Buffer.from('streamed payload'); } + await zip.addEntry(zlib.ZipEntry.createStream('s.txt', source())); + // A second mutation rebuilds the central directory from the freshly + // adopted headers. The streamed entry's on-disk local header (bit 3 + // set, with a data descriptor after its content) is never rewritten, + // so the rebuilt central header must keep advertising bit 3 - a + // cleared flag would contradict the local header (sec. 4.3.12). + await zip.delete('seed.txt'); + } finally { + await zip.close(); + } + + const raw = await fs.readFile(filePath); + const FLAG_DATA_DESCRIPTOR = 0x0008; + // Only s.txt is live: its central record locates its local header. + const centralPos = raw.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02])); + assert.notStrictEqual(centralPos, -1); + const centralFlags = raw.readUInt16LE(centralPos + 8); + const localOffset = raw.readUInt32LE(centralPos + 42); + assert.strictEqual(raw.readUInt32LE(localOffset), 0x04034b50); + const localFlags = raw.readUInt16LE(localOffset + 6); + assert.strictEqual(localFlags & FLAG_DATA_DESCRIPTOR, FLAG_DATA_DESCRIPTOR); + assert.strictEqual(centralFlags & FLAG_DATA_DESCRIPTOR, FLAG_DATA_DESCRIPTOR); + + // The archive stays fully readable after the rewrite. + const reread = await zlib.ZipFile.open(filePath); + try { + assert.strictEqual( + (await (await reread.get('s.txt')).content()).toString(), 'streamed payload'); + } finally { + await reread.close(); + } + } finally { + await cleanup(); + } +}); + +test('ZipFile opened writable: delete() rewrites the CD without growing the file', async () => { + const { filePath, cleanup } = await createTempZip([ + await zlib.ZipEntry.create('a.txt', Buffer.from('a')), + await zlib.ZipEntry.create('b.txt', Buffer.from('b')), + ]); + try { + const zip = await zlib.ZipFile.open(filePath, { writable: true }); + try { + const sizeBefore = (await fs.stat(filePath)).size; + assert.strictEqual(await zip.delete('b.txt'), true); + const sizeAfter = (await fs.stat(filePath)).size; + assert.ok(sizeAfter < sizeBefore); + assert.strictEqual(zip.has('b.txt'), false); + assert.strictEqual(zip.size, 1); + assert.strictEqual(await zip.delete('does-not-exist'), false); + } finally { + await zip.close(); + } + } finally { + await cleanup(); + } +}); + +test('ZipFile.compact() streams a fresh archive with no dead entries and does not touch the open file', async () => { + const { filePath, cleanup } = await createTempZip([ + await zlib.ZipEntry.create('a.txt', Buffer.from('a'.repeat(1000))), + await zlib.ZipEntry.create('b.txt', Buffer.from('b'.repeat(1000))), + ]); + try { + const zip = await zlib.ZipFile.open(filePath, { writable: true }); + try { + await zip.delete('b.txt'); // Leaves dead space behind + const sizeWithDeadSpace = (await fs.stat(filePath)).size; + + const chunks = []; + for await (const chunk of zip.compact()) chunks.push(chunk); + const compacted = Buffer.concat(chunks); + + // compact() must not have modified the still-open file. + assert.strictEqual((await fs.stat(filePath)).size, sizeWithDeadSpace); + + assert.ok(compacted.length < sizeWithDeadSpace); + using reread = new zlib.ZipBuffer(compacted); + assert.deepStrictEqual([...reread.keys()], ['a.txt']); + assert.strictEqual((await reread.get('a.txt').content()).toString(), 'a'.repeat(1000)); + } finally { + await zip.close(); + } + } finally { + await cleanup(); + } +}); + +test('ZipFile preserves the archive comment across writes', async () => { + const { filePath, cleanup } = await createTempZip( + [await zlib.ZipEntry.create('a.txt', Buffer.from('a'))], 'a comment'); + try { + const zip = await zlib.ZipFile.open(filePath, { writable: true }); + try { + assert.strictEqual(zip.comment, 'a comment'); + await zip.add('b.txt', Buffer.from('b')); + assert.strictEqual(zip.comment, 'a comment'); + } finally { + await zip.close(); + } + const reread = await zlib.ZipFile.open(filePath); + try { + assert.strictEqual(reread.comment, 'a comment'); + } finally { + await reread.close(); + } + } finally { + await cleanup(); + } +}); + +test('ZipFile serializes concurrent add()/delete() calls instead of racing', async () => { + const { filePath, cleanup } = await createTempZip([await zlib.ZipEntry.create('seed.txt', Buffer.from('seed'))]); + try { + const zip = await zlib.ZipFile.open(filePath, { writable: true }); + try { + const names = []; + for (let i = 0; i < 20; i++) names.push(`f${i}.txt`); + await Promise.all(names.map((name) => zip.add(name, Buffer.from(name)))); + assert.strictEqual(zip.size, names.length + 1); + for (const name of names) { + assert.strictEqual((await (await zip.get(name)).content()).toString(), name); + } + } finally { + await zip.close(); + } + + const reread = await zlib.ZipFile.open(filePath); + try { + assert.strictEqual(reread.size, 21); + } finally { + await reread.close(); + } + } finally { + await cleanup(); + } +}, { timeout: 30_000 }); diff --git a/test/parallel/test-zlib-zip-zip64.js b/test/parallel/test-zlib-zip-zip64.js new file mode 100644 index 000000000000..d22b14b99baa --- /dev/null +++ b/test/parallel/test-zlib-zip-zip64.js @@ -0,0 +1,38 @@ +'use strict'; + +require('../common'); + +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const { test } = require('node:test'); + +const ZIP64_EOCD_SIGNATURE = Buffer.from([0x50, 0x4b, 0x06, 0x06]); + +async function buildArchive(count) { + const entries = []; + for (let i = 0; i < count; i++) { + entries.push(await zlib.ZipEntry.create(`entry-${i}`, Buffer.alloc(0), { method: 'store' })); + } + const chunks = []; + for await (const chunk of zlib.createZipArchive(entries)) chunks.push(chunk); + return Buffer.concat(chunks); +} + +test('an entry count at or above 0xFFFF forces Zip64 structures', async () => { + const archive = await buildArchive(0x10000); + assert.ok(archive.includes(ZIP64_EOCD_SIGNATURE)); + + const read = [...zlib.ZipEntry.read(archive)]; + assert.strictEqual(read.length, 0x10000); + + using zip = new zlib.ZipBuffer(archive); + assert.strictEqual(zip.size, 0x10000); + assert.strictEqual(zip.has('entry-0'), true); + assert.strictEqual(zip.has(`entry-${0x10000 - 1}`), true); +}, { timeout: 120_000 }); + +test('an archive below the Zip64 thresholds contains no Zip64 structures', async () => { + const archive = await buildArchive(10); + assert.ok(!archive.includes(ZIP64_EOCD_SIGNATURE)); + assert.strictEqual([...zlib.ZipEntry.read(archive)].length, 10); +}); diff --git a/test/parallel/test-zlib-zip.js b/test/parallel/test-zlib-zip.js new file mode 100644 index 000000000000..910c17122d8f --- /dev/null +++ b/test/parallel/test-zlib-zip.js @@ -0,0 +1,147 @@ +'use strict'; + +require('../common'); + +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const { test } = require('node:test'); + +async function buildArchive(entries, comment) { + const chunks = []; + for await (const chunk of zlib.createZipArchive(entries, comment)) { + chunks.push(chunk); + } + return Buffer.concat(chunks); +} + +// Deterministic high-entropy bytes that deflate/zstd cannot shrink, without +// pulling in node:crypto (unavailable on --without-ssl builds). A xorshift32 +// PRNG is more than random enough to defeat compression. +function incompressibleBytes(length) { + const out = Buffer.allocUnsafe(length); + let state = 0x9e3779b9; + for (let i = 0; i < length; i++) { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + out[i] = state & 0xff; + } + return out; +} + +test('round-trips a small archive through ZipEntry.read', async () => { + const entries = [ + await zlib.ZipEntry.create('hello.txt', Buffer.from('Hello, world!'.repeat(20))), + await zlib.ZipEntry.create('raw.bin', Buffer.from([1, 2, 3, 4, 5]), { method: 'store' }), + await zlib.ZipEntry.create('empty.txt', Buffer.alloc(0)), + await zlib.ZipEntry.create('dir/', Buffer.alloc(0)), + ]; + const archive = await buildArchive(entries, 'test comment'); + + const read = [...zlib.ZipEntry.read(archive)]; + assert.strictEqual(read.length, 4); + + const byName = new Map(read.map((entry) => [entry.name, entry])); + assert.strictEqual((await byName.get('hello.txt').content()).toString(), + 'Hello, world!'.repeat(20)); + assert.strictEqual(byName.get('hello.txt').method, 8); + assert.deepStrictEqual(await byName.get('raw.bin').content(), Buffer.from([1, 2, 3, 4, 5])); + assert.strictEqual(byName.get('raw.bin').method, 0); + assert.strictEqual((await byName.get('empty.txt').content()).length, 0); + assert.strictEqual(byName.get('dir/').isDirectory, true); + assert.strictEqual(byName.get('hello.txt').isFile, true); +}); + +test('ZipBuffer indexes entries by name', async () => { + const entries = [ + await zlib.ZipEntry.create('a.txt', Buffer.from('a')), + await zlib.ZipEntry.create('b.txt', Buffer.from('b')), + ]; + const archive = await buildArchive(entries); + using zip = new zlib.ZipBuffer(archive); + + assert.strictEqual(zip.size, 2); + assert.strictEqual(zip.has('a.txt'), true); + assert.strictEqual(zip.has('missing.txt'), false); + assert.strictEqual((await zip.get('a.txt').content()).toString(), 'a'); + assert.deepStrictEqual([...zip.keys()].sort(), ['a.txt', 'b.txt']); + + assert.throws(() => zip.get('missing.txt'), { code: 'ERR_ZIP_ENTRY_NOT_FOUND' }); +}); + +test('an incompressible or empty entry falls back to store', async () => { + const random = incompressibleBytes(4096); + const entry = await zlib.ZipEntry.create('random.bin', random); + assert.strictEqual(entry.method, 0); + assert.deepStrictEqual(await entry.content(), random); + + const empty = await zlib.ZipEntry.create('empty.bin', Buffer.alloc(0)); + assert.strictEqual(empty.method, 0); +}); + +test('explicit store option is honored even for compressible data', async () => { + const data = Buffer.from('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + const entry = await zlib.ZipEntry.create('stored.txt', data, { method: 'store' }); + assert.strictEqual(entry.method, 0); + assert.deepStrictEqual(entry.rawContent, data); +}); + +test('the zstd method compresses and round-trips through an archive', async () => { + const data = Buffer.from('zstd content '.repeat(200)); + const entry = await zlib.ZipEntry.create('z.txt', data, { method: 'zstd' }); + assert.strictEqual(entry.method, 93); + assert.ok(entry.compressedSize < data.length); + assert.deepStrictEqual(await entry.content(), data); + + const archive = await buildArchive([entry]); + const [read] = zlib.ZipEntry.read(archive); + assert.strictEqual(read.method, 93); + assert.deepStrictEqual(await read.content(), data); +}); + +test('an incompressible entry with method zstd falls back to store', async () => { + const random = incompressibleBytes(4096); + const entry = await zlib.ZipEntry.create('random.bin', random, { method: 'zstd' }); + assert.strictEqual(entry.method, 0); + assert.deepStrictEqual(await entry.content(), random); +}); + +test('crc32 chains the same way as zlib.crc32', async () => { + const data = Buffer.from('the quick brown fox jumps over the lazy dog'); + const entry = await zlib.ZipEntry.create('f.txt', data); + assert.strictEqual(entry.crc32, zlib.crc32(data)); +}); + +test('createZipArchive rejects an overlong comment', async () => { + await assert.rejects( + buildArchive([], 'x'.repeat(70000)), + { code: 'ERR_ZIP_ARCHIVE_TOO_LARGE' }, + ); +}); + +test('directory entries cannot carry content', async () => { + await assert.rejects( + zlib.ZipEntry.create('dir/', Buffer.from('x')), + { code: 'ERR_INVALID_ARG_VALUE' }, + ); +}); + +test('ZipEntry.createSymlink round-trips as a symlink entry', async () => { + const entry = zlib.ZipEntry.createSymlink('link', '../target', { mode: 0o777 }); + const [read] = zlib.ZipEntry.read(await buildArchive([entry])); + assert.strictEqual(read.isSymlink, true); + assert.strictEqual(read.isFile, false); + assert.strictEqual(read.mode, 0o777); + assert.strictEqual(read.contentSync().toString(), '../target'); +}); + +test('a deflate and a zstd member each stream back through contentIterator', async () => { + for (const method of ['deflate', 'zstd']) { + const data = Buffer.from(`${method} payload `.repeat(500)); + const entry = await zlib.ZipEntry.create('m.txt', data, { method }); + const [read] = zlib.ZipEntry.read(await buildArchive([entry])); + const chunks = []; + for await (const chunk of read.contentIterator()) chunks.push(chunk); + assert.deepStrictEqual(Buffer.concat(chunks), data); + } +}); diff --git a/test/pummel/test-zlib-zip-slow.js b/test/pummel/test-zlib-zip-slow.js new file mode 100644 index 000000000000..c3e334c06dec --- /dev/null +++ b/test/pummel/test-zlib-zip-slow.js @@ -0,0 +1,129 @@ +'use strict'; + +// This test writes and reads back a ZIP archive whose total size exceeds +// 4 GiB, to exercise the Zip64 promotion that is triggered by an offset or +// size overflowing the classic 32-bit fields (as opposed to test-zlib-zip- +// zip64.js in test/parallel, which triggers Zip64 through the 16-bit entry +// count instead). It needs several GiB of free disk space and is too slow +// for the default test run, hence living in test/pummel rather than +// test/parallel. + +const common = require('../common'); + +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const tmpdir = require('../common/tmpdir'); +const { test } = require('node:test'); + +tmpdir.refresh(); + +const GiB = 1024 * 1024 * 1024; +const MEMBER_SIZE = 500 * 1024 * 1024; // Four ~500 MiB stored members... +const STORED_MEMBER_COUNT = 4; +const STREAMED_MEMBER_SIZE = 4.5 * GiB; // ...plus one >4 GiB streamed member: +// the total archive size (~6.5 GiB) pushes offsets over the 4 GiB Zip64 +// threshold, and the streamed member's own sizes exceed 32 bits too, so the +// per-entry Zip64 size fields (central header and data descriptor) are +// exercised as well as the offset promotion. Required free space includes +// generous slack over that total. +const REQUIRED_FREE_BYTES = 12 * GiB; +const CHUNK_SIZE = 16 * 1024 * 1024; + +function fillChunk(seed) { + const chunk = Buffer.allocUnsafe(CHUNK_SIZE); + chunk.fill(seed & 0xff); + return chunk; +} + +async function* repeatingChunks(totalSize, seed) { + let remaining = totalSize; + while (remaining > 0) { + const size = Math.min(CHUNK_SIZE, remaining); + const chunk = fillChunk(seed); + remaining -= size; + yield size === chunk.length ? chunk : chunk.subarray(0, size); + } +} + +test('an archive larger than 4 GiB round-trips and triggers Zip64 via offset', async () => { + let free; + try { + const stats = await fs.statfs(tmpdir.path); + free = stats.bavail * stats.bsize; + } catch { + free = undefined; + } + if (free !== undefined && free < REQUIRED_FREE_BYTES) { + common.skip(`insufficient disk space in ${tmpdir.path} for a >4 GiB archive test`); + return; + } + + const dir = await fs.mkdtemp(path.join(tmpdir.path, 'zlib-zip-slow-')); + const archivePath = path.join(dir, 'large.zip'); + try { + const entries = []; + for (let i = 0; i < STORED_MEMBER_COUNT; i++) { + entries.push(zlib.ZipEntry.createStream(`stored-${i}.bin`, repeatingChunks(MEMBER_SIZE, i), { + method: 'store', + })); + } + entries.push(zlib.ZipEntry.createStream('streamed.bin', repeatingChunks(STREAMED_MEMBER_SIZE, 0xaa), { + method: 'store', + })); + + const handle = await fs.open(archivePath, 'w'); + try { + for await (const chunk of zlib.createZipArchive(entries)) { + await handle.write(chunk); + } + } finally { + await handle.close(); + } + + const stat = await fs.stat(archivePath); + assert.ok(stat.size > 4 * GiB, `archive is only ${stat.size} bytes`); + + const zip = await zlib.ZipFile.open(archivePath); + try { + assert.strictEqual(zip.size, STORED_MEMBER_COUNT + 1); + + let seen = 0; + for await (const chunk of await zip.stream('streamed.bin')) { + seen += chunk.length; + assert.strictEqual(chunk[0], 0xaa); + } + assert.strictEqual(seen, STREAMED_MEMBER_SIZE); + + let storedSeen = 0; + for await (const chunk of await zip.stream('stored-2.bin')) { + storedSeen += chunk.length; + assert.strictEqual(chunk[0], 2); + } + assert.strictEqual(storedSeen, MEMBER_SIZE); + + // The streamed member's sizes genuinely exceed 32 bits (stored, so + // compressed === uncompressed), which the reader must have resolved + // through the central header's Zip64 extra field. + const big = await zip.get('streamed.bin'); + assert.strictEqual(big.size, STREAMED_MEMBER_SIZE); + assert.strictEqual(big.compressedSize, STREAMED_MEMBER_SIZE); + + // Re-serialize the >4 GiB file-backed member through the archive + // writer, discarding the output: this exercises the non-streaming + // Zip64 local-header path (real 64-bit sizes up front, no data + // descriptor) without needing a second copy on disk. + let reserialized = 0; + for await (const chunk of zlib.createZipArchive([big])) { + reserialized += chunk.length; + } + assert.ok(reserialized > STREAMED_MEMBER_SIZE, + `re-serialized only ${reserialized} bytes`); + } finally { + await zip.close(); + } + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}, { timeout: 30 * 60 * 1000 }); From 8e848d9be353fc64d803181ed9c53e353ae3c027 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 6 Aug 2026 13:47:13 +0100 Subject: [PATCH 154/344] zlib: reject ambiguous ZIP archive ends ZIP archives can contain more than one structurally plausible EOCD record. Selecting based on whether a comment reaches EOF can make the chosen central directory depend on trailing padding. Inspect all candidates in the common tail window and reject archives with multiple plausible interpretations. Signed-off-by: Matteo Collina PR-URL: https://github.com/nodejs/node/pull/65007 Reviewed-By: Filip Skokan Reviewed-By: Antoine du Hamel --- lib/internal/zip/headers.js | 83 +++++++++++++++++++----- test/parallel/test-zlib-zip-hardening.js | 77 +++++++++++++++++++++- 2 files changed, 143 insertions(+), 17 deletions(-) diff --git a/lib/internal/zip/headers.js b/lib/internal/zip/headers.js index aadfa1ca3cde..42af8aedeadd 100644 --- a/lib/internal/zip/headers.js +++ b/lib/internal/zip/headers.js @@ -27,6 +27,7 @@ const { MADE_BY_UNIX, SENTINEL16, SENTINEL32, + TAIL_LENGTH, ZIP64_EOCD_MAX_LENGTH, S_IFLNK, S_IFMT, @@ -310,6 +311,51 @@ class LocalFileHeader { } } +// Returns whether an EOCD-looking record could describe an archive this +// implementation supports. This is deliberately only a cheap preflight: the +// selected record still receives the complete Zip64 and central-directory +// validation below. +function isPlausibleArchiveEnd(buffer, eocdPos, scanStart) { + const diskNumber = buffer.readUInt16LE(eocdPos + 4); + const centralDirectoryDiskNumber = buffer.readUInt16LE(eocdPos + 6); + const diskRecords = buffer.readUInt16LE(eocdPos + 8); + const totalRecords = buffer.readUInt16LE(eocdPos + 10); + const centralDirectorySize = buffer.readUInt32LE(eocdPos + 12); + const centralDirectoryOffset = buffer.readUInt32LE(eocdPos + 16); + const needsZip64 = + diskNumber === SENTINEL16 || + centralDirectoryDiskNumber === SENTINEL16 || + diskRecords === SENTINEL16 || + totalRecords === SENTINEL16 || + centralDirectorySize === SENTINEL32 || + centralDirectoryOffset === SENTINEL32; + + const locatorPos = eocdPos - 20; + const hasZip64Locator = locatorPos >= 0 && + buffer.readUInt32LE(locatorPos) === SIG_ZIP64_EOCD_LOCATOR; + if (needsZip64) return hasZip64Locator; + // A Zip64 end record may accompany authoritative, non-sentinel classic + // fields. Its central directory does not immediately precede this EOCD. + if (hasZip64Locator) return true; + if ( + diskNumber !== 0 || + centralDirectoryDiskNumber !== 0 || + diskRecords !== totalRecords || + totalRecords * 46 > centralDirectorySize + ) { + return false; + } + + const centralDirectoryPos = eocdPos - centralDirectorySize; + if (centralDirectoryPos < scanStart) { + // Use the same logical tail window for memory- and file-backed archives. + // The directory is not available for this cheap preflight in either case. + return true; + } + if (totalRecords === 0) return centralDirectorySize === 0; + return buffer.readUInt32LE(centralDirectoryPos) === SIG_CENTRAL_FILE_HEADER; +} + /** * Locates and validates the end-of-archive structures (EOCD, and the Zip64 * EOCD locator/record when present) in `buffer`. `base` is the absolute @@ -334,26 +380,30 @@ function findArchiveEnd(buffer, base = 0) { if (buffer.length < 22) { throw new ERR_ZIP_INVALID_ARCHIVE('no end of central directory record found'); } - const min = MathMax(0, buffer.length - (22 + SENTINEL16)); + // Use the same tail-sized search window for full buffers and ZipFile's tail + // reads. Besides keeping candidate selection consistent, the extra tail + // slack permits a maximum-length comment followed by modest writer padding. + const min = MathMax(0, buffer.length - TAIL_LENGTH); let eocdPos = -1; - // Pass 1: the comment must reach exactly to the end of the buffer (this - // rejects a stray EOCD-looking signature inside an earlier comment). + let fallbackPos = -1; + let exactFallbackPos = -1; for (let pos = buffer.length - 22; pos >= min; pos--) { if (buffer.readUInt32LE(pos) !== SIG_EOCD) continue; - if (pos + 22 + buffer.readUInt16LE(pos + 20) !== buffer.length) continue; + const end = pos + 22 + buffer.readUInt16LE(pos + 20); + if (end > buffer.length) continue; + if (fallbackPos < 0) fallbackPos = pos; + if (end === buffer.length && exactFallbackPos < 0) exactFallbackPos = pos; + if (!isPlausibleArchiveEnd(buffer, pos, min)) continue; + if (eocdPos >= 0) { + throw new ERR_ZIP_INVALID_ARCHIVE( + 'ambiguous end of central directory records'); + } eocdPos = pos; - break; } + // Preserve the targeted validation errors for a sole malformed or + // unsupported candidate. Plausible candidates always take precedence. if (eocdPos < 0) { - // Pass 2: tolerate trailing padding after the EOCD (some streaming - // writers pad their output to a fixed block size); take the last - // candidate found. - for (let pos = buffer.length - 22; pos >= min; pos--) { - if (buffer.readUInt32LE(pos) !== SIG_EOCD) continue; - if (pos + 22 + buffer.readUInt16LE(pos + 20) > buffer.length) continue; - eocdPos = pos; - break; - } + eocdPos = exactFallbackPos >= 0 ? exactFallbackPos : fallbackPos; } if (eocdPos < 0) { throw new ERR_ZIP_INVALID_ARCHIVE('no end of central directory record found'); @@ -474,7 +524,10 @@ function findArchiveEnd(buffer, base = 0) { if (prefix < 0) { throw new ERR_ZIP_INVALID_ARCHIVE('central directory does not fit inside the archive'); } - if (totalRecords * 46 > centralDirectorySize) { + if ( + (totalRecords === 0 && centralDirectorySize !== 0) || + totalRecords * 46 > centralDirectorySize + ) { throw new ERR_ZIP_INVALID_ARCHIVE( 'central directory record count is inconsistent with its size'); } diff --git a/test/parallel/test-zlib-zip-hardening.js b/test/parallel/test-zlib-zip-hardening.js index de2a9efd4918..9427a546c889 100644 --- a/test/parallel/test-zlib-zip-hardening.js +++ b/test/parallel/test-zlib-zip-hardening.js @@ -3,8 +3,10 @@ require('../common'); const assert = require('node:assert'); +const fs = require('node:fs'); const zlib = require('node:zlib'); const { test } = require('node:test'); +const tmpdir = require('../common/tmpdir'); async function buildArchive(entries, comment) { const chunks = []; @@ -47,13 +49,71 @@ test('an EOCD-looking signature inside a trailing comment is not mistaken for th // before it reaches the genuine EOCD signature; embedding 4 bytes that // look like one partway through must not be mistaken for the real record. const fakeSignature = String.fromCharCode(0x50, 0x4b, 0x05, 0x06); - const archive = await buildArchive([entry], `before ${fakeSignature} after`); + const archive = await buildArchive( + [entry], `before ${fakeSignature} this is not a valid EOCD record after`); const read = [...zlib.ZipEntry.read(archive)]; assert.strictEqual(read.length, 1); assert.strictEqual(read[0].name, 'f.txt'); }); +test('multiple plausible EOCD records describing different archives are rejected', async () => { + const first = await buildArchive([ + await zlib.ZipEntry.create('install.sh', Buffer.from('malicious'), { method: 'store' }), + ]); + const second = await buildArchive([ + await zlib.ZipEntry.create('install.sh', Buffer.from('benign'), { method: 'store' }), + ]); + const archive = Buffer.concat([first, second, Buffer.from([0])]); + const firstEocd = first.length - 22; + + // Make the first EOCD exact-to-EOF by treating the second archive and its + // padding as a comment. The second EOCD remains a plausible archive end for + // readers which tolerate trailing padding and select the rightmost record. + archive.writeUInt16LE(archive.length - firstEocd - 22, firstEocd + 20); + + const expected = { + code: 'ERR_ZIP_INVALID_ARCHIVE', + message: /ambiguous end of central directory/, + }; + assert.throws(() => [...zlib.ZipEntry.read(archive)], expected); + assert.throws(() => new zlib.ZipBuffer(archive), expected); + + tmpdir.refresh(); + const file = tmpdir.resolve('ambiguous.zip'); + fs.writeFileSync(file, archive); + await assert.rejects(zlib.ZipFile.open(file), expected); + assert.throws(() => zlib.ZipFile.openSync(file), expected); +}); + +test('an exact EOCD embedded in a genuine comment is rejected as ambiguous', async () => { + const archive = await buildArchive([ + await zlib.ZipEntry.create('f.txt', Buffer.from('content'), { method: 'store' }), + ]); + const nested = Buffer.concat([archive, buildEocd()]); + nested.writeUInt16LE(22, archive.length - 2); + + assert.throws(() => [...zlib.ZipEntry.read(nested)], { + code: 'ERR_ZIP_INVALID_ARCHIVE', + message: /ambiguous end of central directory/, + }); +}); + +test('multiple padded EOCD records are rejected as ambiguous', async () => { + const first = await buildArchive([ + await zlib.ZipEntry.create('a.txt', Buffer.from('first'), { method: 'store' }), + ]); + const second = await buildArchive([ + await zlib.ZipEntry.create('b.txt', Buffer.from('second'), { method: 'store' }), + ]); + const archive = Buffer.concat([first, second, Buffer.from('\0\0')]); + + assert.throws(() => new zlib.ZipBuffer(archive), { + code: 'ERR_ZIP_INVALID_ARCHIVE', + message: /ambiguous end of central directory/, + }); +}); + test('a declared-size mismatch is rejected as corrupt', async () => { const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello world'), { method: 'store' }); const archive = await buildArchive([entry]); @@ -212,7 +272,7 @@ test('every possible truncation of an archive is rejected, deterministically', a test('trailing padding after the EOCD is tolerated', async () => { // Some streaming writers pad their output to a block size; CPython - // tolerates trailing newlines/NULs and so does the pass-2 EOCD scan. + // tolerates trailing newlines/NULs and so does the EOCD scan. const archive = await buildArchive( [await zlib.ZipEntry.create('f.txt', Buffer.from('hi'), { method: 'store' })]); const padded = Buffer.concat([archive, Buffer.from('\r\n\0\0\0')]); @@ -221,6 +281,15 @@ test('trailing padding after the EOCD is tolerated', async () => { assert.strictEqual((await entry.content()).toString(), 'hi'); }); +test('a maximum-length comment followed by block padding is tolerated', async () => { + const comment = 'x'.repeat(0xffff); + const archive = await buildArchive([], comment); + const padded = Buffer.concat([archive, Buffer.alloc(4096)]); + const zip = new zlib.ZipBuffer(padded); + + assert.strictEqual(zip.comment, comment); +}); + test('junk appended past a declared comment is tolerated and the comment preserved', async () => { const archive = await buildArchive( [await zlib.ZipEntry.create('f.txt', Buffer.from('hi'), { method: 'store' })], @@ -262,6 +331,10 @@ test('a record count inconsistent with the directory size is rejected', () => { const archive = Buffer.concat([Buffer.alloc(46), eocd]); assert.throws(() => [...zlib.ZipEntry.read(archive)], { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /inconsistent/ }); + + const zeroRecords = Buffer.concat([Buffer.alloc(46), buildEocd({ cdSize: 46 })]); + assert.throws(() => [...zlib.ZipEntry.read(zeroRecords)], + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /inconsistent/ }); }); test('a corrupted or overrunning central directory header is rejected', async () => { From 575260d3b4f1e2476375af10a0cdd0ac8485355c Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Wed, 12 Aug 2026 10:02:57 +0100 Subject: [PATCH 155/344] zlib: validate central directory record count ZIP readers trusted the EOCD record count. They did not check that the parsed headers consumed the declared central directory size. Reject archives whose count leaves directory bytes unparsed. Signed-off-by: Matteo Collina PR-URL: https://github.com/nodejs/node/pull/65002 Reviewed-By: Filip Skokan Reviewed-By: Antoine du Hamel --- lib/internal/zip/entry.js | 4 +++ lib/internal/zip/headers.js | 4 +++ test/parallel/test-zlib-zip-security.js | 34 +++++++++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/lib/internal/zip/entry.js b/lib/internal/zip/entry.js index 4eab161db9b2..c9fb1cac62cd 100644 --- a/lib/internal/zip/entry.js +++ b/lib/internal/zip/entry.js @@ -941,6 +941,10 @@ function* readArchiveEntries(buf, end) { }); pos = central.byteOffset + central.byteLength; } + if (pos !== cdEnd) { + throw new ERR_ZIP_INVALID_ARCHIVE( + 'central directory record count is inconsistent with its size'); + } // Sort a separate range list; entries themselves are yielded in central // directory order. const ranges = ArrayPrototypeSort(ArrayPrototypeSlice(parsed), (a, b) => a.start - b.start); diff --git a/lib/internal/zip/headers.js b/lib/internal/zip/headers.js index 42af8aedeadd..f1f028cb511b 100644 --- a/lib/internal/zip/headers.js +++ b/lib/internal/zip/headers.js @@ -553,6 +553,10 @@ function readCentralDirectory(buffer, count) { ArrayPrototypePush(result, header); pos += header.byteLength; } + if (pos !== buffer.length) { + throw new ERR_ZIP_INVALID_ARCHIVE( + 'central directory record count is inconsistent with its size'); + } return result; } diff --git a/test/parallel/test-zlib-zip-security.js b/test/parallel/test-zlib-zip-security.js index ba63af08fb2d..aa4b37f4c608 100644 --- a/test/parallel/test-zlib-zip-security.js +++ b/test/parallel/test-zlib-zip-security.js @@ -230,6 +230,40 @@ test('open() rejects contradictory classic-EOCD vs Zip64 metadata', async () => } }, { timeout: 120_000 }); +test('central-directory record count must account for its full declared size', async () => { + const chunks = []; + for await (const chunk of zlib.createZipArchive([ + await zlib.ZipEntry.create('visible.txt', Buffer.from('visible'), { method: 'store' }), + await zlib.ZipEntry.create('hidden.txt', Buffer.from('hidden'), { method: 'store' }), + ])) chunks.push(chunk); + const tampered = Buffer.concat(chunks); + const eocd = tampered.length - 22; + assert.strictEqual(tampered.readUInt16LE(eocd + 8), 2); + assert.strictEqual(tampered.readUInt16LE(eocd + 10), 2); + + // Keep the single-disk counts consistent with each other, but make both + // disagree with the two complete records in the declared directory size. + tampered.writeUInt16LE(1, eocd + 8); + tampered.writeUInt16LE(1, eocd + 10); + const expected = { + code: 'ERR_ZIP_INVALID_ARCHIVE', + message: /central directory record count is inconsistent with its size/, + }; + + assert.throws(() => [...zlib.ZipEntry.read(tampered)], expected); + assert.throws(() => new zlib.ZipBuffer(tampered), expected); + + const dir = await fsp.mkdtemp(path.join(tmpdir.path, `zip-sec-${seq++}-`)); + const p = path.join(dir, 'record-count-mismatch.zip'); + try { + await fsp.writeFile(p, tampered); + await assert.rejects(zlib.ZipFile.open(p), expected); + assert.throws(() => zlib.ZipFile.openSync(p), expected); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + } +}); + // -- Finding 4: the file-backed open-time overlap check uses a 30-byte lower // bound for each local header, while the in-memory reader uses the exact // local-header length. A crafted "quoted overlap" archive therefore passes From e3fda695fea00fd820e356521e1ba400d1edd934 Mon Sep 17 00:00:00 2001 From: Chemi Atlow Date: Thu, 13 Aug 2026 16:26:26 +0300 Subject: [PATCH 156/344] test_runner: extend tag filter with boolean expression DSL Upgrades the experimental tag filter introduced by stage 1 to accept a boolean expression instead of a literal tag name. Grammar: `and`/`&&`, `or`/`||`, `not`/`!`, parentheses for grouping, and `*` wildcards inside identifiers. Standard precedence (`not > and > or`); binary operators are left-associative. Word forms require whitespace separation; punctuation forms do not. Untagged tests evaluate `false` for any include expression and `true` for `not X`, so excluding tags does not accidentally remove untagged tests. The flag and `testTagFilters` option are still repeatable; multiple expressions still AND together. Malformed expressions fail fast at the parent process at startup. Tag value validation tightens to reject whitespace, operator characters (`& | ! ( ) *`), and the reserved words `and`/`or`/`not` in any casing - a breaking change relative to the stage 1 ship, acceptable at Stability 1.0 (Early development). Signed-off-by: atlowChemi PR-URL: https://github.com/nodejs/node/pull/63054 Reviewed-By: Moshe Atlow Reviewed-By: Benjamin Gruenbaum --- doc/api/cli.md | 22 +- doc/api/test.md | 99 +++- doc/node.1 | 20 +- lib/internal/test_runner/runner.js | 35 +- lib/internal/test_runner/tag_filter.js | 446 ++++++++++++++++-- lib/internal/test_runner/utils.js | 18 +- test/parallel/test-runner-tag-filter-cli.mjs | 81 +++- .../test-runner-tag-filter-parser.mjs | 257 ++++++++++ test/parallel/test-runner-tags-events.mjs | 8 + .../test-runner-tags-experimental-warning.mjs | 2 +- test/parallel/test-runner-tags-validation.mjs | 29 +- 11 files changed, 920 insertions(+), 97 deletions(-) create mode 100644 test/parallel/test-runner-tag-filter-parser.mjs diff --git a/doc/api/cli.md b/doc/api/cli.md index 7e503bdde4f2..71f91a825095 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -1480,7 +1480,7 @@ Enable module mocking in the test runner. This feature requires `--allow-worker` if used with the [Permission Model][]. -### `--experimental-test-tag-filter=` +### `--experimental-test-tag-filter=''` + +Resets every counter reported by [`statement.stat()`][] back to zero, except +`memused`, which reports current memory usage and cannot be reset. This +method is a wrapper around [`sqlite3_stmt_status()`][] and is useful for +measuring a specific workload without the counts accumulated by earlier +executions of the same prepared statement. + ### `statement.run([namedParameters][, ...anonymousParameters])` + +* `counter` {string} The name of the counter to read. One of: + + * `'fullscanStep'` The number of times SQLite has stepped forward in a table + as part of a full table scan. + * `'sort'` The number of sort operations that have occurred. + * `'autoindex'` The number of rows inserted into transient indices that were + created automatically to help joins run faster. + * `'vmStep'` The number of virtual machine operations executed by the + prepared statement. + * `'reprepare'` The number of times the statement has been automatically + reprepared due to schema changes or changes to bound parameters. + * `'run'` The number of execution cycles started by the prepared statement. + * `'filterMiss'` The number of times the Bloom filter returned a result that + required the join step to be processed as normal. + * `'filterHit'` The number of times a join step was bypassed because a Bloom + filter returned not-found. + * `'memused'` The approximate number of bytes of heap memory used to store + the prepared statement. + +* Returns: {number} The current value of the requested counter. + +Returns one of the runtime counters that SQLite tracks for this prepared +statement. This method is a wrapper around [`sqlite3_stmt_status()`][] and does +not reset the counter. Asserting that a statement does not perform a full table +scan (`statement.stat('fullscanStep') === 0`) is a useful check to guard +against degenerate performance. + +The `'filterMiss'` and `'filterHit'` counters require SQLite 3.38.0 or later. +Builds linked against an older SQLite with `--shared-sqlite` do not expose them, +and passing either name throws `ERR_INVALID_ARG_VALUE`. + ## Class: `SQLTagStore` +#### Header name constants + +The `HTTP2_HEADER_*` constants provide names for HTTP/2 pseudo-headers and +known HTTP header names. Using these string constants is optional. For example, +`http2.constants.HTTP2_HEADER_CONTENT_TYPE` is equal to `'content-type'`. +For APIs that accept regular header names, +`http2.constants.HTTP2_HEADER_CONTENT_TYPE`, `'content-type'`, and +`'Content-Type'` have the same effect; Node.js serializes the name in +lower-case. + +Regular header constants can be used with the compatibility API wherever the +corresponding literal header name is accepted. In compatibility API request +handlers, prefer `request.method`, `request.authority`, `request.scheme`, and +`request.url` for the corresponding pseudo-headers. Other incoming +pseudo-headers remain available through `request.headers`. Set response status +through `response.statusCode` or the `statusCode` argument to +`response.writeHead()`. Passing `HTTP2_HEADER_STATUS` (`':status'`) to +`response.setHeader()` or in `response.writeHead()`'s headers object throws +`ERR_HTTP2_PSEUDOHEADER_NOT_ALLOWED`. `HTTP2_HEADER_PROTOCOL` is a request +pseudo-header and cannot be sent in a response. + +Incoming header object keys are lower-case, so use a constant or a lower-case +literal when accessing them as object properties. Using a constant does not +change header validation, and the availability of a constant does not imply +that the header is valid in every HTTP/2 context. See [HTTP/2 Headers Object][] +and [Invalid character handling in header names and values][] for details about +header casing and validation. + +##### Pseudo-header constants + +`HTTP2_HEADER_METHOD`, `HTTP2_HEADER_AUTHORITY`, `HTTP2_HEADER_SCHEME`, and +`HTTP2_HEADER_PATH` identify request pseudo-headers. `HTTP2_HEADER_STATUS` +identifies the response pseudo-header. `HTTP2_HEADER_PROTOCOL` identifies the +extended `CONNECT` request pseudo-header. Pseudo-headers are not permitted in +trailers. + +| Constant | Value | +| ---------------------------------------- | -------------- | +| `http2.constants.HTTP2_HEADER_STATUS` | `':status'` | +| `http2.constants.HTTP2_HEADER_METHOD` | `':method'` | +| `http2.constants.HTTP2_HEADER_AUTHORITY` | `':authority'` | +| `http2.constants.HTTP2_HEADER_SCHEME` | `':scheme'` | +| `http2.constants.HTTP2_HEADER_PATH` | `':path'` | +| `http2.constants.HTTP2_HEADER_PROTOCOL` | `':protocol'` | + +##### Regular header constants + +The `HTTP2_HEADER_CONNECTION`, `HTTP2_HEADER_UPGRADE`, +`HTTP2_HEADER_HTTP2_SETTINGS`, `HTTP2_HEADER_KEEP_ALIVE`, +`HTTP2_HEADER_PROXY_CONNECTION`, and `HTTP2_HEADER_TRANSFER_ENCODING` +constants identify connection-specific headers that HTTP/2 does not permit. +`HTTP2_HEADER_TE` is permitted only when its value is `'trailers'`. + +| Constant | Value | +| --------------------------------------------------------------- | ------------------------------------ | +| `http2.constants.HTTP2_HEADER_ACCEPT_ENCODING` | `'accept-encoding'` | +| `http2.constants.HTTP2_HEADER_ACCEPT_LANGUAGE` | `'accept-language'` | +| `http2.constants.HTTP2_HEADER_ACCEPT_RANGES` | `'accept-ranges'` | +| `http2.constants.HTTP2_HEADER_ACCEPT` | `'accept'` | +| `http2.constants.HTTP2_HEADER_ACCESS_CONTROL_ALLOW_CREDENTIALS` | `'access-control-allow-credentials'` | +| `http2.constants.HTTP2_HEADER_ACCESS_CONTROL_ALLOW_HEADERS` | `'access-control-allow-headers'` | +| `http2.constants.HTTP2_HEADER_ACCESS_CONTROL_ALLOW_METHODS` | `'access-control-allow-methods'` | +| `http2.constants.HTTP2_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN` | `'access-control-allow-origin'` | +| `http2.constants.HTTP2_HEADER_ACCESS_CONTROL_EXPOSE_HEADERS` | `'access-control-expose-headers'` | +| `http2.constants.HTTP2_HEADER_ACCESS_CONTROL_REQUEST_HEADERS` | `'access-control-request-headers'` | +| `http2.constants.HTTP2_HEADER_ACCESS_CONTROL_REQUEST_METHOD` | `'access-control-request-method'` | +| `http2.constants.HTTP2_HEADER_AGE` | `'age'` | +| `http2.constants.HTTP2_HEADER_AUTHORIZATION` | `'authorization'` | +| `http2.constants.HTTP2_HEADER_CACHE_CONTROL` | `'cache-control'` | +| `http2.constants.HTTP2_HEADER_CONNECTION` | `'connection'` | +| `http2.constants.HTTP2_HEADER_CONTENT_DISPOSITION` | `'content-disposition'` | +| `http2.constants.HTTP2_HEADER_CONTENT_ENCODING` | `'content-encoding'` | +| `http2.constants.HTTP2_HEADER_CONTENT_LENGTH` | `'content-length'` | +| `http2.constants.HTTP2_HEADER_CONTENT_TYPE` | `'content-type'` | +| `http2.constants.HTTP2_HEADER_COOKIE` | `'cookie'` | +| `http2.constants.HTTP2_HEADER_DATE` | `'date'` | +| `http2.constants.HTTP2_HEADER_ETAG` | `'etag'` | +| `http2.constants.HTTP2_HEADER_FORWARDED` | `'forwarded'` | +| `http2.constants.HTTP2_HEADER_HOST` | `'host'` | +| `http2.constants.HTTP2_HEADER_IF_MODIFIED_SINCE` | `'if-modified-since'` | +| `http2.constants.HTTP2_HEADER_IF_NONE_MATCH` | `'if-none-match'` | +| `http2.constants.HTTP2_HEADER_IF_RANGE` | `'if-range'` | +| `http2.constants.HTTP2_HEADER_LAST_MODIFIED` | `'last-modified'` | +| `http2.constants.HTTP2_HEADER_LINK` | `'link'` | +| `http2.constants.HTTP2_HEADER_LOCATION` | `'location'` | +| `http2.constants.HTTP2_HEADER_RANGE` | `'range'` | +| `http2.constants.HTTP2_HEADER_REFERER` | `'referer'` | +| `http2.constants.HTTP2_HEADER_SERVER` | `'server'` | +| `http2.constants.HTTP2_HEADER_SET_COOKIE` | `'set-cookie'` | +| `http2.constants.HTTP2_HEADER_STRICT_TRANSPORT_SECURITY` | `'strict-transport-security'` | +| `http2.constants.HTTP2_HEADER_TRANSFER_ENCODING` | `'transfer-encoding'` | +| `http2.constants.HTTP2_HEADER_TE` | `'te'` | +| `http2.constants.HTTP2_HEADER_UPGRADE_INSECURE_REQUESTS` | `'upgrade-insecure-requests'` | +| `http2.constants.HTTP2_HEADER_UPGRADE` | `'upgrade'` | +| `http2.constants.HTTP2_HEADER_USER_AGENT` | `'user-agent'` | +| `http2.constants.HTTP2_HEADER_VARY` | `'vary'` | +| `http2.constants.HTTP2_HEADER_X_CONTENT_TYPE_OPTIONS` | `'x-content-type-options'` | +| `http2.constants.HTTP2_HEADER_X_FRAME_OPTIONS` | `'x-frame-options'` | +| `http2.constants.HTTP2_HEADER_KEEP_ALIVE` | `'keep-alive'` | +| `http2.constants.HTTP2_HEADER_PROXY_CONNECTION` | `'proxy-connection'` | +| `http2.constants.HTTP2_HEADER_X_XSS_PROTECTION` | `'x-xss-protection'` | +| `http2.constants.HTTP2_HEADER_ALT_SVC` | `'alt-svc'` | +| `http2.constants.HTTP2_HEADER_CONTENT_SECURITY_POLICY` | `'content-security-policy'` | +| `http2.constants.HTTP2_HEADER_EARLY_DATA` | `'early-data'` | +| `http2.constants.HTTP2_HEADER_EXPECT_CT` | `'expect-ct'` | +| `http2.constants.HTTP2_HEADER_ORIGIN` | `'origin'` | +| `http2.constants.HTTP2_HEADER_PURPOSE` | `'purpose'` | +| `http2.constants.HTTP2_HEADER_TIMING_ALLOW_ORIGIN` | `'timing-allow-origin'` | +| `http2.constants.HTTP2_HEADER_X_FORWARDED_FOR` | `'x-forwarded-for'` | +| `http2.constants.HTTP2_HEADER_PRIORITY` | `'priority'` | +| `http2.constants.HTTP2_HEADER_ACCEPT_CHARSET` | `'accept-charset'` | +| `http2.constants.HTTP2_HEADER_ACCESS_CONTROL_MAX_AGE` | `'access-control-max-age'` | +| `http2.constants.HTTP2_HEADER_ALLOW` | `'allow'` | +| `http2.constants.HTTP2_HEADER_CONTENT_LANGUAGE` | `'content-language'` | +| `http2.constants.HTTP2_HEADER_CONTENT_LOCATION` | `'content-location'` | +| `http2.constants.HTTP2_HEADER_CONTENT_MD5` | `'content-md5'` | +| `http2.constants.HTTP2_HEADER_CONTENT_RANGE` | `'content-range'` | +| `http2.constants.HTTP2_HEADER_DNT` | `'dnt'` | +| `http2.constants.HTTP2_HEADER_EXPECT` | `'expect'` | +| `http2.constants.HTTP2_HEADER_EXPIRES` | `'expires'` | +| `http2.constants.HTTP2_HEADER_FROM` | `'from'` | +| `http2.constants.HTTP2_HEADER_IF_MATCH` | `'if-match'` | +| `http2.constants.HTTP2_HEADER_IF_UNMODIFIED_SINCE` | `'if-unmodified-since'` | +| `http2.constants.HTTP2_HEADER_MAX_FORWARDS` | `'max-forwards'` | +| `http2.constants.HTTP2_HEADER_PREFER` | `'prefer'` | +| `http2.constants.HTTP2_HEADER_PROXY_AUTHENTICATE` | `'proxy-authenticate'` | +| `http2.constants.HTTP2_HEADER_PROXY_AUTHORIZATION` | `'proxy-authorization'` | +| `http2.constants.HTTP2_HEADER_REFRESH` | `'refresh'` | +| `http2.constants.HTTP2_HEADER_RETRY_AFTER` | `'retry-after'` | +| `http2.constants.HTTP2_HEADER_TRAILER` | `'trailer'` | +| `http2.constants.HTTP2_HEADER_TK` | `'tk'` | +| `http2.constants.HTTP2_HEADER_VIA` | `'via'` | +| `http2.constants.HTTP2_HEADER_WARNING` | `'warning'` | +| `http2.constants.HTTP2_HEADER_WWW_AUTHENTICATE` | `'www-authenticate'` | +| `http2.constants.HTTP2_HEADER_HTTP2_SETTINGS` | `'http2-settings'` | + #### Error codes for `RST_STREAM` and `GOAWAY` | Value | Name | Constant | @@ -3930,9 +4066,10 @@ API: ```mjs import { createServer } from 'node:http2'; const server = createServer((req, res) => { - res.setHeader('Content-Type', 'text/html'); - res.setHeader('X-Foo', 'bar'); - res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.writeHead(200, { + 'Content-Type': 'text/plain; charset=utf-8', + 'X-Foo': 'bar', + }); res.end('ok'); }); ``` @@ -3940,9 +4077,10 @@ const server = createServer((req, res) => { ```cjs const http2 = require('node:http2'); const server = http2.createServer((req, res) => { - res.setHeader('Content-Type', 'text/html'); - res.setHeader('X-Foo', 'bar'); - res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.writeHead(200, { + 'Content-Type': 'text/plain; charset=utf-8', + 'X-Foo': 'bar', + }); res.end('ok'); }); ``` @@ -5033,6 +5171,7 @@ you need to implement any fall-back behavior yourself. [HTTP/2 Settings Object]: #settings-object [HTTP/2 Unencrypted]: https://http2.github.io/faq/#does-http2-require-encryption [HTTPS]: https.md +[Invalid character handling in header names and values]: #invalid-character-handling-in-header-names-and-values [Performance Observer]: perf_hooks.md [RFC 7838]: https://tools.ietf.org/html/rfc7838 [RFC 8336]: https://tools.ietf.org/html/rfc8336 From a9b31dfe309e6b64b37ff02258cc43c84808e569 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Fri, 14 Aug 2026 11:09:48 -0400 Subject: [PATCH 176/344] meta: add unified http api initiative HTTP APIs in Node.js have become a bit of a mess. We have separate `node:http`, `node:https`, and `node:http2` modules. We have separate `fetch` implementation. We have `http3` support in development. There are new http features like datagrams and priorities that are entirely unsupported, etc. This strategic initiative will be focused on the development of a unified HTTP API built around the web standard fetch model. Client and Server.. independent of underlying transport and updated to modern HTTP protocol capabilities. Signed-off-by: James M Snell PR-URL: https://github.com/nodejs/node/pull/65139 Reviewed-By: Filip Skokan Reviewed-By: Chengzhong Wu Reviewed-By: Daeyeon Jeong Reviewed-By: Trivikram Kamat Reviewed-By: Marco Ippolito Reviewed-By: Chemi Atlow Reviewed-By: Tim Perry Reviewed-By: Benjamin Gruenbaum Reviewed-By: Richard Lau Reviewed-By: Matteo Collina Reviewed-By: Matthew Aitken --- doc/contributing/strategic-initiatives.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/doc/contributing/strategic-initiatives.md b/doc/contributing/strategic-initiatives.md index c56876238ef0..ac154c0e2bb7 100644 --- a/doc/contributing/strategic-initiatives.md +++ b/doc/contributing/strategic-initiatives.md @@ -8,7 +8,8 @@ agenda to ensure they are active and have the support they need. | Initiative | Champion | Links | | ---------------------- | -------------------------------- | --------------------------------------------- | -| QUIC / HTTP3 | [James M Snell][jasnell] | | +| QUIC / HTTP3 | [James M Snell][jasnell] | | +| Unified HTTP API | [James M Snell][jasnell] | | | Shadow Realm | [Chengzhong Wu][legendecas] | | | V8 Currency | [Michaël Zasso][targos] | | | Next-10 | [Jacob Smith][JakobJingleheimer] | | From 099801641bce419643a0d35654af20333149b39d Mon Sep 17 00:00:00 2001 From: Joyee Cheung Date: Fri, 14 Aug 2026 18:14:48 +0200 Subject: [PATCH 177/344] inspector: avoid calling into JS from V8 interrupts Our inspector implementation dispatches inspector messages from a V8 interrupt handler, so they could be handled during an arbitrary point of JS execution where re-calling into another irrelevant JS code is not safe. This patch tracks V8 interrupt state in this case and rewrite the async hook toggling as state reconciliation, so requests only record the desired state, which is applied once calling into JS is possible and safe, and the actual invocation is deferred to an immediate when inside an interrupt. This simplifies the previous mechanism and makes re-entracy and early termination safer. Drive-by: skip installing command line API extensions during teardown when calling into JS is no longer safe. Signed-off-by: Joyee Cheung PR-URL: https://github.com/nodejs/node/pull/65028 Refs: https://issues.chromium.org/u/1/issues/42212250 Refs: https://chromium-review.googlesource.com/c/v8/v8/+/8173727 Refs: https://github.com/nodejs/node/pull/26935 Reviewed-By: Chengzhong Wu --- src/env-inl.h | 4 + src/env.cc | 2 + src/env.h | 7 ++ src/inspector_agent.cc | 112 ++++++++++-------- src/inspector_agent.h | 13 +- .../test-inspector-async-hook-after-done.js | 4 +- 6 files changed, 83 insertions(+), 59 deletions(-) diff --git a/src/env-inl.h b/src/env-inl.h index e9f940c63e53..efabadbcc0bc 100644 --- a/src/env-inl.h +++ b/src/env-inl.h @@ -623,6 +623,10 @@ inline void Environment::set_can_call_into_js(bool can_call_into_js) { can_call_into_js_ = can_call_into_js; } +inline bool Environment::is_processing_v8_interrupt() const { + return is_processing_v8_interrupt_; +} + inline bool Environment::has_run_bootstrapping_code() const { return principal_realm_->has_run_bootstrapping_code(); } diff --git a/src/env.cc b/src/env.cc index 13344ad135e2..61921995cdd0 100644 --- a/src/env.cc +++ b/src/env.cc @@ -1549,7 +1549,9 @@ void Environment::RequestInterruptFromV8() { return; } env->interrupt_data_.store(nullptr); + env->is_processing_v8_interrupt_ = true; env->RunAndClearInterrupts(); + env->is_processing_v8_interrupt_ = false; }, interrupt_data); } diff --git a/src/env.h b/src/env.h index c2bf9fdd497a..288084e3a589 100644 --- a/src/env.h +++ b/src/env.h @@ -799,6 +799,12 @@ class Environment final : public MemoryRetainer { inline bool can_call_into_js() const; inline void set_can_call_into_js(bool can_call_into_js); + // True while RequestInterrupt() callbacks are being invoked from the + // v8::Isolate::RequestInterrupt() handler, i.e. potentially at an + // arbitrary point during JS execution. Calling into JS must be avoided + // in that case. + inline bool is_processing_v8_interrupt() const; + // Increase or decrease a counter that manages whether this Environment // keeps the event loop alive on its own or not. The counter starts out at 0, // meaning it does not, and any positive value will make it keep the event @@ -1252,6 +1258,7 @@ class Environment final : public MemoryRetainer { bool task_queues_async_initialized_ = false; std::atomic interrupt_data_ {nullptr}; + bool is_processing_v8_interrupt_ = false; void RequestInterruptFromV8(); static void CheckImmediate(uv_check_t* handle); diff --git a/src/inspector_agent.cc b/src/inspector_agent.cc index 00b4982d9b83..4bb34e56bcbf 100644 --- a/src/inspector_agent.cc +++ b/src/inspector_agent.cc @@ -555,11 +555,7 @@ class NodeInspectorClient : public V8InspectorClient { return; } if (auto agent = env_->inspector_agent()) { - if (depth == 0) { - agent->DisableAsyncHook(); - } else { - agent->EnableAsyncHook(); - } + agent->SetAsyncHookTrackingEnabled(depth != 0); } } @@ -655,6 +651,7 @@ class NodeInspectorClient : public V8InspectorClient { void installAdditionalCommandLineAPI(Local context, Local target) override { + if (!env_->can_call_into_js()) return; Local installer = env_->inspector_console_extension_installer(); if (!installer.IsEmpty()) { Local argv[] = {target}; @@ -1076,58 +1073,69 @@ void Agent::RegisterAsyncHook(Isolate* isolate, Local disable_function) { parent_env_->set_inspector_enable_async_hooks(enable_function); parent_env_->set_inspector_disable_async_hooks(disable_function); - if (pending_enable_async_hook_) { - CHECK(!pending_disable_async_hook_); - pending_enable_async_hook_ = false; - EnableAsyncHook(); - } else if (pending_disable_async_hook_) { - CHECK(!pending_enable_async_hook_); - pending_disable_async_hook_ = false; - DisableAsyncHook(); - } + SyncAsyncHookState(); } -void Agent::EnableAsyncHook() { - HandleScope scope(parent_env_->isolate()); - Local enable = parent_env_->inspector_enable_async_hooks(); - if (!enable.IsEmpty()) { - ToggleAsyncHook(parent_env_->isolate(), enable); - } else if (pending_disable_async_hook_) { - CHECK(!pending_enable_async_hook_); - pending_disable_async_hook_ = false; - } else { - pending_enable_async_hook_ = true; - } +void Agent::SetAsyncHookTrackingEnabled(bool enabled) { + async_hook_wanted_ = enabled; + SyncAsyncHookState(); } -void Agent::DisableAsyncHook() { - HandleScope scope(parent_env_->isolate()); - Local disable = parent_env_->inspector_disable_async_hooks(); - if (!disable.IsEmpty()) { - ToggleAsyncHook(parent_env_->isolate(), disable); - } else if (pending_enable_async_hook_) { - CHECK(!pending_disable_async_hook_); - pending_enable_async_hook_ = false; - } else { - pending_disable_async_hook_ = true; - } -} +// Reconcile the state of the async hook used for async stack traces with the +// state last requested by the protocol. The hook is set up in JS land, +// (see inspector_async_hooks.js), which isn't safe to do when: +// 1. We are in early bootstrap and the setup functions aren't registered in +// C++ yet. +// 2. We are in a V8 interrupt requested by inspector protocol message +// dispatch e.g. from maxAsyncCallStackDepthChanged() notifications. +// When it's not safe to call into JS, this is a no-op and we'll try again in +// RegisterAsyncHook() (for 1) or from a scheduled immediate (for 2). +void Agent::SyncAsyncHookState() { + // The debugger can request an interrupt within the toggle JS function itself, + // A nested call only records the new requested state, the outermost call sees + // it when re-checking the loop condition after each toggle. + if (syncing_async_hook_state_) return; + syncing_async_hook_state_ = true; + auto on_exit = OnScopeLeave([this]() { syncing_async_hook_state_ = false; }); + + Isolate* isolate = parent_env_->isolate(); + HandleScope scope(isolate); + while (async_hook_wanted_ != async_hook_enabled_) { + // Guard against running this during cleanup -- no async events will be + // emitted anyway at that point anymore, and calling into JS is not + // possible. This should probably not be something we're attempting in the + // first place, + // Refs: https://github.com/nodejs/node/pull/34362#discussion_r456006039 + if (!parent_env_->can_call_into_js()) return; + + bool enable = async_hook_wanted_; + Local fn = enable ? parent_env_->inspector_enable_async_hooks() + : parent_env_->inspector_disable_async_hooks(); + if (fn.IsEmpty()) return; + + if (parent_env_->is_processing_v8_interrupt()) { + parent_env_->SetImmediate( + [](Environment* env) { + Agent* agent = env->inspector_agent(); + if (agent != nullptr) agent->SyncAsyncHookState(); + }, + CallbackFlags::kUnrefed); + return; + } -void Agent::ToggleAsyncHook(Isolate* isolate, Local fn) { - // Guard against running this during cleanup -- no async events will be - // emitted anyway at that point anymore, and calling into JS is not possible. - // This should probably not be something we're attempting in the first place, - // Refs: https://github.com/nodejs/node/pull/34362#discussion_r456006039 - if (!parent_env_->can_call_into_js()) return; - CHECK(parent_env_->has_run_bootstrapping_code()); - HandleScope handle_scope(isolate); - CHECK(!fn.IsEmpty()); - auto context = parent_env_->context(); - v8::TryCatch try_catch(isolate); - USE(fn->Call(context, Undefined(isolate), 0, nullptr)); - if (try_catch.HasCaught() && !try_catch.HasTerminated()) { - PrintCaughtException(isolate, context, try_catch); - UNREACHABLE("Cannot toggle Inspector's AsyncHook, please report this."); + CHECK(parent_env_->has_run_bootstrapping_code()); + Local context = parent_env_->context(); + v8::TryCatch try_catch(isolate); + USE(fn->Call(context, Undefined(isolate), 0, nullptr)); + if (try_catch.HasCaught()) { + // Termination may abort the toggle invocation, retrying now would just + // be terminated again. Instead of recording the toggle that may not have + // taken effect, leave the states as-is so that a later sync retries. + if (try_catch.HasTerminated()) return; + PrintCaughtException(isolate, context, try_catch); + UNREACHABLE("Cannot toggle Inspector's AsyncHook, please report this."); + } + async_hook_enabled_ = enable; } } diff --git a/src/inspector_agent.h b/src/inspector_agent.h index 5ace72a64012..932e4e8dce89 100644 --- a/src/inspector_agent.h +++ b/src/inspector_agent.h @@ -90,8 +90,7 @@ class Agent { void RegisterAsyncHook(v8::Isolate* isolate, v8::Local enable_function, v8::Local disable_function); - void EnableAsyncHook(); - void DisableAsyncHook(); + void SetAsyncHookTrackingEnabled(bool enabled); void SetParentHandle(std::unique_ptr parent_handle); std::unique_ptr GetParentHandle(uint64_t thread_id, @@ -132,7 +131,7 @@ class Agent { std::shared_ptr GetNetworkResourceManager(); private: - void ToggleAsyncHook(v8::Isolate* isolate, v8::Local fn); + void SyncAsyncHookState(); void ToggleNetworkTracking(v8::Isolate* isolate, v8::Local fn); node::Environment* parent_env_; @@ -150,8 +149,12 @@ class Agent { DebugOptions debug_options_; std::shared_ptr> host_port_; - bool pending_enable_async_hook_ = false; - bool pending_disable_async_hook_ = false; + // The state of the async hook used for async stack traces that the protocol + // last requested, and the state JS currently has. SyncAsyncHookState() + // reconciles the two when it is possible and safe to call into JS. + bool async_hook_wanted_ = false; + bool async_hook_enabled_ = false; + bool syncing_async_hook_state_ = false; bool network_tracking_enabled_ = false; bool pending_enable_network_tracking = false; diff --git a/test/parallel/test-inspector-async-hook-after-done.js b/test/parallel/test-inspector-async-hook-after-done.js index f9cd7b491360..b4eff0467ecd 100644 --- a/test/parallel/test-inspector-async-hook-after-done.js +++ b/test/parallel/test-inspector-async-hook-after-done.js @@ -34,8 +34,8 @@ function onAttachToWorker({ params: { sessionId } }) { session.once('NodeWorker.receivedMessageFromWorker', onMessageReceived); return; } - // Force a call to node::inspector::Agent::ToggleAsyncHook by changing the - // async call stack depth + // Force a call to node::inspector::Agent::SyncAsyncHookState by changing + // the async call stack depth postToWorkerInspector('Debugger.setAsyncCallStackDepth', { maxDepth: 1 }); // This is were the original crash happened session.post('NodeWorker.detach', { sessionId }, () => { From bca3367583e5678f8286410bd867ca9ee085dc93 Mon Sep 17 00:00:00 2001 From: Maya Lekova Date: Fri, 14 Aug 2026 19:14:58 +0300 Subject: [PATCH 178/344] test: add a simple test for `import defer` of a CJS module This tests imports a CommonJS modules with the `defer` modifier. It ensures that the imported module is not evaluated before accessing properties from its exports. Signed-off-by: Maya Lekova PR-URL: https://github.com/nodejs/node/pull/64694 Reviewed-By: Joyee Cheung --- .../test-cjs-defer-static-import-eval.mjs | 30 +++++++++++++++++++ .../es-modules/module-cjs-deferred-eval.js | 17 +++++++++++ 2 files changed, 47 insertions(+) create mode 100644 test/es-module/test-cjs-defer-static-import-eval.mjs create mode 100644 test/fixtures/es-modules/module-cjs-deferred-eval.js diff --git a/test/es-module/test-cjs-defer-static-import-eval.mjs b/test/es-module/test-cjs-defer-static-import-eval.mjs new file mode 100644 index 000000000000..0150f6f40b38 --- /dev/null +++ b/test/es-module/test-cjs-defer-static-import-eval.mjs @@ -0,0 +1,30 @@ +// Flags: --js-defer-import-eval + +// Test that uses import.defer for a CJS module. It ensures that: +// 1. the module is imported successfully; +// 2. it's evaluated synchronously, regardless of the `defer` modifier; +// 3. Evaluation of the imported module is deferred +// until first namespace access. + +import '../common/index.mjs'; +import * as assert from 'assert'; + +// Import the CJS module with the `defer` modifier. +import defer * as imported from '../fixtures/es-modules/module-cjs-deferred-eval.js'; + +// At this point, the deferred module should not yet be evaluated. Initialize +// the `eval_list`, which will be populated only when the module is evaluated +// for the first time, triggered by namespace access below. +globalThis.eval_list = []; + +// Additionally check that the exported properties `foo` and `identifier` +// are defined and have their values assigned at this point. +assert.strictEqual(imported.foo, 42); +assert.strictEqual(imported.identifier, 'package-type-commonjs'); + +// Check that the module has been evaluated at this point, +// also that it's not evaluated more than once. +assert.deepStrictEqual(['defer-1'], globalThis.eval_list); + +// Clean-up +delete globalThis.eval_list; diff --git a/test/fixtures/es-modules/module-cjs-deferred-eval.js b/test/fixtures/es-modules/module-cjs-deferred-eval.js new file mode 100644 index 000000000000..a7239e6ff454 --- /dev/null +++ b/test/fixtures/es-modules/module-cjs-deferred-eval.js @@ -0,0 +1,17 @@ +// This fixture is imported as a module +// in test/es-module/test-cjs-defer-static-import-eval.mjs +// to ensure a CommonJS module imported with `import defer` +// is only executed once. +const assert = require('assert'); + +const identifier = 'package-type-commonjs'; + +module.exports.foo = 42; +module.exports.identifier = identifier; + +// The `eval_list` is initialised by the importing module, +// so by the time the fixture is executed, `eval_list` should +// already be initialised. +assert.deepEqual(globalThis.eval_list, []); + +globalThis.eval_list.push('defer-1'); From abb7a15a8daf0e6c0eff8e051fd66c7c016950f3 Mon Sep 17 00:00:00 2001 From: "Node.js GitHub Bot" Date: Fri, 14 Aug 2026 14:33:27 -0400 Subject: [PATCH 179/344] deps: update perfetto to 57.2 PR-URL: https://github.com/nodejs/node/pull/65114 Reviewed-By: Chengzhong Wu --- deps/perfetto/LICENSE | 20 + deps/perfetto/VERSION | 2 +- deps/perfetto/sdk/perfetto.cc | 6726 +++++++--- deps/perfetto/sdk/perfetto.h | 23033 ++++++++++++++------------------ 4 files changed, 15265 insertions(+), 14516 deletions(-) diff --git a/deps/perfetto/LICENSE b/deps/perfetto/LICENSE index cbdc2881d57d..681d40008ee3 100644 --- a/deps/perfetto/LICENSE +++ b/deps/perfetto/LICENSE @@ -224,6 +224,26 @@ Files: src/trace_processor/perfetto_sql/stdlib/chromium/*, protos/third_party/ch OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +------------------ + +Files: src/trace_processor/perfetto_sql/syntaqlite/syntaqlite_perfetto.{c, h} + + Copyright 2025 The syntaqlite Authors. All rights reserved. + + Machine-generated amalgamation of syntaqlite runtime + Perfetto dialect + sources (https://github.com/LalitMaganti/syntaqlite). Portions derive + from SQLite's public-domain `parse.y` grammar. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + ------------------ Files: src/trace_processor/perfetto_sql/preprocessor/preprocessor_grammar.{c, h} diff --git a/deps/perfetto/VERSION b/deps/perfetto/VERSION index 517ac763657c..06b0aef2d7b5 100644 --- a/deps/perfetto/VERSION +++ b/deps/perfetto/VERSION @@ -1 +1 @@ -54.0 +57.2 diff --git a/deps/perfetto/sdk/perfetto.cc b/deps/perfetto/sdk/perfetto.cc index 2ccd11f7b831..4df59d2c46dd 100644 --- a/deps/perfetto/sdk/perfetto.cc +++ b/deps/perfetto/sdk/perfetto.cc @@ -560,6 +560,7 @@ struct std::hash<::perfetto::base::StringView> { #include #include #include +#include #include #include @@ -723,7 +724,9 @@ std::vector SplitString(const std::string& text, const std::string& delimiter); std::string StripPrefix(const std::string& str, const std::string& prefix); std::string StripSuffix(const std::string& str, const std::string& suffix); +std::string_view TrimWhitespace(std::string_view str); std::string TrimWhitespace(const std::string& str); +std::string_view TrimWhitespace(const char* str); std::string ToLower(const std::string& str); std::string ToUpper(const std::string& str); std::string StripChars(const std::string& str, @@ -1544,10 +1547,10 @@ std::optional Base64Decode(const char* src, size_t src_size) { } // namespace base } // namespace perfetto -// gen_amalgamated begin source: src/base/crash_keys.cc -// gen_amalgamated begin header: include/perfetto/ext/base/crash_keys.h +// gen_amalgamated begin source: src/base/cpu_info.cc +// gen_amalgamated begin header: include/perfetto/ext/base/cpu_info.h /* - * Copyright (C) 2021 The Android Open Source Project + * Copyright (C) 2026 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -1562,154 +1565,42 @@ std::optional Base64Decode(const char* src, size_t src_size) { * limitations under the License. */ -#ifndef INCLUDE_PERFETTO_EXT_BASE_CRASH_KEYS_H_ -#define INCLUDE_PERFETTO_EXT_BASE_CRASH_KEYS_H_ - -#include -#include +#ifndef INCLUDE_PERFETTO_EXT_BASE_CPU_INFO_H_ +#define INCLUDE_PERFETTO_EXT_BASE_CPU_INFO_H_ #include -#include - -// gen_amalgamated expanded: #include "perfetto/base/compiler.h" -// gen_amalgamated expanded: #include "perfetto/ext/base/string_view.h" - -// Crash keys are very simple global variables with static-storage that -// are reported on crash time for managed crashes (CHECK/FATAL/Watchdog). -// - Translation units can define a CrashKey and register it at some point -// during initialization. -// - CrashKey instances must be long-lived. They should really be just global -// static variable in the anonymous namespace. -// Example: -// subsystem_1.cc -// CrashKey g_client_id("ipc_client_id"); -// ... -// OnIpcReceived(client_id) { -// g_client_id.Set(client_id); -// ... // Process the IPC -// g_client_id.Clear(); -// } -// Or equivalently: -// OnIpcReceived(client_id) { -// auto scoped_key = g_client_id.SetScoped(client_id); -// ... // Process the IPC -// } -// -// If a crash happens while processing the IPC, the crash report will -// have a line "ipc_client_id: 42". -// -// Thread safety considerations: -// CrashKeys can be registered and set/cleared from any thread. -// There is no compelling use-case to have full acquire/release consistency when -// setting a key. This means that if a thread crashes immediately after a -// crash key has been set on another thread, the value printed on the crash -// report could be incomplete. The code guarantees defined behavior and does -// not rely on null-terminated string (in the worst case 32 bytes of random -// garbage will be printed out). - -// The tests live in logging_unittest.cc. +#include +#include +#include namespace perfetto { namespace base { -constexpr size_t kCrashKeyMaxStrSize = 32; - -// CrashKey instances must be long lived -class CrashKey { - public: - class ScopedClear { - public: - explicit ScopedClear(CrashKey* k) : key_(k) {} - ~ScopedClear() { - if (key_) - key_->Clear(); - } - ScopedClear(const ScopedClear&) = delete; - ScopedClear& operator=(const ScopedClear&) = delete; - ScopedClear& operator=(ScopedClear&&) = delete; - ScopedClear(ScopedClear&& other) noexcept : key_(other.key_) { - other.key_ = nullptr; - } - - private: - CrashKey* key_; - }; - - // constexpr so it can be used in the anon namespace without requiring a - // global constructor. - // |name| must be a long-lived string. - constexpr explicit CrashKey(const char* name) - : registered_{}, type_(Type::kUnset), name_(name), str_value_{} {} - CrashKey(const CrashKey&) = delete; - CrashKey& operator=(const CrashKey&) = delete; - CrashKey(CrashKey&&) = delete; - CrashKey& operator=(CrashKey&&) = delete; - - enum class Type : uint8_t { kUnset = 0, kInt, kStr }; - - void Clear() { - int_value_.store(0, std::memory_order_relaxed); - type_.store(Type::kUnset, std::memory_order_relaxed); - } - - void Set(int64_t value) { - int_value_.store(value, std::memory_order_relaxed); - type_.store(Type::kInt, std::memory_order_relaxed); - if (PERFETTO_UNLIKELY(!registered_.load(std::memory_order_relaxed))) - Register(); - } - - void Set(StringView sv) { - size_t len = std::min(sv.size(), sizeof(str_value_) - 1); - for (size_t i = 0; i < len; ++i) - str_value_[i].store(sv.data()[i], std::memory_order_relaxed); - str_value_[len].store('\0', std::memory_order_relaxed); - type_.store(Type::kStr, std::memory_order_relaxed); - if (PERFETTO_UNLIKELY(!registered_.load(std::memory_order_relaxed))) - Register(); - } - - ScopedClear SetScoped(int64_t value) PERFETTO_WARN_UNUSED_RESULT { - Set(value); - return ScopedClear(this); - } - - ScopedClear SetScoped(StringView sv) PERFETTO_WARN_UNUSED_RESULT { - Set(sv); - return ScopedClear(this); - } - - void Register(); - - int64_t int_value() const { - return int_value_.load(std::memory_order_relaxed); - } - size_t ToString(char* dst, size_t len); - - private: - std::atomic registered_; - std::atomic type_; - const char* const name_; - union { - std::atomic str_value_[kCrashKeyMaxStrSize]; - std::atomic int_value_; - }; +struct CpuInfo { + std::string processor; + uint32_t cpu_index = 0; + std::optional implementer; + std::optional architecture; + std::optional variant; + std::optional part; + std::optional revision; + uint64_t features = 0; + char arm_cpuid[32] = {}; }; -// Fills |dst| with a string containing one line for each crash key -// (excluding the unset ones). -// Returns number of chars written, without counting the NUL terminator. -// This is used in logging.cc when emitting the crash report abort message. -size_t SerializeCrashKeys(char* dst, size_t len); +// Parses the contents of the input string into per-CPU entries. +std::vector ParseCpuInfo(std::string proc_cpu_info); -void UnregisterAllCrashKeysForTesting(); +// Reads /proc/cpuinfo and parses it into per-CPU entries. +std::vector ReadCpuInfo(); } // namespace base } // namespace perfetto -#endif // INCLUDE_PERFETTO_EXT_BASE_CRASH_KEYS_H_ +#endif // INCLUDE_PERFETTO_EXT_BASE_CPU_INFO_H_ +// gen_amalgamated begin header: include/perfetto/ext/base/cpu_info_features_allowlist.h /* - * Copyright (C) 2021 The Android Open Source Project + * Copyright (C) 2025 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -1724,91 +1615,29 @@ void UnregisterAllCrashKeysForTesting(); * limitations under the License. */ -// gen_amalgamated expanded: #include "perfetto/ext/base/crash_keys.h" - -#include - -#include -#include - -// gen_amalgamated expanded: #include "perfetto/ext/base/string_utils.h" +#ifndef INCLUDE_PERFETTO_EXT_BASE_CPU_INFO_FEATURES_ALLOWLIST_H_ +#define INCLUDE_PERFETTO_EXT_BASE_CPU_INFO_FEATURES_ALLOWLIST_H_ namespace perfetto { namespace base { -namespace { - -constexpr size_t kMaxKeys = 32; - -std::atomic g_keys[kMaxKeys]{}; -std::atomic g_num_keys{}; -} // namespace - -void CrashKey::Register() { - // If doesn't matter if we fail below. If there are no slots left, don't - // keep trying re-registering on every Set(), the outcome won't change. - - // If two threads raced on the Register(), avoid registering the key twice. - if (registered_.exchange(true)) - return; - - uint32_t slot = g_num_keys.fetch_add(1); - if (slot >= kMaxKeys) { - PERFETTO_LOG("Too many crash keys registered"); - return; - } - g_keys[slot].store(this); -} - -// Returns the number of chars written, without counting the \0. -size_t CrashKey::ToString(char* dst, size_t len) { - if (len > 0) - *dst = '\0'; - switch (type_.load(std::memory_order_relaxed)) { - case Type::kUnset: - break; - case Type::kInt: - return SprintfTrunc(dst, len, "%s: %" PRId64 "\n", name_, - int_value_.load(std::memory_order_relaxed)); - case Type::kStr: - char buf[sizeof(str_value_)]; - for (size_t i = 0; i < sizeof(str_value_); i++) - buf[i] = str_value_[i].load(std::memory_order_relaxed); - - // Don't assume |str_value_| is properly null-terminated. - return SprintfTrunc(dst, len, "%s: %.*s\n", name_, int(sizeof(buf)), buf); - } - return 0; -} - -void UnregisterAllCrashKeysForTesting() { - g_num_keys.store(0); - for (auto& key : g_keys) - key.store(nullptr); -} - -size_t SerializeCrashKeys(char* dst, size_t len) { - size_t written = 0; - uint32_t num_keys = g_num_keys.load(); - if (len > 0) - *dst = '\0'; - for (uint32_t i = 0; i < num_keys && written < len; i++) { - CrashKey* key = g_keys[i].load(); - if (!key) - continue; // Can happen if we hit this between the add and the store. - written += key->ToString(dst + written, len - written); - } - PERFETTO_DCHECK(written <= len); - PERFETTO_DCHECK(len == 0 || dst[written] == '\0'); - return written; -} +// APPEND ONLY. DO NOT EVER REMOVE ENTRIES FROM THIS ARRAY OR REORDER. +// This array is used both by traced_probes and trace_processor to index the +// cpuinfo flags. Changing the order will break trace_processor compatibility +// with old traces. +constexpr const char* kCpuInfoFeatures[] = { + "mte", // DO NOT REMOVE/REODER. + "mte3", // DO NOT REMOVE/REODER. +}; } // namespace base } // namespace perfetto -// gen_amalgamated begin source: src/base/ctrl_c_handler.cc -// gen_amalgamated begin header: include/perfetto/ext/base/ctrl_c_handler.h + +#endif // INCLUDE_PERFETTO_EXT_BASE_CPU_INFO_FEATURES_ALLOWLIST_H_ +// gen_amalgamated begin header: include/perfetto/ext/base/file_utils.h +// gen_amalgamated begin header: include/perfetto/base/status.h /* - * Copyright (C) 2021 The Android Open Source Project + * Copyright (C) 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -1823,106 +1652,107 @@ size_t SerializeCrashKeys(char* dst, size_t len) { * limitations under the License. */ -#ifndef INCLUDE_PERFETTO_EXT_BASE_CTRL_C_HANDLER_H_ -#define INCLUDE_PERFETTO_EXT_BASE_CTRL_C_HANDLER_H_ +#ifndef INCLUDE_PERFETTO_BASE_STATUS_H_ +#define INCLUDE_PERFETTO_BASE_STATUS_H_ + +#include +#include +#include +#include + +// gen_amalgamated expanded: #include "perfetto/base/compiler.h" +// gen_amalgamated expanded: #include "perfetto/base/export.h" +// gen_amalgamated expanded: #include "perfetto/base/logging.h" namespace perfetto { namespace base { -// On Linux/Android/Mac: installs SIGINT + SIGTERM signal handlers. -// On Windows: installs a SetConsoleCtrlHandler() handler. -// The passed handler must be async safe. -using CtrlCHandlerFunction = void (*)(); -void InstallCtrlCHandler(CtrlCHandlerFunction); +// Represents either the success or the failure message of a function. +// This can used as the return type of functions which would usually return an +// bool for success or int for errno but also wants to add some string context +// (ususally for logging). +// +// Similar to absl::Status, an optional "payload" can also be included with more +// context about the error. This allows passing additional metadata about the +// error (e.g. location of errors, potential mitigations etc). +class PERFETTO_EXPORT_COMPONENT Status { + public: + Status() : ok_(true) {} + explicit Status(std::string msg) : ok_(false), message_(std::move(msg)) { + PERFETTO_CHECK(!message_.empty()); + } -} // namespace base -} // namespace perfetto + // Copy operations. + Status(const Status&) = default; + Status& operator=(const Status&) = default; -#endif // INCLUDE_PERFETTO_EXT_BASE_CTRL_C_HANDLER_H_ -/* - * Copyright (C) 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ + // Move operations. The moved-from state is valid but unspecified. + Status(Status&&) noexcept = default; + Status& operator=(Status&&) = default; -// gen_amalgamated expanded: #include "perfetto/ext/base/ctrl_c_handler.h" + bool ok() const { return ok_; } -// gen_amalgamated expanded: #include "perfetto/base/build_config.h" -// gen_amalgamated expanded: #include "perfetto/base/compiler.h" -// gen_amalgamated expanded: #include "perfetto/base/logging.h" + // When ok() is false this returns the error message. Returns the empty string + // otherwise. + const std::string& message() const { return message_; } + const char* c_message() const { return message_.c_str(); } -#if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN) -#include + ////////////////////////////////////////////////////////////////////////////// + // Payload Management APIs + ////////////////////////////////////////////////////////////////////////////// -#include -#else -#include -#include -#endif + // Payloads can be attached to error statuses to provide additional context. + // + // Payloads are (key, value) pairs, where the key is a string acting as a + // unique "type URL" and the value is an opaque string. The "type URL" should + // be unique, follow the format of a URL and, ideally, documentation on how to + // interpret its associated data should be available. + // + // To attach a payload to a status object, call `Status::SetPayload()`. + // Similarly, to extract the payload from a status, call + // `Status::GetPayload()`. + // + // Note: the payload APIs are only meaningful to call when the status is an + // error. Otherwise, all methods are noops. -namespace perfetto { -namespace base { + // Gets the payload for the given |type_url| if one exists. + // + // Will always return std::nullopt if |ok()|. + std::optional GetPayload(std::string_view type_url) const; -namespace { -CtrlCHandlerFunction g_handler = nullptr; + // Sets the payload for the given key. The key should + // + // Will always do nothing if |ok()|. + void SetPayload(std::string_view type_url, std::string value); -#if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN) -BOOL WINAPI Trampoline(DWORD type) { - if (type == CTRL_C_EVENT) { - g_handler(); - return TRUE; - } - return FALSE; -} -#endif -} // namespace + // Erases the payload for the given string and returns true if the payload + // existed and was erased. + // + // Will always do nothing if |ok()|. + bool ErasePayload(std::string_view type_url); -void InstallCtrlCHandler(CtrlCHandlerFunction handler) { - PERFETTO_CHECK(g_handler == nullptr); - g_handler = handler; + private: + struct Payload { + std::string type_url; + std::string payload; + }; -#if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN) - ::SetConsoleCtrlHandler(Trampoline, true); -#elif PERFETTO_BUILDFLAG(PERFETTO_OS_LINUX) || \ - PERFETTO_BUILDFLAG(PERFETTO_OS_ANDROID) || \ - PERFETTO_BUILDFLAG(PERFETTO_OS_APPLE) - // Setup signal handler. - struct sigaction sa{}; + bool ok_ = false; + std::string message_; + std::vector payloads_; +}; -// Glibc headers for sa_sigaction trigger this. -#pragma GCC diagnostic push -#if defined(__clang__) -#pragma GCC diagnostic ignored "-Wdisabled-macro-expansion" -#endif - sa.sa_handler = [](int) { g_handler(); }; -#if !PERFETTO_BUILDFLAG(PERFETTO_OS_QNX) - sa.sa_flags = static_cast(SA_RESETHAND | SA_RESTART); -#else // POSIX-compliant - sa.sa_flags = static_cast(SA_RESETHAND); -#endif -#pragma GCC diagnostic pop - sigaction(SIGINT, &sa, nullptr); - sigaction(SIGTERM, &sa, nullptr); -#else - // Do nothing on NaCL and Fuchsia. - ignore_result(handler); -#endif +// Returns a status object which represents the Ok status. +inline Status OkStatus() { + return Status(); } +Status ErrStatus(const char* format, ...) PERFETTO_PRINTF_FORMAT(1, 2); + } // namespace base } // namespace perfetto -// gen_amalgamated begin source: src/base/event_fd.cc -// gen_amalgamated begin header: include/perfetto/ext/base/event_fd.h + +#endif // INCLUDE_PERFETTO_BASE_STATUS_H_ // gen_amalgamated begin header: include/perfetto/ext/base/scoped_file.h /* * Copyright (C) 2017 The Android Open Source Project @@ -2061,58 +1891,198 @@ using ScopedDir = ScopedResource; * limitations under the License. */ -#ifndef INCLUDE_PERFETTO_EXT_BASE_EVENT_FD_H_ -#define INCLUDE_PERFETTO_EXT_BASE_EVENT_FD_H_ +#ifndef INCLUDE_PERFETTO_EXT_BASE_FILE_UTILS_H_ +#define INCLUDE_PERFETTO_EXT_BASE_FILE_UTILS_H_ + +#include // For mode_t & O_RDONLY/RDWR. Exists also on Windows. +#include + +#include +#include +#include +#include +#include // gen_amalgamated expanded: #include "perfetto/base/build_config.h" -// gen_amalgamated expanded: #include "perfetto/base/platform_handle.h" +// gen_amalgamated expanded: #include "perfetto/base/export.h" +// gen_amalgamated expanded: #include "perfetto/base/status.h" // gen_amalgamated expanded: #include "perfetto/ext/base/scoped_file.h" +// gen_amalgamated expanded: #include "perfetto/ext/base/sys_types.h" namespace perfetto { namespace base { -// A waitable event that can be used with poll/select. -// This is really a wrapper around eventfd_create with a pipe-based fallback -// for other platforms where eventfd is not supported. -class EventFd { - public: - EventFd(); - ~EventFd(); - EventFd(EventFd&&) noexcept = default; - EventFd& operator=(EventFd&&) = default; +class TaskRunner; - // The non-blocking file descriptor that can be polled to wait for the event. - PlatformHandle fd() const { return event_handle_.get(); } +#if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN) +using FileOpenMode = int; +inline constexpr char kDevNull[] = "NUL"; +inline constexpr char kFopenReadFlag[] = "r"; +#else +using FileOpenMode = mode_t; +inline constexpr char kDevNull[] = "/dev/null"; +inline constexpr char kFopenReadFlag[] = "re"; +#endif - // Can be called from any thread. - void Notify(); +constexpr FileOpenMode kFileModeInvalid = static_cast(-1); - // Can be called from any thread. If more Notify() are queued a Clear() call - // can clear all of them (up to 16 per call). - void Clear(); +// Cross-platform variant of ReadFileDescriptor() that takes a PlatformHandle. +// On Windows normalizes ERROR_BROKEN_PIPE to EOF so behavior matches POSIX. +bool ReadPlatformHandle(PlatformHandle, std::string* out); - private: - // The eventfd, when eventfd is supported, otherwise this is the read end of - // the pipe for fallback mode. - ScopedPlatformHandle event_handle_; +// Reads from |fd|, appending what is currently available into |*out|. +// Returns: +// True: EOF reached (all writers of |fd| have closed their end). +// False: read error. On a non-blocking |fd| this includes EAGAIN (no data +// currently available but writers are still alive); callers can check +// IsAgain(errno) and retry on the next readability notification. +bool ReadFileDescriptor(int fd, std::string* out); -// QNX is specified because it is a non-Linux UNIX platform but it -// still sets the PERFETTO_OS_LINUX flag to be as compatible as possible -// with the Linux build. -#if !PERFETTO_BUILDFLAG(PERFETTO_OS_LINUX_BUT_NOT_QNX) && \ - !PERFETTO_BUILDFLAG(PERFETTO_OS_ANDROID) && \ - !PERFETTO_BUILDFLAG(PERFETTO_OS_WIN) - // On Mac and other non-Linux UNIX platforms a pipe-based fallback is used. - // The write end of the wakeup pipe. - ScopedFile write_fd_; -#endif +// Convenience wrapper around ReadFileDescriptor() that takes a FILE*. +bool ReadFileStream(FILE* f, std::string* out); + +// Opens |path| read-only and reads its contents into |*out|. +// Returns false if the file cannot be opened. +bool ReadFile(const std::string& path, std::string* out); + +// A wrapper around read(2). It deals with Linux vs Windows includes. It also +// deals with handling EINTR. Has the same semantics of UNIX's read(2). +ssize_t Read(int fd, void* dst, size_t dst_size); + +// Call write until all data is written or an error is detected. +// +// man 2 write: +// If a write() is interrupted by a signal handler before any bytes are +// written, then the call fails with the error EINTR; if it is +// interrupted after at least one byte has been written, the call +// succeeds, and returns the number of bytes written. +ssize_t WriteAll(int fd, const void* buf, size_t count); + +// Copies all data from |fd_in| to |fd_out|. Saves the offset of |fd_in|, +// rewinds it to the beginning, copies the content, and restores the offset. +// |fd_in| can't be a pipe, socket of FIFO. +base::Status CopyFileContents(int fd_in, int fd_out); + +ssize_t WriteAllHandle(PlatformHandle, const void* buf, size_t count); + +ScopedFile OpenFile(const std::string& path, + int flags, + FileOpenMode = kFileModeInvalid); +ScopedFstream OpenFstream(const std::string& path, const std::string& mode); + +// This is an alias for close(). It's to avoid leaking windows.h in headers. +// Exported because ScopedFile is used in the /include/ext API by Chromium +// component builds. +int PERFETTO_EXPORT_COMPONENT CloseFile(int fd); + +bool FlushFile(int fd); + +// Returns true if mkdir succeeds, false if it fails (see errno in that case). +// `mode` is the permission bits for the new directory; it is ignored on +// Windows. +bool Mkdir(const std::string& path, uint32_t mode = 0755); + +// Calls rmdir() on UNIX, _rmdir() on Windows. +bool Rmdir(const std::string& path); + +// Removes a file: unlink() on UNIX, _unlink() on Windows. Takes a const char* +// and is async-signal-safe on POSIX, so it's callable from a signal handler. +bool Unlink(const char* path); + +// Wrapper around access(path, F_OK). +bool FileExists(const std::string& path); + +// Gets the extension for a filename. If the file has two extensions, returns +// only the last one (foo.pb.gz => .gz). Returns empty string if there is no +// extension. +std::string GetFileExtension(const std::string& filename); + +// Returns the basename component of a path (the final component after the last +// directory separator). Behaves like man 2 basename, but works with both '/' +// and '\' separators for cross-platform compatibility. +// Examples: +// Basename("/usr/bin/ls") => "ls" +// Basename("/usr/bin/") => "bin" +// Basename("/") => "/" +// Basename("foo") => "foo" +// Basename("") => "." +// Basename("C:\\Windows\\System32") => "System32" +std::string Basename(const std::string& path); + +// Returns the directory component of a path (everything up to but not +// including the final component). Behaves like man 2 dirname, but works with +// both '/' and '\' separators for cross-platform compatibility. +// Examples: +// Dirname("/usr/bin/ls") => "/usr/bin" +// Dirname("/usr/bin") => "/usr" +// Dirname("/") => "/" +// Dirname("foo") => "." +// Dirname("") => "." +// Dirname("C:\\Windows\\System32") => "C:\\Windows" +std::string Dirname(const std::string& path); + +// Puts the path to all files under |dir_path| in |output|, recursively walking +// subdirectories. File paths are relative to |dir_path|. Only files are +// included, not directories. Path separator is always '/', even on windows (not +// '\'). +base::Status ListFilesRecursive(const std::string& dir_path, + std::vector& output); + +// Lists immediate subdirectories in |dir_path| (non-recursive). Directory names +// are relative to |dir_path| and do not include the path separator. Returns +// only directories, not files. Works on both Unix and Windows. +base::Status ListDirectories(const std::string& dir_path, + std::vector& output); + +// Sets |path|'s owner group to |group_name| and permission mode bits to +// |mode_bits|. +base::Status SetFilePermissions(const std::string& path, + const std::string& group_name, + const std::string& mode_bits); + +// Returns the size of the file located at |path|, or nullopt in case of error. +std::optional GetFileSize(const std::string& path); + +// Returns the size of the open file |fd|, or nullopt in case of error. +std::optional GetFileSize(PlatformHandle fd); + +// This class uses inotify (on Linux/Android) to watch for the creation of +// files in the filesystem. When the specified file is created, it triggers a +// callback function. +// Destroying the returned unique_ptr will automatically unregister the watch. +// +// Note: This only works with filesystem paths (not abstract sockets or other +// special file types). +// It's only supported on Linux and Android, it's a no-op (returns nullptr) on +// other platforms. +// +// Usage: +// auto watch = LinuxFileWatch::WatchFileCreation( +// task_runner, "/tmp/my_file", []() { +// // Called when /tmp/my_file is created +// }); +class LinuxFileWatch { + public: + // Creates a watcher for file creation. Returns nullptr if the path is not a + // valid filesystem path or if the platform doesn't support inotify. The + // callback will be invoked on the provided TaskRunner when the file is + // created. + static std::unique_ptr WatchFileCreation( + TaskRunner*, + const char* path, + std::function callback); + + virtual ~LinuxFileWatch(); + + protected: + LinuxFileWatch() = default; }; } // namespace base } // namespace perfetto -#endif // INCLUDE_PERFETTO_EXT_BASE_EVENT_FD_H_ -// gen_amalgamated begin header: include/perfetto/ext/base/pipe.h +#endif // INCLUDE_PERFETTO_EXT_BASE_FILE_UTILS_H_ +// gen_amalgamated begin header: include/perfetto/ext/base/string_splitter.h /* * Copyright (C) 2018 The Android Open Source Project * @@ -2129,42 +2099,93 @@ class EventFd { * limitations under the License. */ -#ifndef INCLUDE_PERFETTO_EXT_BASE_PIPE_H_ -#define INCLUDE_PERFETTO_EXT_BASE_PIPE_H_ +#ifndef INCLUDE_PERFETTO_EXT_BASE_STRING_SPLITTER_H_ +#define INCLUDE_PERFETTO_EXT_BASE_STRING_SPLITTER_H_ -// gen_amalgamated expanded: #include "perfetto/base/platform_handle.h" -// gen_amalgamated expanded: #include "perfetto/ext/base/scoped_file.h" +#include namespace perfetto { namespace base { -class Pipe { +// C++ version of strtok(). Splits a string without making copies or any heap +// allocations. Destructs the original string passed in input. +// Supports the special case of using \0 as a delimiter. +// The token returned in output are valid as long as the input string is valid. +class StringSplitter { public: - enum Flags { - kBothBlock = 0, -#if !PERFETTO_BUILDFLAG(PERFETTO_OS_WIN) - kBothNonBlock, - kRdNonBlock, - kWrNonBlock, -#endif + // Whether an empty string (two delimiters side-to-side) is a valid token. + enum class EmptyTokenMode { + DISALLOW_EMPTY_TOKENS, + ALLOW_EMPTY_TOKENS, + + DEFAULT = DISALLOW_EMPTY_TOKENS, }; - static Pipe Create(Flags = kBothBlock); + // Can take ownership of the string if passed via std::move(), e.g.: + // StringSplitter(std::move(str), '\n'); + StringSplitter(std::string, + char delimiter, + EmptyTokenMode empty_token_mode = EmptyTokenMode::DEFAULT); - Pipe(); - Pipe(Pipe&&) noexcept; - Pipe& operator=(Pipe&&); + // Splits a C-string. The input string will be forcefully null-terminated (so + // str[size - 1] should be == '\0' or the last char will be truncated). + StringSplitter(char* str, + size_t size, + char delimiter, + EmptyTokenMode empty_token_mode = EmptyTokenMode::DEFAULT); - ScopedPlatformHandle rd; - ScopedPlatformHandle wr; + // Splits the current token from an outer StringSplitter instance. This is to + // chain splitters as follows: + // for (base::StringSplitter lines(x, '\n'); ss.Next();) + // for (base::StringSplitter words(&lines, ' '); words.Next();) + StringSplitter(StringSplitter*, + char delimiter, + EmptyTokenMode empty_token_mode = EmptyTokenMode::DEFAULT); + + // Returns true if a token is found (in which case it will be stored in + // cur_token()), false if no more tokens are found. + bool Next(); + + // Returns the next token if found (in which case it will be stored in + // cur_token()), nullptr if no more tokens are found. + char* NextToken() { return Next() ? cur_token() : nullptr; } + + // Returns the current token iff last call to Next() returned true. In this + // case it guarantees that the returned string is always null terminated. + // In all other cases (before the 1st call to Next() and after Next() returns + // false) returns nullptr. + char* cur_token() { return cur_; } + + // Returns the length of the current token (excluding the null terminator). + size_t cur_token_size() const { return cur_size_; } + + // Return the untokenized remainder of the input string that occurs after the + // current token. + char* remainder() { return next_; } + + // Returns the size of the untokenized input + size_t remainder_size() { return static_cast(end_ - next_); } + + private: + StringSplitter(const StringSplitter&) = delete; + StringSplitter& operator=(const StringSplitter&) = delete; + void Initialize(char* str, size_t size); + + std::string str_; + char* cur_; + size_t cur_size_; + char* next_; + char* end_; // STL-style, points one past the last char. + const char delimiter_; + const EmptyTokenMode empty_token_mode_; }; } // namespace base } // namespace perfetto -#endif // INCLUDE_PERFETTO_EXT_BASE_PIPE_H_ +#endif // INCLUDE_PERFETTO_EXT_BASE_STRING_SPLITTER_H_ /* - * Copyright (C) 2018 The Android Open Source Project + * Copyright (C) 2026 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -2179,110 +2200,166 @@ class Pipe { * limitations under the License. */ -// gen_amalgamated expanded: #include "perfetto/base/build_config.h" - -#include -#include - -#if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN) -#include - -#include -#elif PERFETTO_BUILDFLAG(PERFETTO_OS_QNX) -#include -#elif PERFETTO_BUILDFLAG(PERFETTO_OS_LINUX) || \ - PERFETTO_BUILDFLAG(PERFETTO_OS_ANDROID) -#include -#include -#else // Mac, Fuchsia and other non-Linux UNIXes -#include -#endif +#include +#include +#include +#include -// gen_amalgamated expanded: #include "perfetto/base/logging.h" -// gen_amalgamated expanded: #include "perfetto/ext/base/event_fd.h" -// gen_amalgamated expanded: #include "perfetto/ext/base/pipe.h" +// gen_amalgamated expanded: #include "perfetto/ext/base/cpu_info.h" +// gen_amalgamated expanded: #include "perfetto/ext/base/cpu_info_features_allowlist.h" +// gen_amalgamated expanded: #include "perfetto/ext/base/file_utils.h" +// gen_amalgamated expanded: #include "perfetto/ext/base/string_splitter.h" +// gen_amalgamated expanded: #include "perfetto/ext/base/string_utils.h" // gen_amalgamated expanded: #include "perfetto/ext/base/utils.h" namespace perfetto { namespace base { +namespace { -EventFd::~EventFd() = default; +// Key for default processor string in /proc/cpuinfo as seen on arm. Note the +// uppercase P. +const char kDefaultProcessor[] = "Processor"; -#if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN) -EventFd::EventFd() { - event_handle_.reset( - CreateEventA(/*lpEventAttributes=*/nullptr, /*bManualReset=*/true, - /*bInitialState=*/false, /*bInitialState=*/nullptr)); -} +// Key for processor entry in /proc/cpuinfo. Used to determine whether a group +// of lines describes a CPU. +const char kProcessor[] = "processor"; -void EventFd::Notify() { - if (!SetEvent(event_handle_.get())) // 0: fail, !0: success, unlike UNIX. - PERFETTO_DFATAL("EventFd::Notify()"); -} +// Key for CPU implementer in /proc/cpuinfo. Arm only. +const char kImplementer[] = "CPU implementer"; -void EventFd::Clear() { - if (!ResetEvent(event_handle_.get())) // 0: fail, !0: success, unlike UNIX. - PERFETTO_DFATAL("EventFd::Clear()"); -} +// Key for CPU architecture in /proc/cpuinfo. Arm only. +const char kArchitecture[] = "CPU architecture"; -#elif PERFETTO_BUILDFLAG(PERFETTO_OS_LINUX_BUT_NOT_QNX) || \ - PERFETTO_BUILDFLAG(PERFETTO_OS_ANDROID) +// Key for CPU variant in /proc/cpuinfo. Arm only. +const char kVariant[] = "CPU variant"; -EventFd::EventFd() { - event_handle_.reset(eventfd(/*initval=*/0, EFD_CLOEXEC | EFD_NONBLOCK)); - PERFETTO_CHECK(event_handle_); -} +// Key for CPU part in /proc/cpuinfo. Arm only. +const char kPart[] = "CPU part"; -void EventFd::Notify() { - const uint64_t value = 1; - ssize_t ret = write(event_handle_.get(), &value, sizeof(value)); - if (ret <= 0 && errno != EAGAIN) - PERFETTO_DFATAL("EventFd::Notify()"); -} +// Key for CPU revision in /proc/cpuinfo. Arm only. +const char kRevision[] = "CPU revision"; -void EventFd::Clear() { - uint64_t value; - ssize_t ret = - PERFETTO_EINTR(read(event_handle_.get(), &value, sizeof(value))); - if (ret <= 0 && errno != EAGAIN) - PERFETTO_DFATAL("EventFd::Clear()"); +// Key for feature flags in /proc/cpuinfo. Arm calls them Features, +// Intel calls them Flags. +const char kFeatures[] = "Features"; +const char kFlags[] = "Flags"; + +std::string ReadFile(const std::string& path) { + std::string contents; + if (!base::ReadFile(path, &contents)) + return ""; + return contents; } -#else +} // namespace -EventFd::EventFd() { - // Make the pipe non-blocking so that we never block the waking thread (either - // the main thread or another one) when scheduling a wake-up. - Pipe pipe = Pipe::Create(Pipe::kBothNonBlock); - event_handle_ = ScopedPlatformHandle(std::move(pipe.rd).release()); - write_fd_ = std::move(pipe.wr); -} +std::vector ParseCpuInfo(std::string proc_cpu_info) { + std::vector cpus; + std::string processor = "unknown"; + + std::optional cpu_index; + std::optional implementer; + std::optional architecture; + std::optional variant; + std::optional part; + std::optional revision; + uint64_t features = 0; + uint32_t next_cpu_index = 0; + + auto flush_cpu = [&] { + if (cpu_index.has_value()) { + CpuInfo cpu{}; + cpu.processor = processor; + cpu.cpu_index = *cpu_index; + cpu.implementer = implementer; + cpu.architecture = architecture; + cpu.variant = variant; + cpu.part = part; + cpu.revision = revision; + cpu.features = features; +#if PERFETTO_BUILDFLAG(PERFETTO_ARCH_CPU_ARM64) + if (cpu.implementer && cpu.part) { + std::string cpuid = + base::Uint64ToHexStringNoPrefix(cpu.implementer.value()) + + base::Uint64ToHexStringNoPrefix(cpu.part.value()); + if (cpu.variant) { + cpuid += base::Uint64ToHexStringNoPrefix(cpu.variant.value()); + if (cpu.revision) { + cpuid += base::Uint64ToHexStringNoPrefix(cpu.revision.value()); + } + } + base::StringCopy(cpu.arm_cpuid, cpuid.c_str(), sizeof(cpu.arm_cpuid)); + } +#endif // PERFETTO_BUILDFLAG(PERFETTO_ARCH_CPU_ARM64) + cpus.emplace_back(std::move(cpu)); + next_cpu_index++; + } + cpu_index = std::nullopt; + implementer = std::nullopt; + architecture = std::nullopt; + variant = std::nullopt; + part = std::nullopt; + revision = std::nullopt; + features = 0; + }; -void EventFd::Notify() { - const uint64_t value = 1; - ssize_t ret = write(write_fd_.get(), &value, sizeof(uint8_t)); - if (ret <= 0 && errno != EAGAIN) - PERFETTO_DFATAL("EventFd::Notify()"); + for (base::StringSplitter lines( + std::move(proc_cpu_info), '\n', + base::StringSplitter::EmptyTokenMode::ALLOW_EMPTY_TOKENS); + lines.Next();) { + std::string line(lines.cur_token(), lines.cur_token_size()); + if (line.empty() && cpu_index.has_value()) { + flush_cpu(); + continue; + } + + auto splits = base::SplitString(line, ":"); + if (splits.size() != 2) + continue; + std::string key = + base::StripSuffix(base::StripChars(splits[0], "\t", ' '), " "); + std::string value = base::StripPrefix(splits[1], " "); + + if (key == kDefaultProcessor) { + processor = value; + } else if (key == kProcessor) { + cpu_index = base::StringToUInt32(value); + } else if (key == kImplementer) { + implementer = base::CStringToUInt32(value.data(), 16); + } else if (key == kArchitecture) { + architecture = base::CStringToUInt32(value.data(), 10); + } else if (key == kVariant) { + variant = base::CStringToUInt32(value.data(), 16); + } else if (key == kPart) { + part = base::CStringToUInt32(value.data(), 16); + } else if (key == kRevision) { + revision = base::CStringToUInt32(value.data(), 10); + } else if (key == kFeatures || key == kFlags) { + for (base::StringSplitter ss(value.data(), ' '); ss.Next();) { + for (size_t i = 0; i < base::ArraySize(kCpuInfoFeatures); ++i) { + if (strcmp(ss.cur_token(), kCpuInfoFeatures[i]) == 0) { + static_assert(base::ArraySize(kCpuInfoFeatures) < 64); + features |= 1ull << i; + } + } + } + } + } + + flush_cpu(); + return cpus; } -void EventFd::Clear() { - // Drain the byte(s) written to the wake-up pipe. We can potentially read - // more than one byte if several wake-ups have been scheduled. - char buffer[16]; - ssize_t ret = - PERFETTO_EINTR(read(event_handle_.get(), &buffer[0], sizeof(buffer))); - if (ret <= 0 && errno != EAGAIN) - PERFETTO_DFATAL("EventFd::Clear()"); +std::vector ReadCpuInfo() { + return ParseCpuInfo(ReadFile("/proc/cpuinfo")); } -#endif } // namespace base } // namespace perfetto -// gen_amalgamated begin source: src/base/file_utils.cc -// gen_amalgamated begin header: include/perfetto/ext/base/file_utils.h -// gen_amalgamated begin header: include/perfetto/base/status.h +// gen_amalgamated begin source: src/base/crash_keys.cc +// gen_amalgamated begin header: include/perfetto/ext/base/crash_keys.h /* - * Copyright (C) 2019 The Android Open Source Project + * Copyright (C) 2021 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -2297,107 +2374,695 @@ void EventFd::Clear() { * limitations under the License. */ -#ifndef INCLUDE_PERFETTO_BASE_STATUS_H_ -#define INCLUDE_PERFETTO_BASE_STATUS_H_ +#ifndef INCLUDE_PERFETTO_EXT_BASE_CRASH_KEYS_H_ +#define INCLUDE_PERFETTO_EXT_BASE_CRASH_KEYS_H_ -#include -#include -#include -#include +#include +#include + +#include +#include // gen_amalgamated expanded: #include "perfetto/base/compiler.h" -// gen_amalgamated expanded: #include "perfetto/base/export.h" -// gen_amalgamated expanded: #include "perfetto/base/logging.h" +// gen_amalgamated expanded: #include "perfetto/ext/base/string_view.h" + +// Crash keys are very simple global variables with static-storage that +// are reported on crash time for managed crashes (CHECK/FATAL/Watchdog). +// - Translation units can define a CrashKey and register it at some point +// during initialization. +// - CrashKey instances must be long-lived. They should really be just global +// static variable in the anonymous namespace. +// Example: +// subsystem_1.cc +// CrashKey g_client_id("ipc_client_id"); +// ... +// OnIpcReceived(client_id) { +// g_client_id.Set(client_id); +// ... // Process the IPC +// g_client_id.Clear(); +// } +// Or equivalently: +// OnIpcReceived(client_id) { +// auto scoped_key = g_client_id.SetScoped(client_id); +// ... // Process the IPC +// } +// +// If a crash happens while processing the IPC, the crash report will +// have a line "ipc_client_id: 42". +// +// Thread safety considerations: +// CrashKeys can be registered and set/cleared from any thread. +// There is no compelling use-case to have full acquire/release consistency when +// setting a key. This means that if a thread crashes immediately after a +// crash key has been set on another thread, the value printed on the crash +// report could be incomplete. The code guarantees defined behavior and does +// not rely on null-terminated string (in the worst case 32 bytes of random +// garbage will be printed out). + +// The tests live in logging_unittest.cc. namespace perfetto { namespace base { -// Represents either the success or the failure message of a function. -// This can used as the return type of functions which would usually return an -// bool for success or int for errno but also wants to add some string context -// (ususally for logging). -// -// Similar to absl::Status, an optional "payload" can also be included with more -// context about the error. This allows passing additional metadata about the -// error (e.g. location of errors, potential mitigations etc). -class PERFETTO_EXPORT_COMPONENT Status { +constexpr size_t kCrashKeyMaxStrSize = 32; + +// CrashKey instances must be long lived +class CrashKey { public: - Status() : ok_(true) {} - explicit Status(std::string msg) : ok_(false), message_(std::move(msg)) { - PERFETTO_CHECK(!message_.empty()); - } + class ScopedClear { + public: + explicit ScopedClear(CrashKey* k) : key_(k) {} + ~ScopedClear() { + if (key_) + key_->Clear(); + } + ScopedClear(const ScopedClear&) = delete; + ScopedClear& operator=(const ScopedClear&) = delete; + ScopedClear& operator=(ScopedClear&&) = delete; + ScopedClear(ScopedClear&& other) noexcept : key_(other.key_) { + other.key_ = nullptr; + } - // Copy operations. - Status(const Status&) = default; - Status& operator=(const Status&) = default; + private: + CrashKey* key_; + }; - // Move operations. The moved-from state is valid but unspecified. - Status(Status&&) noexcept = default; - Status& operator=(Status&&) = default; + // constexpr so it can be used in the anon namespace without requiring a + // global constructor. + // |name| must be a long-lived string. + constexpr explicit CrashKey(const char* name) + : registered_{}, type_(Type::kUnset), name_(name), str_value_{} {} + CrashKey(const CrashKey&) = delete; + CrashKey& operator=(const CrashKey&) = delete; + CrashKey(CrashKey&&) = delete; + CrashKey& operator=(CrashKey&&) = delete; - bool ok() const { return ok_; } + enum class Type : uint8_t { kUnset = 0, kInt, kStr }; - // When ok() is false this returns the error message. Returns the empty string - // otherwise. - const std::string& message() const { return message_; } - const char* c_message() const { return message_.c_str(); } + void Clear() { + int_value_.store(0, std::memory_order_relaxed); + type_.store(Type::kUnset, std::memory_order_relaxed); + } - ////////////////////////////////////////////////////////////////////////////// - // Payload Management APIs - ////////////////////////////////////////////////////////////////////////////// + void Set(int64_t value) { + int_value_.store(value, std::memory_order_relaxed); + type_.store(Type::kInt, std::memory_order_relaxed); + if (PERFETTO_UNLIKELY(!registered_.load(std::memory_order_relaxed))) + Register(); + } - // Payloads can be attached to error statuses to provide additional context. - // - // Payloads are (key, value) pairs, where the key is a string acting as a - // unique "type URL" and the value is an opaque string. The "type URL" should - // be unique, follow the format of a URL and, ideally, documentation on how to - // interpret its associated data should be available. - // - // To attach a payload to a status object, call `Status::SetPayload()`. - // Similarly, to extract the payload from a status, call - // `Status::GetPayload()`. - // - // Note: the payload APIs are only meaningful to call when the status is an - // error. Otherwise, all methods are noops. + void Set(StringView sv) { + size_t len = std::min(sv.size(), sizeof(str_value_) - 1); + for (size_t i = 0; i < len; ++i) + str_value_[i].store(sv.data()[i], std::memory_order_relaxed); + str_value_[len].store('\0', std::memory_order_relaxed); + type_.store(Type::kStr, std::memory_order_relaxed); + if (PERFETTO_UNLIKELY(!registered_.load(std::memory_order_relaxed))) + Register(); + } - // Gets the payload for the given |type_url| if one exists. - // - // Will always return std::nullopt if |ok()|. - std::optional GetPayload(std::string_view type_url) const; + ScopedClear SetScoped(int64_t value) PERFETTO_WARN_UNUSED_RESULT { + Set(value); + return ScopedClear(this); + } - // Sets the payload for the given key. The key should - // - // Will always do nothing if |ok()|. - void SetPayload(std::string_view type_url, std::string value); + ScopedClear SetScoped(StringView sv) PERFETTO_WARN_UNUSED_RESULT { + Set(sv); + return ScopedClear(this); + } - // Erases the payload for the given string and returns true if the payload - // existed and was erased. - // - // Will always do nothing if |ok()|. - bool ErasePayload(std::string_view type_url); + void Register(); + + int64_t int_value() const { + return int_value_.load(std::memory_order_relaxed); + } + size_t ToString(char* dst, size_t len); private: - struct Payload { - std::string type_url; - std::string payload; + std::atomic registered_; + std::atomic type_; + const char* const name_; + union { + std::atomic str_value_[kCrashKeyMaxStrSize]; + std::atomic int_value_; }; +}; - bool ok_ = false; - std::string message_; - std::vector payloads_; +// Fills |dst| with a string containing one line for each crash key +// (excluding the unset ones). +// Returns number of chars written, without counting the NUL terminator. +// This is used in logging.cc when emitting the crash report abort message. +size_t SerializeCrashKeys(char* dst, size_t len); + +void UnregisterAllCrashKeysForTesting(); + +} // namespace base +} // namespace perfetto + +#endif // INCLUDE_PERFETTO_EXT_BASE_CRASH_KEYS_H_ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// gen_amalgamated expanded: #include "perfetto/ext/base/crash_keys.h" + +#include + +#include +#include + +// gen_amalgamated expanded: #include "perfetto/ext/base/string_utils.h" + +namespace perfetto { +namespace base { + +namespace { + +constexpr size_t kMaxKeys = 32; + +std::atomic g_keys[kMaxKeys]{}; +std::atomic g_num_keys{}; +} // namespace + +void CrashKey::Register() { + // If doesn't matter if we fail below. If there are no slots left, don't + // keep trying re-registering on every Set(), the outcome won't change. + + // If two threads raced on the Register(), avoid registering the key twice. + if (registered_.exchange(true)) + return; + + uint32_t slot = g_num_keys.fetch_add(1); + if (slot >= kMaxKeys) { + PERFETTO_LOG("Too many crash keys registered"); + return; + } + g_keys[slot].store(this); +} + +// Returns the number of chars written, without counting the \0. +size_t CrashKey::ToString(char* dst, size_t len) { + if (len > 0) + *dst = '\0'; + switch (type_.load(std::memory_order_relaxed)) { + case Type::kUnset: + break; + case Type::kInt: + return SprintfTrunc(dst, len, "%s: %" PRId64 "\n", name_, + int_value_.load(std::memory_order_relaxed)); + case Type::kStr: + char buf[sizeof(str_value_)]; + for (size_t i = 0; i < sizeof(str_value_); i++) + buf[i] = str_value_[i].load(std::memory_order_relaxed); + + // Don't assume |str_value_| is properly null-terminated. + return SprintfTrunc(dst, len, "%s: %.*s\n", name_, int(sizeof(buf)), buf); + } + return 0; +} + +void UnregisterAllCrashKeysForTesting() { + g_num_keys.store(0); + for (auto& key : g_keys) + key.store(nullptr); +} + +size_t SerializeCrashKeys(char* dst, size_t len) { + size_t written = 0; + uint32_t num_keys = g_num_keys.load(); + if (len > 0) + *dst = '\0'; + for (uint32_t i = 0; i < num_keys && written < len; i++) { + CrashKey* key = g_keys[i].load(); + if (!key) + continue; // Can happen if we hit this between the add and the store. + written += key->ToString(dst + written, len - written); + } + PERFETTO_DCHECK(written <= len); + PERFETTO_DCHECK(len == 0 || dst[written] == '\0'); + return written; +} + +} // namespace base +} // namespace perfetto +// gen_amalgamated begin source: src/base/ctrl_c_handler.cc +// gen_amalgamated begin header: include/perfetto/ext/base/ctrl_c_handler.h +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef INCLUDE_PERFETTO_EXT_BASE_CTRL_C_HANDLER_H_ +#define INCLUDE_PERFETTO_EXT_BASE_CTRL_C_HANDLER_H_ + +namespace perfetto { +namespace base { + +// On Linux/Android/Mac: installs SIGINT + SIGTERM signal handlers. +// On Windows: installs a SetConsoleCtrlHandler() handler. +// The passed handler must be async safe. +using CtrlCHandlerFunction = void (*)(); +void InstallCtrlCHandler(CtrlCHandlerFunction); + +} // namespace base +} // namespace perfetto + +#endif // INCLUDE_PERFETTO_EXT_BASE_CTRL_C_HANDLER_H_ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// gen_amalgamated expanded: #include "perfetto/ext/base/ctrl_c_handler.h" + +// gen_amalgamated expanded: #include "perfetto/base/build_config.h" +// gen_amalgamated expanded: #include "perfetto/base/compiler.h" +// gen_amalgamated expanded: #include "perfetto/base/logging.h" + +#if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN) +#include + +#include +#else +#include +#include +#endif + +namespace perfetto { +namespace base { + +namespace { +CtrlCHandlerFunction g_handler = nullptr; + +#if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN) +BOOL WINAPI Trampoline(DWORD type) { + if (type == CTRL_C_EVENT) { + g_handler(); + return TRUE; + } + return FALSE; +} +#endif +} // namespace + +void InstallCtrlCHandler(CtrlCHandlerFunction handler) { + PERFETTO_CHECK(g_handler == nullptr); + g_handler = handler; + +#if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN) + ::SetConsoleCtrlHandler(Trampoline, true); +#elif PERFETTO_BUILDFLAG(PERFETTO_OS_LINUX) || \ + PERFETTO_BUILDFLAG(PERFETTO_OS_ANDROID) || \ + PERFETTO_BUILDFLAG(PERFETTO_OS_APPLE) + // Setup signal handler. + struct sigaction sa{}; + +// Glibc headers for sa_sigaction trigger this. +#pragma GCC diagnostic push +#if defined(__clang__) +#pragma GCC diagnostic ignored "-Wdisabled-macro-expansion" +#endif + sa.sa_handler = [](int) { g_handler(); }; +#if !PERFETTO_BUILDFLAG(PERFETTO_OS_QNX) + sa.sa_flags = static_cast(SA_RESETHAND | SA_RESTART); +#else // POSIX-compliant + sa.sa_flags = static_cast(SA_RESETHAND); +#endif +#pragma GCC diagnostic pop + sigaction(SIGINT, &sa, nullptr); + sigaction(SIGTERM, &sa, nullptr); +#else + // Do nothing on NaCL and Fuchsia. + ignore_result(handler); +#endif +} + +} // namespace base +} // namespace perfetto +// gen_amalgamated begin source: src/base/dynamic_string_writer.cc +// gen_amalgamated begin header: include/perfetto/ext/base/dynamic_string_writer.h +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef INCLUDE_PERFETTO_EXT_BASE_DYNAMIC_STRING_WRITER_H_ +#define INCLUDE_PERFETTO_EXT_BASE_DYNAMIC_STRING_WRITER_H_ + +#include + +#include +#include +#include +#include +#include +#include +#include + +// gen_amalgamated expanded: #include "perfetto/base/logging.h" +// gen_amalgamated expanded: #include "perfetto/ext/base/string_utils.h" +// gen_amalgamated expanded: #include "perfetto/ext/base/string_view.h" + +namespace perfetto { +namespace base { + +// A helper class which writes formatted data to a string buffer. +// This is used in the trace processor where we write O(GBs) of strings and +// sprintf is too slow. +class DynamicStringWriter { + public: + using ScopedCString = std::unique_ptr; + + // Creates a string buffer from a char buffer and length. + DynamicStringWriter() {} + + // Appends n instances of a char to the buffer. + void AppendChar(char in, size_t n = 1) { buffer_.append(n, in); } + + // Appends a length delimited string to the buffer. + void AppendString(const char* in, size_t n) { buffer_.append(in, n); } + + void AppendStringView(StringView sv) { AppendString(sv.data(), sv.size()); } + + // Appends a null-terminated string literal to the buffer. + template + inline void AppendLiteral(const char (&in)[N]) { + AppendString(in, N - 1); + } + + // Appends a StringView to the buffer. + void AppendString(StringView data) { + buffer_.append(data.data(), data.size()); + } + + // Appends an integer to the buffer. + void AppendInt(int64_t value) { + constexpr size_t STACK_BUFFER_SIZE = 32; + StackString buf("%" PRId64, value); + AppendString(buf.string_view()); + } + + // Appends an integer to the buffer, padding with |padchar| if the number of + // digits of the integer is less than |padding|. + template + void AppendPaddedInt(int64_t sign_value) { + const bool negate = std::signbit(static_cast(sign_value)); + uint64_t absolute_value; + if (sign_value == std::numeric_limits::min()) { + absolute_value = + static_cast(std::numeric_limits::max()) + 1; + } else { + absolute_value = static_cast(std::abs(sign_value)); + } + AppendPaddedIntImpl(absolute_value, negate); + } + + void AppendUnsignedInt(uint64_t value) { + constexpr size_t STACK_BUFFER_SIZE = 32; + StackString buf("%" PRIu64, value); + AppendString(buf.string_view()); + } + + template + void AppendPaddedUnsignedInt(uint64_t value) { + AppendPaddedIntImpl(value, false); + } + + template + void AppendPaddedHexInt(IntType value, char padchar, uint64_t padding) { + using UnsignedType = std::make_unsigned_t; + constexpr size_t kMaxHexDigits = sizeof(IntType) * 2; + constexpr size_t kBufferSize = 32; + auto size_needed = + kMaxHexDigits > padding ? kMaxHexDigits : static_cast(padding); + PERFETTO_DCHECK(size_needed <= kBufferSize); + + std::array data; + constexpr char hex_asc[] = "0123456789abcdef"; + + size_t idx = size_needed - 1; + auto uvalue = static_cast(value); + do { + data[idx--] = hex_asc[uvalue & 0xF]; + uvalue >>= 4; + } while (uvalue != 0); + + if (padding > 0) { + const auto num_digits = static_cast(size_needed - 1 - idx); + // std::max() needed to work around GCC not being able to tell that + // padding > 0. + for (auto i = num_digits; i < std::max(uint64_t{1u}, padding); i++) { + data[idx--] = padchar; + } + } + AppendString(&data[idx + 1], size_needed - idx - 1); + } + + // Appends a hex integer to the buffer. + template + void AppendHexInt(IntType value) { + constexpr size_t STACK_BUFFER_SIZE = 64; + StackString buf("%" PRIx64, value); + AppendString(buf.string_view()); + } + + void AppendHexString(const uint8_t* data, size_t size, char separator); + + void AppendHexString(StringView data, char separator) { + AppendHexString(reinterpret_cast(data.data()), data.size(), + separator); + } + + // Appends a double to the buffer. + void AppendDouble(double value) { + constexpr size_t STACK_BUFFER_SIZE = 32; + StackString buf("%.16g", value); + AppendString(buf.string_view()); + } + + void AppendBool(bool value) { + if (value) { + AppendLiteral("true"); + return; + } + AppendLiteral("false"); + } + + StringView GetStringView() { + return StringView(buffer_.c_str(), buffer_.size()); + } + + ScopedCString CreateStringCopy() const { + size_t n = buffer_.size(); + char* dup = reinterpret_cast(malloc(n + 1)); + if (dup) { + memcpy(dup, buffer_.data(), n); + dup[n] = '\0'; + } + return {dup, free}; + } + + size_t pos() const { return buffer_.size(); } + + void Clear() { buffer_.clear(); } + + private: + template + void AppendPaddedIntImpl(uint64_t absolute_value, bool negate) { + // Need to add 2 to the number of digits to account for minus sign and + // rounding down of digits10. + constexpr auto kMaxDigits = std::numeric_limits::digits10 + 2; + constexpr auto kSizeNeeded = kMaxDigits > padding ? kMaxDigits : padding; + + char data[kSizeNeeded]; + + size_t idx; + for (idx = kSizeNeeded - 1; absolute_value >= 10;) { + char digit = absolute_value % 10; + absolute_value /= 10; + data[idx--] = digit + '0'; + } + data[idx--] = static_cast(absolute_value) + '0'; + + if (padding > 0) { + size_t num_digits = kSizeNeeded - 1 - idx; + // std::max() needed to work around GCC not being able to tell that + // padding > 0. + for (size_t i = num_digits; i < std::max(uint64_t{1u}, padding); i++) { + data[idx--] = padchar; + } + } + + if (negate) + AppendChar('-'); + AppendString(&data[idx + 1], kSizeNeeded - idx - 1); + } + + std::string buffer_; }; -// Returns a status object which represents the Ok status. -inline Status OkStatus() { - return Status(); -} +} // namespace base +} // namespace perfetto + +#endif // INCLUDE_PERFETTO_EXT_BASE_DYNAMIC_STRING_WRITER_H_ +/* + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// gen_amalgamated expanded: #include "perfetto/ext/base/dynamic_string_writer.h" + +#include +#include +#include + +namespace perfetto { +namespace base { + +void DynamicStringWriter::AppendHexString(const uint8_t* data, + size_t size, + char separator) { + // Truncate to 64 bytes, as this is the maximum supported by the Linux + // kernel's vsnprintf implementation. + size_t printed_size = std::min(size, size_t{64}); + + if (printed_size) { + AppendPaddedHexInt(data[0], '0', 2); + } + for (size_t pos = 1; pos < printed_size; pos++) { + AppendChar(separator); + AppendPaddedHexInt(data[pos], '0', 2); + } +} + +} // namespace base +} // namespace perfetto +// gen_amalgamated begin source: src/base/event_fd.cc +// gen_amalgamated begin header: include/perfetto/ext/base/event_fd.h +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef INCLUDE_PERFETTO_EXT_BASE_EVENT_FD_H_ +#define INCLUDE_PERFETTO_EXT_BASE_EVENT_FD_H_ + +// gen_amalgamated expanded: #include "perfetto/base/build_config.h" +// gen_amalgamated expanded: #include "perfetto/base/platform_handle.h" +// gen_amalgamated expanded: #include "perfetto/ext/base/scoped_file.h" + +namespace perfetto { +namespace base { + +// A waitable event that can be used with poll/select. +// This is really a wrapper around eventfd_create with a pipe-based fallback +// for other platforms where eventfd is not supported. +class EventFd { + public: + EventFd(); + ~EventFd(); + EventFd(EventFd&&) noexcept = default; + EventFd& operator=(EventFd&&) = default; + + // The non-blocking file descriptor that can be polled to wait for the event. + PlatformHandle fd() const { return event_handle_.get(); } + + // Can be called from any thread. + void Notify(); + + // Can be called from any thread. If more Notify() are queued a Clear() call + // can clear all of them (up to 16 per call). + void Clear(); + + private: + // The eventfd, when eventfd is supported, otherwise this is the read end of + // the pipe for fallback mode. + ScopedPlatformHandle event_handle_; -Status ErrStatus(const char* format, ...) PERFETTO_PRINTF_FORMAT(1, 2); +// QNX is specified because it is a non-Linux UNIX platform but it +// still sets the PERFETTO_OS_LINUX flag to be as compatible as possible +// with the Linux build. +#if !PERFETTO_BUILDFLAG(PERFETTO_OS_LINUX_BUT_NOT_QNX) && \ + !PERFETTO_BUILDFLAG(PERFETTO_OS_ANDROID) && \ + !PERFETTO_BUILDFLAG(PERFETTO_OS_WIN) + // On Mac and other non-Linux UNIX platforms a pipe-based fallback is used. + // The write end of the wakeup pipe. + ScopedFile write_fd_; +#endif +}; } // namespace base } // namespace perfetto -#endif // INCLUDE_PERFETTO_BASE_STATUS_H_ +#endif // INCLUDE_PERFETTO_EXT_BASE_EVENT_FD_H_ +// gen_amalgamated begin header: include/perfetto/ext/base/pipe.h /* * Copyright (C) 2018 The Android Open Source Project * @@ -2414,177 +3079,156 @@ Status ErrStatus(const char* format, ...) PERFETTO_PRINTF_FORMAT(1, 2); * limitations under the License. */ -#ifndef INCLUDE_PERFETTO_EXT_BASE_FILE_UTILS_H_ -#define INCLUDE_PERFETTO_EXT_BASE_FILE_UTILS_H_ - -#include // For mode_t & O_RDONLY/RDWR. Exists also on Windows. -#include - -#include -#include -#include -#include -#include +#ifndef INCLUDE_PERFETTO_EXT_BASE_PIPE_H_ +#define INCLUDE_PERFETTO_EXT_BASE_PIPE_H_ -// gen_amalgamated expanded: #include "perfetto/base/build_config.h" -// gen_amalgamated expanded: #include "perfetto/base/export.h" -// gen_amalgamated expanded: #include "perfetto/base/status.h" +// gen_amalgamated expanded: #include "perfetto/base/platform_handle.h" // gen_amalgamated expanded: #include "perfetto/ext/base/scoped_file.h" -// gen_amalgamated expanded: #include "perfetto/ext/base/sys_types.h" namespace perfetto { namespace base { -class TaskRunner; - -#if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN) -using FileOpenMode = int; -inline constexpr char kDevNull[] = "NUL"; -inline constexpr char kFopenReadFlag[] = "r"; -#else -using FileOpenMode = mode_t; -inline constexpr char kDevNull[] = "/dev/null"; -inline constexpr char kFopenReadFlag[] = "re"; +class Pipe { + public: + enum Flags { + kBothBlock = 0, +#if !PERFETTO_BUILDFLAG(PERFETTO_OS_WIN) + kBothNonBlock, + kRdNonBlock, + kWrNonBlock, #endif + }; -constexpr FileOpenMode kFileModeInvalid = static_cast(-1); + static Pipe Create(Flags = kBothBlock); -bool ReadPlatformHandle(PlatformHandle, std::string* out); -bool ReadFileDescriptor(int fd, std::string* out); -bool ReadFileStream(FILE* f, std::string* out); -bool ReadFile(const std::string& path, std::string* out); + Pipe(); + Pipe(Pipe&&) noexcept; + Pipe& operator=(Pipe&&); -// A wrapper around read(2). It deals with Linux vs Windows includes. It also -// deals with handling EINTR. Has the same semantics of UNIX's read(2). -ssize_t Read(int fd, void* dst, size_t dst_size); + ScopedPlatformHandle rd; + ScopedPlatformHandle wr; +}; -// Call write until all data is written or an error is detected. -// -// man 2 write: -// If a write() is interrupted by a signal handler before any bytes are -// written, then the call fails with the error EINTR; if it is -// interrupted after at least one byte has been written, the call -// succeeds, and returns the number of bytes written. -ssize_t WriteAll(int fd, const void* buf, size_t count); +} // namespace base +} // namespace perfetto -// Copies all data from |fd_in| to |fd_out|. Saves the offset of |fd_in|, -// rewinds it to the beginning, copies the content, and restores the offset. -// |fd_in| can't be a pipe, socket of FIFO. -base::Status CopyFileContents(int fd_in, int fd_out); +#endif // INCLUDE_PERFETTO_EXT_BASE_PIPE_H_ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ -ssize_t WriteAllHandle(PlatformHandle, const void* buf, size_t count); +// gen_amalgamated expanded: #include "perfetto/base/build_config.h" -ScopedFile OpenFile(const std::string& path, - int flags, - FileOpenMode = kFileModeInvalid); -ScopedFstream OpenFstream(const std::string& path, const std::string& mode); +#include +#include -// This is an alias for close(). It's to avoid leaking windows.h in headers. -// Exported because ScopedFile is used in the /include/ext API by Chromium -// component builds. -int PERFETTO_EXPORT_COMPONENT CloseFile(int fd); +#if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN) +#include -bool FlushFile(int fd); +#include +#elif PERFETTO_BUILDFLAG(PERFETTO_OS_QNX) +#include +#elif PERFETTO_BUILDFLAG(PERFETTO_OS_LINUX) || \ + PERFETTO_BUILDFLAG(PERFETTO_OS_ANDROID) +#include +#include +#else // Mac, Fuchsia and other non-Linux UNIXes +#include +#endif -// Returns true if mkdir succeeds, false if it fails (see errno in that case). -bool Mkdir(const std::string& path); +// gen_amalgamated expanded: #include "perfetto/base/logging.h" +// gen_amalgamated expanded: #include "perfetto/ext/base/event_fd.h" +// gen_amalgamated expanded: #include "perfetto/ext/base/pipe.h" +// gen_amalgamated expanded: #include "perfetto/ext/base/utils.h" -// Calls rmdir() on UNIX, _rmdir() on Windows. -bool Rmdir(const std::string& path); +namespace perfetto { +namespace base { -// Wrapper around access(path, F_OK). -bool FileExists(const std::string& path); +EventFd::~EventFd() = default; -// Gets the extension for a filename. If the file has two extensions, returns -// only the last one (foo.pb.gz => .gz). Returns empty string if there is no -// extension. -std::string GetFileExtension(const std::string& filename); +#if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN) +EventFd::EventFd() { + event_handle_.reset( + CreateEventA(/*lpEventAttributes=*/nullptr, /*bManualReset=*/true, + /*bInitialState=*/false, /*bInitialState=*/nullptr)); +} -// Returns the basename component of a path (the final component after the last -// directory separator). Behaves like man 2 basename, but works with both '/' -// and '\' separators for cross-platform compatibility. -// Examples: -// Basename("/usr/bin/ls") => "ls" -// Basename("/usr/bin/") => "bin" -// Basename("/") => "/" -// Basename("foo") => "foo" -// Basename("") => "." -// Basename("C:\\Windows\\System32") => "System32" -std::string Basename(const std::string& path); +void EventFd::Notify() { + if (!SetEvent(event_handle_.get())) // 0: fail, !0: success, unlike UNIX. + PERFETTO_DFATAL("EventFd::Notify()"); +} -// Returns the directory component of a path (everything up to but not -// including the final component). Behaves like man 2 dirname, but works with -// both '/' and '\' separators for cross-platform compatibility. -// Examples: -// Dirname("/usr/bin/ls") => "/usr/bin" -// Dirname("/usr/bin") => "/usr" -// Dirname("/") => "/" -// Dirname("foo") => "." -// Dirname("") => "." -// Dirname("C:\\Windows\\System32") => "C:\\Windows" -std::string Dirname(const std::string& path); +void EventFd::Clear() { + if (!ResetEvent(event_handle_.get())) // 0: fail, !0: success, unlike UNIX. + PERFETTO_DFATAL("EventFd::Clear()"); +} -// Puts the path to all files under |dir_path| in |output|, recursively walking -// subdirectories. File paths are relative to |dir_path|. Only files are -// included, not directories. Path separator is always '/', even on windows (not -// '\'). -base::Status ListFilesRecursive(const std::string& dir_path, - std::vector& output); +#elif PERFETTO_BUILDFLAG(PERFETTO_OS_LINUX_BUT_NOT_QNX) || \ + PERFETTO_BUILDFLAG(PERFETTO_OS_ANDROID) -// Lists immediate subdirectories in |dir_path| (non-recursive). Directory names -// are relative to |dir_path| and do not include the path separator. Returns -// only directories, not files. Works on both Unix and Windows. -base::Status ListDirectories(const std::string& dir_path, - std::vector& output); +EventFd::EventFd() { + event_handle_.reset(eventfd(/*initval=*/0, EFD_CLOEXEC | EFD_NONBLOCK)); + PERFETTO_CHECK(event_handle_); +} -// Sets |path|'s owner group to |group_name| and permission mode bits to -// |mode_bits|. -base::Status SetFilePermissions(const std::string& path, - const std::string& group_name, - const std::string& mode_bits); +void EventFd::Notify() { + const uint64_t value = 1; + ssize_t ret = write(event_handle_.get(), &value, sizeof(value)); + if (ret <= 0 && errno != EAGAIN) + PERFETTO_DFATAL("EventFd::Notify()"); +} -// Returns the size of the file located at |path|, or nullopt in case of error. -std::optional GetFileSize(const std::string& path); +void EventFd::Clear() { + uint64_t value; + ssize_t ret = + PERFETTO_EINTR(read(event_handle_.get(), &value, sizeof(value))); + if (ret <= 0 && errno != EAGAIN) + PERFETTO_DFATAL("EventFd::Clear()"); +} -// Returns the size of the open file |fd|, or nullopt in case of error. -std::optional GetFileSize(PlatformHandle fd); +#else -// This class uses inotify (on Linux/Android) to watch for the creation of -// files in the filesystem. When the specified file is created, it triggers a -// callback function. -// Destroying the returned unique_ptr will automatically unregister the watch. -// -// Note: This only works with filesystem paths (not abstract sockets or other -// special file types). -// It's only supported on Linux and Android, it's a no-op (returns nullptr) on -// other platforms. -// -// Usage: -// auto watch = LinuxFileWatch::WatchFileCreation( -// task_runner, "/tmp/my_file", []() { -// // Called when /tmp/my_file is created -// }); -class LinuxFileWatch { - public: - // Creates a watcher for file creation. Returns nullptr if the path is not a - // valid filesystem path or if the platform doesn't support inotify. The - // callback will be invoked on the provided TaskRunner when the file is - // created. - static std::unique_ptr WatchFileCreation( - TaskRunner*, - const char* path, - std::function callback); +EventFd::EventFd() { + // Make the pipe non-blocking so that we never block the waking thread (either + // the main thread or another one) when scheduling a wake-up. + Pipe pipe = Pipe::Create(Pipe::kBothNonBlock); + event_handle_ = ScopedPlatformHandle(std::move(pipe.rd).release()); + write_fd_ = std::move(pipe.wr); +} - virtual ~LinuxFileWatch(); +void EventFd::Notify() { + const uint64_t value = 1; + ssize_t ret = write(write_fd_.get(), &value, sizeof(uint8_t)); + if (ret <= 0 && errno != EAGAIN) + PERFETTO_DFATAL("EventFd::Notify()"); +} - protected: - LinuxFileWatch() = default; -}; +void EventFd::Clear() { + // Drain the byte(s) written to the wake-up pipe. We can potentially read + // more than one byte if several wake-ups have been scheduled. + char buffer[16]; + ssize_t ret = + PERFETTO_EINTR(read(event_handle_.get(), &buffer[0], sizeof(buffer))); + if (ret <= 0 && errno != EAGAIN) + PERFETTO_DFATAL("EventFd::Clear()"); +} +#endif } // namespace base } // namespace perfetto - -#endif // INCLUDE_PERFETTO_EXT_BASE_FILE_UTILS_H_ +// gen_amalgamated begin source: src/base/file_utils.cc // gen_amalgamated begin header: include/perfetto/base/task_runner.h /* * Copyright (C) 2017 The Android Open Source Project @@ -3121,11 +3765,12 @@ bool FlushFile(int fd) { #endif } -bool Mkdir(const std::string& path) { +bool Mkdir(const std::string& path, uint32_t mode) { #if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN) + base::ignore_result(mode); return _mkdir(path.c_str()) == 0; #else - return mkdir(path.c_str(), 0755) == 0; + return mkdir(path.c_str(), mode) == 0; #endif } @@ -3137,6 +3782,14 @@ bool Rmdir(const std::string& path) { #endif } +bool Unlink(const char* path) { +#if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN) + return _unlink(path) == 0; +#else + return unlink(path) == 0; +#endif +} + int CloseFile(int fd) { return close(fd); } @@ -3265,6 +3918,9 @@ base::Status ListFilesRecursive(const std::string& dir_path, struct stat dirstat; std::string full_path = cur_dir + dirent->d_name; PERFETTO_CHECK(stat(full_path.c_str(), &dirstat) == 0); + // MSan's stat() interceptor on glibc 2.35+ does not mark the output + // buffer as initialized (the syscall goes through statx). + PERFETTO_MSAN_UNPOISON(&dirstat, sizeof(dirstat)); if (S_ISDIR(dirstat.st_mode)) { dir_queue.push_back(full_path + '/'); } else if (S_ISREG(dirstat.st_mode)) { @@ -3596,275 +4252,6 @@ LinuxFileWatch::~LinuxFileWatch() = default; #endif // OS_LINUX || OS_ANDROID -} // namespace base -} // namespace perfetto -// gen_amalgamated begin source: src/base/fixed_string_writer.cc -// gen_amalgamated begin header: include/perfetto/ext/base/fixed_string_writer.h -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#ifndef INCLUDE_PERFETTO_EXT_BASE_FIXED_STRING_WRITER_H_ -#define INCLUDE_PERFETTO_EXT_BASE_FIXED_STRING_WRITER_H_ - -#include - -#include -#include -#include -#include -#include -#include - -// gen_amalgamated expanded: #include "perfetto/base/logging.h" -// gen_amalgamated expanded: #include "perfetto/ext/base/string_utils.h" -// gen_amalgamated expanded: #include "perfetto/ext/base/string_view.h" - -namespace perfetto { -namespace base { - -// A helper class which writes formatted data to a string buffer. -// This is used in the trace processor where we write O(GBs) of strings and -// sprintf is too slow. -class FixedStringWriter { - public: - // Creates a string buffer from a char buffer and length. - FixedStringWriter(char* buffer, size_t size) : buffer_(buffer), size_(size) {} - - // Appends n instances of a char to the buffer. - void AppendChar(char in, size_t n = 1) { - PERFETTO_DCHECK(pos_ + n <= size_); - memset(&buffer_[pos_], in, n); - pos_ += n; - } - - // Appends a length delimited string to the buffer. - void AppendString(const char* in, size_t n) { - PERFETTO_DCHECK(pos_ + n <= size_); - memcpy(&buffer_[pos_], in, n); - pos_ += n; - } - - void AppendStringView(StringView sv) { AppendString(sv.data(), sv.size()); } - - // Appends a null-terminated string literal to the buffer. - template - inline void AppendLiteral(const char (&in)[N]) { - AppendString(in, N - 1); - } - - // Appends a StringView to the buffer. - void AppendString(StringView data) { AppendString(data.data(), data.size()); } - - // Appends an integer to the buffer. - void AppendInt(int64_t value) { AppendPaddedInt<'0', 0>(value); } - - // Appends an integer to the buffer, padding with |padchar| if the number of - // digits of the integer is less than |padding|. - template - void AppendPaddedInt(int64_t sign_value) { - const bool negate = std::signbit(static_cast(sign_value)); - uint64_t absolute_value; - if (sign_value == std::numeric_limits::min()) { - absolute_value = - static_cast(std::numeric_limits::max()) + 1; - } else { - absolute_value = static_cast(std::abs(sign_value)); - } - AppendPaddedInt(absolute_value, negate); - } - - void AppendUnsignedInt(uint64_t value) { - AppendPaddedUnsignedInt<'0', 0>(value); - } - - // Appends an unsigned integer to the buffer, padding with |padchar| if the - // number of digits of the integer is less than |padding|. - template - void AppendPaddedUnsignedInt(uint64_t value) { - AppendPaddedInt(value, false); - } - - template - void AppendPaddedHexInt(IntType value, char padchar, uint64_t padding) { - using UnsignedType = std::make_unsigned_t; - constexpr size_t kMaxHexDigits = sizeof(IntType) * 2; - // 32 bytes is more than enough for any integer type (max 16 hex digits for - // 64-bit) - constexpr size_t kBufferSize = 32; - auto size_needed = - kMaxHexDigits > padding ? kMaxHexDigits : static_cast(padding); - PERFETTO_DCHECK(size_needed <= kBufferSize); - PERFETTO_DCHECK(pos_ + size_needed <= size_); - - std::array data; - constexpr char hex_asc[] = "0123456789abcdef"; - - size_t idx = size_needed - 1; - auto uvalue = static_cast(value); - do { - data[idx--] = hex_asc[uvalue & 0xF]; - uvalue >>= 4; - } while (uvalue != 0); - - if (padding > 0) { - const auto num_digits = static_cast(size_needed - 1 - idx); - // std::max() needed to work around GCC not being able to tell that - // padding > 0. - for (auto i = num_digits; i < std::max(uint64_t{1u}, padding); i++) { - data[idx--] = padchar; - } - } - AppendString(&data[idx + 1], size_needed - idx - 1); - } - - // Appends a hex integer to the buffer. - template - void AppendHexInt(IntType value) { - AppendPaddedHexInt(value, '0', 0); - } - - // Appends a hex string to the buffer. - void AppendHexString(const uint8_t* data, size_t size, char separator); - - void AppendHexString(StringView data, char separator) { - AppendHexString(reinterpret_cast(data.data()), data.size(), - separator); - } - - // Appends a double to the buffer. - void AppendDouble(double value) { - // TODO(lalitm): trying to optimize this is premature given we almost never - // print doubles. Reevaluate this in the future if we do print them more. - size_t res = base::SprintfTrunc(buffer_ + pos_, size_ - pos_, "%lf", value); - PERFETTO_DCHECK(pos_ + res <= size_); - pos_ += res; - } - - void AppendBool(bool value) { - if (value) { - AppendLiteral("true"); - return; - } - AppendLiteral("false"); - } - - StringView GetStringView() { - PERFETTO_DCHECK(pos_ <= size_); - return StringView(buffer_, pos_); - } - - char* CreateStringCopy() { - char* dup = reinterpret_cast(malloc(pos_ + 1)); - if (dup) { - memcpy(dup, buffer_, pos_); - dup[pos_] = '\0'; - } - return dup; - } - - size_t pos() const { return pos_; } - size_t size() const { return size_; } - void reset() { pos_ = 0; } - - private: - template - void AppendPaddedInt(uint64_t absolute_value, bool negate) { - // Need to add 2 to the number of digits to account for minus sign and - // rounding down of digits10. - constexpr auto kMaxDigits = std::numeric_limits::digits10 + 2; - constexpr auto kSizeNeeded = kMaxDigits > padding ? kMaxDigits : padding; - PERFETTO_DCHECK(pos_ + kSizeNeeded <= size_); - - char data[kSizeNeeded]; - - size_t idx; - for (idx = kSizeNeeded - 1; absolute_value >= 10;) { - char digit = absolute_value % 10; - absolute_value /= 10; - data[idx--] = digit + '0'; - } - data[idx--] = static_cast(absolute_value) + '0'; - - if (padding > 0) { - size_t num_digits = kSizeNeeded - 1 - idx; - // std::max() needed to work around GCC not being able to tell that - // padding > 0. - for (size_t i = num_digits; i < std::max(uint64_t{1u}, padding); i++) { - data[idx--] = padchar; - } - } - - if (negate) - buffer_[pos_++] = '-'; - AppendString(&data[idx + 1], kSizeNeeded - idx - 1); - } - - char* buffer_ = nullptr; - size_t size_ = 0; - size_t pos_ = 0; -}; - -} // namespace base -} // namespace perfetto - -#endif // INCLUDE_PERFETTO_EXT_BASE_FIXED_STRING_WRITER_H_ -/* - * Copyright (C) 2026 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// gen_amalgamated expanded: #include "perfetto/ext/base/fixed_string_writer.h" - -#include -#include -#include - -namespace perfetto { -namespace base { - -void FixedStringWriter::AppendHexString(const uint8_t* data, - size_t size, - char separator) { - // Truncate to 64 bytes, as this is the maximum supported by the Linux - // kernel's vsnprintf implementation. - size_t printed_size = std::min(size, size_t{64}); - // Remove trailing separator from calculation if printed_size > 0. - size_t max_chars = printed_size * 3 - (printed_size > 0 ? 1 : 0); - PERFETTO_DCHECK(pos_ + max_chars <= size_); - - if (printed_size) { - AppendPaddedHexInt(data[0], '0', 2); - } - for (size_t pos = 1; pos < printed_size; pos++) { - AppendChar(separator); - AppendPaddedHexInt(data[pos], '0', 2); - } -} - } // namespace base } // namespace perfetto // gen_amalgamated begin source: src/base/getopt_compat.cc @@ -3997,6 +4384,36 @@ const option* LookupShortOpt(const std::vector