From 39174814db026735f47eee2a53b6fa79b056439a Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Wed, 10 Jun 2026 15:44:57 -0700 Subject: [PATCH 1/5] Upgrade reported Node.js version to 26.3.0 [build images] Bump the Node.js compatibility target from 24.3.0 to 26.3.0 (V8 14.6.202.34, NODE_MODULE_VERSION 147) and sync node:stream and node:http with the behavioral changes upstream made between v24.x and v26.x. - Headers, bootstrap pins, flake, process.versions updated; CI images rebuilt for the Node 26 toolchain - V8 shim updated for 14.6 (Isolate roots layout, flattened FunctionCallbackInfo exit frame, String Write*V2/Utf8LengthV2, External::New pointer-tag overload, HandleScope::Extend) with Itanium + MSVC symbol exports - napi_get_value_string_* no longer panics when querying length with a null buffer; v8/napi fixtures migrated to the new header APIs - stream/http v26 sync: Writable.toWeb sync-drain hang, read() one-chunk semantics, Duplex.from destroy-during-idle hang, BYOB Readable.toWeb, writeHeader removal (DEP0063 EOL), upgrade-listener fallthrough, set-cookie header edge cases, http2 respond() raw-array rejection and session error codes - Vendored the matching upstream tests and verified changed expectations against real Node 26.3.0 --- flake.nix | 6 +- scripts/bootstrap.ps1 | 8 +- scripts/bootstrap.sh | 31 +- scripts/build/codegen.ts | 6 +- scripts/build/config.ts | 7 +- scripts/build/deps/nodejs-headers.ts | 7 +- scripts/build/flags.ts | 6 +- scripts/packer/windows-x64.pkr.hcl | 6 +- src/js/builtins/CompressionStream.ts | 8 +- src/js/builtins/DecompressionStream.ts | 8 +- src/js/builtins/ReadableStreamInternals.ts | 13 + src/js/internal/http.ts | 4 + src/js/internal/primordials.js | 19 +- src/js/internal/streams/duplex.ts | 4 +- src/js/internal/streams/duplexify.ts | 2 +- src/js/internal/streams/end-of-stream.ts | 227 +++++--- src/js/internal/streams/operators.ts | 48 +- src/js/internal/streams/pipeline.ts | 2 +- src/js/internal/streams/readable.ts | 41 +- src/js/internal/streams/writable.ts | 3 + src/js/internal/webstreams_adapters.ts | 292 +++++++--- src/js/node/_http_common.ts | 7 +- src/js/node/_http_outgoing.ts | 103 +++- src/js/node/_http_server.ts | 9 +- src/js/node/http2.ts | 115 +++- src/js/node/https.ts | 31 +- src/jsc/ErrorCode.rs | 7 +- src/jsc/bindings/BunProcess.cpp | 22 +- .../BunProcessReportObjectWindows.cpp | 7 +- src/jsc/bindings/ErrorCode.cpp | 2 + src/jsc/bindings/ErrorCode.ts | 3 + src/jsc/bindings/NodeHTTP.cpp | 6 + src/jsc/bindings/napi.cpp | 17 + src/jsc/bindings/v8/V8Array.cpp | 4 +- .../v8/V8EscapableHandleScopeBase.cpp | 56 +- .../bindings/v8/V8EscapableHandleScopeBase.h | 19 +- src/jsc/bindings/v8/V8External.cpp | 14 + src/jsc/bindings/v8/V8External.h | 10 + .../bindings/v8/V8FunctionCallbackInfo.cpp | 41 +- src/jsc/bindings/v8/V8FunctionCallbackInfo.h | 69 ++- src/jsc/bindings/v8/V8HandleScope.cpp | 118 ++++- src/jsc/bindings/v8/V8HandleScope.h | 27 + src/jsc/bindings/v8/V8Isolate.cpp | 4 + src/jsc/bindings/v8/V8Isolate.h | 19 +- src/jsc/bindings/v8/V8Number.cpp | 10 + src/jsc/bindings/v8/V8Number.h | 7 + src/jsc/bindings/v8/V8String.cpp | 145 ++++- src/jsc/bindings/v8/V8String.h | 41 ++ src/jsc/bindings/v8/V8Value.cpp | 25 + src/jsc/bindings/v8/V8Value.h | 7 + src/jsc/bindings/v8/shim/FunctionTemplate.cpp | 64 ++- src/jsc/bindings/v8/shim/GlobalInternals.h | 20 + src/jsc/bindings/v8/shim/Handle.h | 6 + .../bindings/v8/shim/HandleScopeBuffer.cpp | 32 ++ src/jsc/bindings/v8/shim/HandleScopeBuffer.h | 40 ++ src/jsc/bindings/v8/v8_handle_scope_data.h | 39 ++ src/jsc/bindings/webcore/JSFetchHeaders.cpp | 14 +- src/runtime/api/bun/h2_frame_parser.rs | 37 +- src/runtime/napi/napi_body.rs | 61 +++ src/symbols.def | 20 + src/symbols.dyn | 16 + src/symbols.txt | 16 + .../migration/complex-workspace.test.ts | 8 +- test/harness.ts | 178 ++++++- .../next-pages/test/dev-server-puppeteer.ts | 31 +- .../test/dev-server-ssr-100.test.ts | 6 +- .../next-pages/test/dev-server.test.ts | 16 +- .../next-pages/test/next-build.test.ts | 6 +- .../js/bun/crypto/cipheriv-decipheriv.test.ts | 12 +- test/js/node/crypto/crypto.test.ts | 17 +- test/js/node/http/node-http-parser.test.ts | 31 ++ test/js/node/http/node-http.test.ts | 206 ++++++++ test/js/node/http2/node-http2.test.js | 182 ++++++- .../process/dlopen-duplicate-load.test.ts | 14 +- .../process/dlopen-non-object-exports.test.ts | 14 +- test/js/node/process/process.test.js | 8 +- .../stream/node-stream-uint8array.test.ts | 4 +- test/js/node/stream/node-stream.test.js | 500 ++++++++++++++++++ .../test-crypto-cipheriv-decipheriv.js | 4 +- .../parallel/test-http2-getpackedsettings.js | 4 +- .../node/test/parallel/test-stream-compose.js | 3 +- .../test/parallel/test-stream-push-strings.js | 2 +- .../test-stream-readable-emittedReadable.js | 2 +- .../test-stream-readable-infinite-read.js | 13 +- .../test-stream-readable-needReadable.js | 2 +- .../test-stream-readable-to-web-byob.js | 49 ++ ...stream-readable-to-web-termination-byob.js | 15 + ...test-stream-readable-to-web-termination.js | 36 +- .../test/parallel/test-stream-typedarray.js | 5 +- .../test/parallel/test-stream-uint8array.js | 4 +- .../test/parallel/test-stream2-transform.js | 5 +- ...treams-adapters-writable-buffer-sources.js | 95 ++++ .../test-webstreams-compression-bad-chunks.js | 75 +++ ...st-webstreams-compression-buffer-source.js | 42 ++ ...plex-fromweb-writev-unhandled-rejection.js | 55 ++ .../test-whatwg-webstreams-compression.js | 4 +- .../test-zlib-flush-write-sync-interleaved.js | 4 +- .../duckdb/duckdb-basic-usage.test.ts | 8 + .../third_party/grpc-js/test-server.test.ts | 11 +- test/js/web/streams/compression.test.ts | 86 +++ test/js/web/streams/streams.test.js | 14 + test/napi/napi-app/bun.lock | 1 + test/napi/napi-app/package.json | 4 +- test/napi/napi-app/standalone_tests.cpp | 16 +- test/napi/napi-finalizer-delete-ref.test.ts | 65 +-- test/napi/napi-value-ffi.test.ts | 7 +- test/napi/napi.test.ts | 43 +- test/napi/node-napi-tests/harness.ts | 4 +- test/regression/issue/30205.test.ts | 4 +- .../v8/bad-modules/mismatched_abi_version.cpp | 2 +- test/v8/bad-modules/no_entrypoint.cpp | 2 +- test/v8/v8-module/main.cpp | 165 ++++-- test/v8/v8.test.ts | 78 ++- 113 files changed, 3668 insertions(+), 552 deletions(-) create mode 100644 src/jsc/bindings/v8/v8_handle_scope_data.h create mode 100644 test/js/node/test/parallel/test-stream-readable-to-web-byob.js create mode 100644 test/js/node/test/parallel/test-stream-readable-to-web-termination-byob.js create mode 100644 test/js/node/test/parallel/test-webstreams-adapters-writable-buffer-sources.js create mode 100644 test/js/node/test/parallel/test-webstreams-compression-bad-chunks.js create mode 100644 test/js/node/test/parallel/test-webstreams-compression-buffer-source.js create mode 100644 test/js/node/test/parallel/test-webstreams-duplex-fromweb-writev-unhandled-rejection.js diff --git a/flake.nix b/flake.nix index 175b7c584d76..38667c11dfb3 100644 --- a/flake.nix +++ b/flake.nix @@ -31,8 +31,8 @@ clang = pkgs.clang_21; lld = pkgs.lld_21; - # Node.js 24 - matching the bootstrap script (targets 24.3.0, actual version from nixpkgs-unstable) - nodejs = pkgs.nodejs_24; + # Node.js 26 - matching the bootstrap script (targets 26.3.0, actual version from nixpkgs-unstable) + nodejs = pkgs.nodejs_26; # Build tools and dependencies packages = [ @@ -54,7 +54,7 @@ # Bun itself (for running build scripts via `bun bd`) pkgs.bun - # Node.js - version pinned to 24 + # Node.js - version pinned to 26 nodejs # Python for build scripts diff --git a/scripts/bootstrap.ps1 b/scripts/bootstrap.ps1 index f222ef8137d8..93619c5b8cc3 100755 --- a/scripts/bootstrap.ps1 +++ b/scripts/bootstrap.ps1 @@ -1,4 +1,4 @@ -# Version: 20 +# Version: 21 # A script that installs the dependencies needed to build and test Bun on Windows. # Supports both x64 and ARM64 using Scoop for package management. # Used by Azure [build images] pipeline. @@ -215,9 +215,9 @@ function Install-Git { } function Install-NodeJs { - # Pin to match the ABI version Bun expects (NODE_MODULE_VERSION 137). - # Latest Node (25.x) uses ABI 141 which breaks node-gyp tests. - $nodejsVersion = "24.3.0" + # Pin to match the ABI version Bun expects (NODE_MODULE_VERSION 147). + # A mismatched Node ABI breaks node-gyp tests. + $nodejsVersion = "26.3.0" Install-Scoop-Package "nodejs@$nodejsVersion" -Command node # Seed node-gyp's cache so napi tests don't re-download headers + node.lib diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index a0bbd7920a44..1e85e42b9a69 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -1,5 +1,5 @@ #!/bin/sh -# Version: 34 +# Version: 37 # A script that installs the dependencies needed to build and test Bun. # This should work on macOS and Linux with a POSIX shell. @@ -780,7 +780,7 @@ install_common_software() { } nodejs_version_exact() { - print "24.3.0" + print "26.3.0" } nodejs_version() { @@ -819,7 +819,11 @@ install_nodejs() { case "$abi" in musl) - nodejs_mirror="https://bun-nodejs-release.s3.us-west-1.amazonaws.com" + # nodejs.org doesn't publish musl binaries; the unofficial-builds + # project (nodejs/unofficial-builds) ships both x64-musl and + # arm64-musl for current releases. (The old private S3 mirror at + # bun-nodejs-release predates arm64-musl being available there.) + nodejs_mirror="https://unofficial-builds.nodejs.org/download/release" nodejs_foldername="node-v$nodejs_version-$nodejs_platform-$nodejs_arch-musl" ;; *) @@ -1450,6 +1454,12 @@ install_windows_sysroot() { execute_sudo rm -rf "$sysroot" execute_sudo mkdir -p "$sysroot" + # The cache must live on the same filesystem as the output: splat moves + # unpacked files with rename(2), which fails with EXDEV (cross-device + # link) when the download dir is on tmpfs and /opt is not. + xwin_cache="$sysroot.cache" + execute_sudo rm -rf "$xwin_cache" + execute_sudo mkdir -p "$xwin_cache" # Both target arches in one splat; --include-debug-libs so /MTd (debug # CRT) links work; --include-atl for (rescle.cpp); # winsysroot-style + MS arch notation so clang-cl and lld-link resolve it @@ -1457,14 +1467,14 @@ install_windows_sysroot() { # include/lib casing on a case-sensitive filesystem. # stdout is dropped: xwin draws progress bars there even without a TTY, # which floods the image-build log. Errors stay on stderr. - execute_sudo "$xwin_dir/xwin" --accept-license --arch x86_64,aarch64 --sdk-version 10.0.26100 --crt-version 14.44.17.14 --include-atl --cache-dir "$xwin_dir/cache" \ + execute_sudo "$xwin_dir/xwin" --accept-license --arch x86_64,aarch64 --sdk-version 10.0.26100 --crt-version 14.44.17.14 --include-atl --cache-dir "$xwin_cache" \ splat --use-winsysroot-style --preserve-ms-arch-notation --include-debug-libs \ --output "$sysroot" >/dev/null # clang-cl/lld-link compose SDK paths as "Include"/"Lib" (title case); # the winsysroot-style splat writes lowercase — alias both spellings. execute_sudo ln -s include "$sysroot/Windows Kits/10/Include" execute_sudo ln -s lib "$sysroot/Windows Kits/10/Lib" - execute_sudo rm -rf "$xwin_dir" + execute_sudo rm -rf "$xwin_dir" "$xwin_cache" # No WINDOWS_SYSROOT export — detectWindowsSysroot() picks up # /opt/winsysroot by well-known path. } @@ -1768,6 +1778,17 @@ install_chromium() { else install_packages libasound2 fi + + # Install Chrome itself on x64 (no arm64 build exists): with a system + # browser present, puppeteer-based tests skip their per-run ~300MB + # Chrome for Testing download entirely (see + # test/harness.ts getPuppeteerInstallEnv). + if [ "$arch" = "x64" ]; then + chrome_deb=$(download_file "https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb") + # Best-effort: execute_sudo aborts the whole script on failure, so the + # fallback chain must run inside a single sudo'd shell. + execute_sudo sh -c "apt-get install -y '$chrome_deb' || dpkg -i '$chrome_deb' || true" + fi ;; dnf | yum) install_packages \ diff --git a/scripts/build/codegen.ts b/scripts/build/codegen.ts index 83c5c0f91edf..49c57c61e462 100644 --- a/scripts/build/codegen.ts +++ b/scripts/build/codegen.ts @@ -763,6 +763,10 @@ function emitJsModules({ n, cfg, sources, o, dirStamp }: Ctx): void { // InternalModuleRegistry.cpp is read by the script (for a sanity check). const extraInput = resolve(cfg.cwd, "src", "jsc", "bindings", "InternalModuleRegistry.cpp"); + // replacements.ts bakes ErrorCode.ts indices into every bundled module + // ($makeErrorWithCode(N, ...)); without this dep an ErrorCode.ts edit leaves + // stale error numbers in the JS bundles while the C++ enum regenerates. + const errorCodeInput = resolve(cfg.cwd, "src", "jsc", "bindings", "ErrorCode.ts"); // Written into src/ (not codegenDir) — see zigFilesGeneratedIntoSrc at top. const js2nativeZig = resolve(cfg.cwd, zigFilesGeneratedIntoSrc[1]); @@ -791,7 +795,7 @@ function emitJsModules({ n, cfg, sources, o, dirStamp }: Ctx): void { n.build({ outputs, rule: "codegen", - inputs: [script, ...sources.js, ...sources.jsCodegen, extraInput], + inputs: [script, ...sources.js, ...sources.jsCodegen, extraInput, errorCodeInput], orderOnlyInputs: [dirStamp], vars: { cwd: cfg.cwd, diff --git a/scripts/build/config.ts b/scripts/build/config.ts index 380575939466..54d554b9e131 100644 --- a/scripts/build/config.ts +++ b/scripts/build/config.ts @@ -10,7 +10,7 @@ import { execSync } from "node:child_process"; import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, symlinkSync } from "node:fs"; import { homedir, arch as hostArch, platform as hostPlatform } from "node:os"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; -import { NODEJS_ABI_VERSION, NODEJS_VERSION } from "./deps/nodejs-headers.ts"; +import { NODEJS_ABI_VERSION, NODEJS_V8_VERSION, NODEJS_VERSION } from "./deps/nodejs-headers.ts"; import { WEBKIT_VERSION } from "./deps/webkit.ts"; import { assert, BuildError } from "./error.ts"; import { resolveMacosSdkPath } from "./macos-sdk.ts"; @@ -61,6 +61,7 @@ export interface Host { const versionDefaults = { nodejsVersion: NODEJS_VERSION, nodejsAbiVersion: NODEJS_ABI_VERSION, + nodejsV8Version: NODEJS_V8_VERSION, webkitVersion: WEBKIT_VERSION, }; @@ -307,6 +308,7 @@ export interface Config { /** Node.js compat version. Default in versions.ts; override to test a bump. */ nodejsVersion: string; nodejsAbiVersion: string; + nodejsV8Version: string; /** WebKit commit. Default in versions.ts; override to test a WebKit branch. */ webkitVersion: string; } @@ -368,6 +370,7 @@ export interface PartialConfig { // Version pins (defaults in versions.ts). nodejsVersion?: string; nodejsAbiVersion?: string; + nodejsV8Version?: string; webkitVersion?: string; } @@ -1019,6 +1022,7 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con // to test a branch before bumping the pinned default. const nodejsVersion = partial.nodejsVersion ?? versionDefaults.nodejsVersion; const nodejsAbiVersion = partial.nodejsAbiVersion ?? versionDefaults.nodejsAbiVersion; + const nodejsV8Version = partial.nodejsV8Version ?? versionDefaults.nodejsV8Version; const webkitVersion = partial.webkitVersion ?? versionDefaults.webkitVersion; // ─── macOS SDK ─── @@ -1180,6 +1184,7 @@ export function resolveConfig(partial: PartialConfig, toolchain: Toolchain): Con version, revision, nodejsVersion, + nodejsV8Version, nodejsAbiVersion, canaryRevision, webkitVersion, diff --git a/scripts/build/deps/nodejs-headers.ts b/scripts/build/deps/nodejs-headers.ts index bd68ed833951..3bad3fea27e6 100644 --- a/scripts/build/deps/nodejs-headers.ts +++ b/scripts/build/deps/nodejs-headers.ts @@ -14,10 +14,13 @@ import type { Dependency } from "../source.ts"; * download URL, and passed to zig as -Dreported_nodejs_version. * Override via `--nodejs-version=X.Y.Z` to test a bump. */ -export const NODEJS_VERSION = "24.3.0"; +export const NODEJS_VERSION = "26.3.0"; /** Node.js NODE_MODULE_VERSION — for native addon ABI compat. */ -export const NODEJS_ABI_VERSION = "137"; +export const NODEJS_ABI_VERSION = "147"; + +/** V8 version reported by process.versions.v8 — must match the pinned Node.js version's. */ +export const NODEJS_V8_VERSION = "14.6.202.34-node.20"; export const nodejsHeaders: Dependency = { name: "nodejs", diff --git a/scripts/build/flags.ts b/scripts/build/flags.ts index 7759c7576d6f..966f56f77bcb 100644 --- a/scripts/build/flags.ts +++ b/scripts/build/flags.ts @@ -742,7 +742,7 @@ export const defines: Flag[] = [ }, { // Shell-escaped quotes so clang receives literal quotes in the define - // (the preprocessor needs the string to be "24.3.0", not bare 24.3.0). + // (the preprocessor needs the string to be "26.3.0", not bare 26.3.0). flag: c => `REPORTED_NODEJS_VERSION=\\"${c.nodejsVersion}\\"`, desc: "Node.js version string reported by process.version", }, @@ -750,6 +750,10 @@ export const defines: Flag[] = [ flag: c => `REPORTED_NODEJS_ABI_VERSION=${c.nodejsAbiVersion}`, desc: "Node.js ABI version (process.versions.modules)", }, + { + flag: c => `REPORTED_NODEJS_V8_VERSION=\\"${c.nodejsV8Version}\\"`, + desc: "V8 version string (process.versions.v8)", + }, { // Hardcoded ON — experimental flag not exposed in config flag: "USE_BUN_MIMALLOC=1", diff --git a/scripts/packer/windows-x64.pkr.hcl b/scripts/packer/windows-x64.pkr.hcl index 250c7f0ffc7d..10fab1c60992 100644 --- a/scripts/packer/windows-x64.pkr.hcl +++ b/scripts/packer/windows-x64.pkr.hcl @@ -14,7 +14,11 @@ source "azure-arm" "windows-x64" { // Build VM — only used during image creation, not for CI runners. // CI runner VM sizes are set in ci.mjs (azureVmSizes). - vm_size = "Standard_D4ds_v6" + // D4as_v7 (AMD): D4ds_v6 hit repeated AllocationFailed (no capacity for + // that size in the region); Azure's allocation-guidance suggested this + // size as an in-region alternative. Build-only VM, so the CPU vendor + // doesn't affect the produced image. + vm_size = "Standard_D4as_v7" // Use existing resource group instead of creating a temp one build_resource_group_name = var.resource_group diff --git a/src/js/builtins/CompressionStream.ts b/src/js/builtins/CompressionStream.ts index a777b35a531e..5ca7520ddc4d 100644 --- a/src/js/builtins/CompressionStream.ts +++ b/src/js/builtins/CompressionStream.ts @@ -1,6 +1,6 @@ export function initializeCompressionStream(this, format) { const zlib = require("node:zlib"); - const stream = require("node:stream"); + const { newBufferSourceTransformPairFromDuplex } = require("internal/webstreams_adapters"); const builders = { "deflate": zlib.createDeflate, @@ -13,9 +13,9 @@ export function initializeCompressionStream(this, format) { if (!(format in builders)) throw $ERR_INVALID_ARG_VALUE("format", format, "must be one of: " + Object.keys(builders).join(", ")); - const handle = builders[format](); - $putByIdDirectPrivate(this, "readable", stream.Readable.toWeb(handle)); - $putByIdDirectPrivate(this, "writable", stream.Writable.toWeb(handle)); + const transform = newBufferSourceTransformPairFromDuplex(builders[format]()); + $putByIdDirectPrivate(this, "readable", transform.readable); + $putByIdDirectPrivate(this, "writable", transform.writable); return this; } diff --git a/src/js/builtins/DecompressionStream.ts b/src/js/builtins/DecompressionStream.ts index bf608d03fdcf..0df175dc69fd 100644 --- a/src/js/builtins/DecompressionStream.ts +++ b/src/js/builtins/DecompressionStream.ts @@ -1,6 +1,6 @@ export function initializeDecompressionStream(this, format) { const zlib = require("node:zlib"); - const stream = require("node:stream"); + const { newBufferSourceTransformPairFromDuplex } = require("internal/webstreams_adapters"); const builders = { "deflate": zlib.createInflate, @@ -13,9 +13,9 @@ export function initializeDecompressionStream(this, format) { if (!(format in builders)) throw $ERR_INVALID_ARG_VALUE("format", format, "must be one of: " + Object.keys(builders).join(", ")); - const handle = builders[format](); - $putByIdDirectPrivate(this, "readable", stream.Readable.toWeb(handle)); - $putByIdDirectPrivate(this, "writable", stream.Writable.toWeb(handle)); + const transform = newBufferSourceTransformPairFromDuplex(builders[format]()); + $putByIdDirectPrivate(this, "readable", transform.readable); + $putByIdDirectPrivate(this, "writable", transform.writable); return this; } diff --git a/src/js/builtins/ReadableStreamInternals.ts b/src/js/builtins/ReadableStreamInternals.ts index 4675347c1a2f..1698c3b2eb57 100644 --- a/src/js/builtins/ReadableStreamInternals.ts +++ b/src/js/builtins/ReadableStreamInternals.ts @@ -1606,6 +1606,19 @@ export function readableStreamCancel(stream: ReadableStream, reason: any) { if (state === $streamErrored) return Promise.$reject($getByIdDirectPrivate(stream, "storedError")); $readableStreamClose(stream); + // https://streams.spec.whatwg.org/#readable-stream-cancel step 5: a BYOB + // reader's pending read requests are closed with undefined ($readableStreamClose + // only settles default-reader read requests; respond(0) is not coming after cancel). + const reader = $getByIdDirectPrivate(stream, "reader"); + if (reader && $isReadableStreamBYOBReader(reader)) { + const requests = $getByIdDirectPrivate(reader, "readIntoRequests"); + if (requests.isNotEmpty()) { + $putByIdDirectPrivate(reader, "readIntoRequests", $createFIFO()); + for (var request = requests.shift(); request; request = requests.shift()) + $fulfillPromise(request, { value: undefined, done: true }); + } + } + const controller = $getByIdDirectPrivate(stream, "readableStreamController"); if (controller === null) return Promise.$resolve(); diff --git a/src/js/internal/http.ts b/src/js/internal/http.ts index ecd92d4c4f01..c13fb12d2139 100644 --- a/src/js/internal/http.ts +++ b/src/js/internal/http.ts @@ -353,6 +353,8 @@ function emitErrorNt(msg, err, callback) { const setMaxHTTPHeaderSize = $newZigFunction("node_http_binding.zig", "setMaxHTTPHeaderSize", 1); const getMaxHTTPHeaderSize = $newZigFunction("node_http_binding.zig", "getMaxHTTPHeaderSize", 0); const kOutHeaders = Symbol("kOutHeaders"); +const kProxyConfig = Symbol("kProxyConfig"); +const kWaitForProxyTunnel = Symbol("kWaitForProxyTunnel"); function ipToInt(ip) { const octets = ip.split("."); @@ -532,6 +534,7 @@ export { kPendingCallbacks, kPort, kProtocol, + kProxyConfig, kRealListen, kRequest, kRes, @@ -542,6 +545,7 @@ export { kTls, kUpgradeOrConnect, kUseDefaultPort, + kWaitForProxyTunnel, noBodySymbol, optionsSymbol, parseProxyConfigFromEnv, diff --git a/src/js/internal/primordials.js b/src/js/internal/primordials.js index f0b3f598ecad..9ad7d3e80013 100644 --- a/src/js/internal/primordials.js +++ b/src/js/internal/primordials.js @@ -96,13 +96,13 @@ const arrayToSafePromiseIterable = (promises, mapFn) => const PromiseAll = Promise.all; const PromiseResolve = Promise.$resolve.bind(Promise); const SafePromiseAll = (promises, mapFn) => PromiseAll(arrayToSafePromiseIterable(promises, mapFn)); -const SafePromiseAllReturnArrayLike = (promises, mapFn) => +// Shared scheduler for SafePromiseAllReturnVoid/ReturnArrayLike: `returnVal` +// is null for the void variant (no result bookkeeping, resolves with nothing). +const safePromiseAllCollect = (promises, mapFn, returnVal) => new Promise((resolve, reject) => { const { length } = promises; - const returnVal = Array(length); - ObjectSetPrototypeOf(returnVal, null); - if (length === 0) resolve(returnVal); + if (length === 0) resolve(returnVal ?? undefined); let pendingPromises = length; for (let i = 0; i < length; i++) { @@ -110,13 +110,19 @@ const SafePromiseAllReturnArrayLike = (promises, mapFn) => PromisePrototypeThen.$call( PromiseResolve(promise), result => { - returnVal[i] = result; - if (--pendingPromises === 0) resolve(returnVal); + if (returnVal !== null) returnVal[i] = result; + if (--pendingPromises === 0) resolve(returnVal ?? undefined); }, reject, ); } }); +const SafePromiseAllReturnVoid = (promises, mapFn) => safePromiseAllCollect(promises, mapFn, null); +const SafePromiseAllReturnArrayLike = (promises, mapFn) => { + const returnVal = Array(promises.length); + ObjectSetPrototypeOf(returnVal, null); + return safePromiseAllCollect(promises, mapFn, returnVal); +}; export default { Array, @@ -136,6 +142,7 @@ export default { ), SafePromiseAll, SafePromiseAllReturnArrayLike, + SafePromiseAllReturnVoid, SafeSet: makeSafe( Set, class SafeSet extends Set { diff --git a/src/js/internal/streams/duplex.ts b/src/js/internal/streams/duplex.ts index 69754017b663..1637355d1dce 100644 --- a/src/js/internal/streams/duplex.ts +++ b/src/js/internal/streams/duplex.ts @@ -140,8 +140,8 @@ Duplex.fromWeb = function (pair, options) { return lazyWebStreams().newStreamDuplexFromReadableWritablePair(pair, options); }; -Duplex.toWeb = function (duplex) { - return lazyWebStreams().newReadableWritablePairFromDuplex(duplex); +Duplex.toWeb = function (duplex, options) { + return lazyWebStreams().newReadableWritablePairFromDuplex(duplex, options); }; let duplexify; diff --git a/src/js/internal/streams/duplexify.ts b/src/js/internal/streams/duplexify.ts index cb193e7895b4..43d405ede059 100644 --- a/src/js/internal/streams/duplexify.ts +++ b/src/js/internal/streams/duplexify.ts @@ -312,7 +312,7 @@ function _duplexify(pair) { eos(r, err => { readable = false; if (err) { - destroyer(r, err); + destroyer(w, err); } onfinished(err); }); diff --git a/src/js/internal/streams/end-of-stream.ts b/src/js/internal/streams/end-of-stream.ts index 0ba0a43eeb60..99a66dd5ac79 100644 --- a/src/js/internal/streams/end-of-stream.ts +++ b/src/js/internal/streams/end-of-stream.ts @@ -26,7 +26,7 @@ const SymbolDispose = Symbol.dispose; const PromisePrototypeThen = $Promise.prototype.$then; let addAbortListener; -let AsyncLocalStorage; +let AsyncResource; function isRequest(stream) { return stream.setHeader && typeof stream.abort === "function"; @@ -34,6 +34,57 @@ function isRequest(stream) { const nop = () => {}; +function bindAsyncResource(fn, type) { + AsyncResource ??= require("node:async_hooks").AsyncResource; + const resource = new AsyncResource(type); + return function (...args) { + return resource.runInAsyncScope(fn, this, ...args); + }; +} + +// Returns true when an AsyncLocalStorage context is currently active, in +// which case eos() must snapshot it so the callback observes the context +// from registration time (matching Node's AsyncContextFrame.current()). +function hasAsyncContext() { + return $getInternalField($asyncContext, 0) !== undefined; +} + +/** + * Returns the current stream error tracked by eos(), if any. + */ +function getEosErrored(stream) { + const errored = isWritableErrored(stream) || isReadableErrored(stream); + return (typeof errored !== "boolean" && errored) || null; +} + +/** + * Returns the error eos() would report from an immediate close, including + * premature close detection for unfinished readable or writable sides. + */ +function getEosOnCloseError(stream, readable, readableFinished, writable, writableFinished) { + const errored = getEosErrored(stream); + if (errored) { + return errored; + } + + if (readable && !readableFinished && isReadableNodeStream(stream, true)) { + if (!isReadableFinished(stream, false)) { + return $ERR_STREAM_PREMATURE_CLOSE(); + } + } + if (writable && !writableFinished) { + if (!isWritableFinished(stream, false)) { + return $ERR_STREAM_PREMATURE_CLOSE(); + } + } + + return null; +} + +// Internal only: if eos() can settle immediately, invoke the callback before +// returning cleanup. Callers must tolerate cleanup yet to be assigned. +const kEosNodeSynchronousCallback = Symbol("kEosNodeSynchronousCallback"); + function eos(stream, options, callback) { if (arguments.length === 2) { callback = options; @@ -46,9 +97,6 @@ function eos(stream, options, callback) { validateFunction(callback, "callback"); validateAbortSignal(options.signal, "options.signal"); - AsyncLocalStorage ??= require("node:async_hooks").AsyncLocalStorage; - callback = once(AsyncLocalStorage.bind(callback)); - if (isReadableStream(stream) || isWritableStream(stream)) { return eosWeb(stream, options, callback); } @@ -60,22 +108,92 @@ function eos(stream, options, callback) { const readable = options.readable ?? isReadableNodeStream(stream); const writable = options.writable ?? isWritableNodeStream(stream); + // TODO (ronag): Improve soft detection to include core modules and + // common ecosystem modules that do properly emit 'close' but fail + // this generic check. + let willEmitClose = + _willEmitClose(stream) && isReadableNodeStream(stream) === readable && isWritableNodeStream(stream) === writable; + let writableFinished = isWritableFinished(stream, false); + let readableFinished = isReadableFinished(stream, false); + const wState = stream._writableState; const rState = stream._readableState; + /** + * undefined: to be determined + * null: no error + * Error: an error occurred + */ + let immediateResult; + if (isClosed(stream)) { + immediateResult = getEosOnCloseError(stream, readable, readableFinished, writable, writableFinished); + } else if (wState?.errorEmitted || rState?.errorEmitted) { + if (!willEmitClose) { + immediateResult = getEosErrored(stream); + } + } else if ( + !readable && + (!willEmitClose || isReadable(stream)) && + (writableFinished || isWritable(stream) === false) && + (wState == null || wState.pendingcb === undefined || wState.pendingcb === 0) + ) { + immediateResult = getEosErrored(stream); + } else if ( + !writable && + (!willEmitClose || isWritable(stream)) && + (readableFinished || isReadable(stream) === false) + ) { + immediateResult = getEosErrored(stream); + } else if (rState && stream.req && stream.aborted) { + immediateResult = getEosErrored(stream); + } + + let cleanup = () => { + callback = nop; + }; + if (immediateResult !== undefined) { + if (options.error !== false) { + stream.on("error", nop); + cleanup = () => { + callback = nop; + stream.removeListener("error", nop); + }; + } + } else if (options.signal?.aborted) { + immediateResult = $makeAbortError(undefined, { cause: options.signal.reason }); + } + // null means "finished without error": invoke with no error argument at all, + // not an explicit null/undefined. + const invokeImmediate = () => { + if (immediateResult === null) { + callback.$call(stream); + } else { + callback.$call(stream, immediateResult); + } + }; + + if (immediateResult !== undefined && options[kEosNodeSynchronousCallback]) { + invokeImmediate(); + return cleanup; + } + + if (hasAsyncContext()) { + callback = bindAsyncResource(callback, "STREAM_END_OF_STREAM"); + } + + if (immediateResult !== undefined) { + process.nextTick(invokeImmediate); + return cleanup; + } + + callback = once(callback); + const onlegacyfinish = () => { if (!stream.writable) { onfinish(); } }; - // TODO (ronag): Improve soft detection to include core modules and - // common ecosystem modules that do properly emit 'close' but fail - // this generic check. - let willEmitClose = - _willEmitClose(stream) && isReadableNodeStream(stream) === readable && isWritableNodeStream(stream) === writable; - - let writableFinished = isWritableFinished(stream, false); const onfinish = () => { writableFinished = true; // Stream should not be destroyed here. If it is that @@ -94,7 +212,6 @@ function eos(stream, options, callback) { } }; - let readableFinished = isReadableFinished(stream, false); const onend = () => { readableFinished = true; // Stream should not be destroyed here. If it is that @@ -117,37 +234,13 @@ function eos(stream, options, callback) { callback.$call(stream, err); }; - let closed = isClosed(stream); - const onclose = () => { - closed = true; - - const errored = isWritableErrored(stream) || isReadableErrored(stream); - - if (errored && typeof errored !== "boolean") { - return callback.$call(stream, errored); - } - - if (readable && !readableFinished && isReadableNodeStream(stream, true)) { - if (!isReadableFinished(stream, false)) return callback.$call(stream, $ERR_STREAM_PREMATURE_CLOSE()); - } - if (writable && !writableFinished) { - if (!isWritableFinished(stream, false)) return callback.$call(stream, $ERR_STREAM_PREMATURE_CLOSE()); - } - - callback.$call(stream); - }; - - const onclosed = () => { - closed = true; - - const errored = isWritableErrored(stream) || isReadableErrored(stream); - - if (errored && typeof errored !== "boolean") { - return callback.$call(stream, errored); + const error = getEosOnCloseError(stream, readable, readableFinished, writable, writableFinished); + if (error === null) { + callback.$call(stream); + } else { + callback.$call(stream, error); } - - callback.$call(stream); }; const onrequest = () => { @@ -182,30 +275,7 @@ function eos(stream, options, callback) { } stream.on("close", onclose); - if (closed) { - process.nextTick(onclose); - } else if (wState?.errorEmitted || rState?.errorEmitted) { - if (!willEmitClose) { - process.nextTick(onclosed); - } - } else if ( - !readable && - (!willEmitClose || isReadable(stream)) && - (writableFinished || isWritable(stream) === false) && - (wState == null || wState.pendingcb === undefined || wState.pendingcb === 0) - ) { - process.nextTick(onclosed); - } else if ( - !writable && - (!willEmitClose || isWritable(stream)) && - (readableFinished || isReadable(stream) === false) - ) { - process.nextTick(onclosed); - } else if (rState && stream.req && stream.aborted) { - process.nextTick(onclosed); - } - - const cleanup = () => { + cleanup = () => { callback = nop; stream.removeListener("aborted", onclose); stream.removeListener("complete", onfinish); @@ -220,30 +290,32 @@ function eos(stream, options, callback) { stream.removeListener("close", onclose); }; - if (options.signal && !closed) { + if (options.signal) { const abort = () => { // Keep it because cleanup removes it. const endCallback = callback; cleanup(); endCallback.$call(stream, $makeAbortError(undefined, { cause: options.signal.reason })); }; - if (options.signal.aborted) { - process.nextTick(abort); - } else { - addAbortListener ??= require("internal/abort_listener").addAbortListener; - const disposable = addAbortListener(options.signal, abort); - const originalCallback = callback; - callback = once((...args) => { - disposable[SymbolDispose](); - originalCallback.$apply(stream, args); - }); - } + addAbortListener ??= require("internal/abort_listener").addAbortListener; + const disposable = addAbortListener(options.signal, abort); + const originalCallback = callback; + callback = once((...args) => { + disposable[SymbolDispose](); + originalCallback.$apply(stream, args); + }); } return cleanup; } function eosWeb(stream, options, callback) { + if (hasAsyncContext()) { + callback = once(bindAsyncResource(callback, "STREAM_END_OF_STREAM")); + } else { + callback = once(callback); + } + let isAborted = false; let abort = nop; if (options.signal) { @@ -296,4 +368,5 @@ function finished(stream, opts) { } eos.finished = finished; +eos.kEosNodeSynchronousCallback = kEosNodeSynchronousCallback; export default eos; diff --git a/src/js/internal/streams/operators.ts b/src/js/internal/streams/operators.ts index 802c9c8c24e6..1508a66480a9 100644 --- a/src/js/internal/streams/operators.ts +++ b/src/js/internal/streams/operators.ts @@ -1,11 +1,8 @@ "use strict"; -const { validateAbortSignal, validateInteger, validateObject } = require("internal/validators"); +const { validateAbortSignal, validateFunction, validateInteger, validateObject } = require("internal/validators"); const { kWeakHandler, kResistStopPropagation } = require("internal/shared"); const { finished } = require("internal/streams/end-of-stream"); -const staticCompose = require("internal/streams/compose"); -const { addAbortSignalNoValidate } = require("internal/streams/add-abort-signal"); -const { isWritable, isNodeStream } = require("internal/streams/utils"); const MathFloor = Math.floor; const PromiseResolve = Promise.$resolve.bind(Promise); @@ -18,32 +15,8 @@ const ObjectDefineProperty = Object.defineProperty; const kEmpty = Symbol("kEmpty"); const kEof = Symbol("kEof"); -function compose(stream, options) { - if (options != null) { - validateObject(options, "options"); - } - if (options?.signal != null) { - validateAbortSignal(options.signal, "options.signal"); - } - - if (isNodeStream(stream) && !isWritable(stream)) { - throw $ERR_INVALID_ARG_VALUE("stream", stream, "must be writable"); - } - - const composedStream = staticCompose(this, stream); - - if (options?.signal) { - // Not validating as we already validated before - addAbortSignalNoValidate(options.signal, composedStream); - } - - return composedStream; -} - function map(fn, options) { - if (typeof fn !== "function") { - throw $ERR_INVALID_ARG_TYPE("fn", ["Function", "AsyncFunction"], fn); - } + validateFunction(fn, "fn"); if (options != null) { validateObject(options, "options"); } @@ -192,9 +165,7 @@ async function some(fn, options = undefined) { } async function every(fn, options = undefined) { - if (typeof fn !== "function") { - throw $ERR_INVALID_ARG_TYPE("fn", ["Function", "AsyncFunction"], fn); - } + validateFunction(fn, "fn"); // https://en.wikipedia.org/wiki/De_Morgan's_laws return !(await some.$call( this, @@ -213,9 +184,7 @@ async function find(fn, options) { } async function forEach(fn, options) { - if (typeof fn !== "function") { - throw $ERR_INVALID_ARG_TYPE("fn", ["Function", "AsyncFunction"], fn); - } + validateFunction(fn, "fn"); async function forEachFn(value, options) { await fn(value, options); return kEmpty; @@ -225,9 +194,7 @@ async function forEach(fn, options) { } function filter(fn, options) { - if (typeof fn !== "function") { - throw $ERR_INVALID_ARG_TYPE("fn", ["Function", "AsyncFunction"], fn); - } + validateFunction(fn, "fn"); async function filterFn(value, options) { if (await fn(value, options)) { return value; @@ -248,9 +215,7 @@ class ReduceAwareErrMissingArgs extends TypeError { } async function reduce(reducer, initialValue, options) { - if (typeof reducer !== "function") { - throw $ERR_INVALID_ARG_TYPE("reducer", ["Function", "AsyncFunction"], reducer); - } + validateFunction(reducer, "reducer"); if (options != null) { validateObject(options, "options"); } @@ -397,7 +362,6 @@ export default { flatMap, map, take, - compose, }, promiseReturningOperators: { every, diff --git a/src/js/internal/streams/pipeline.ts b/src/js/internal/streams/pipeline.ts index c771436558a9..8d51817773e4 100644 --- a/src/js/internal/streams/pipeline.ts +++ b/src/js/internal/streams/pipeline.ts @@ -207,7 +207,7 @@ function pipelineImpl(streams, callback, opts?) { } function finishImpl(err, final?) { - if (err && (!error || error.code === "ERR_STREAM_PREMATURE_CLOSE")) { + if (err && (!error || error.code === "ERR_STREAM_PREMATURE_CLOSE" || error.name === "AbortError")) { error = err; } diff --git a/src/js/internal/streams/readable.ts b/src/js/internal/streams/readable.ts index 67fb16b260c0..b18458c4213a 100644 --- a/src/js/internal/streams/readable.ts +++ b/src/js/internal/streams/readable.ts @@ -2,7 +2,7 @@ const EE = require("node:events"); const { Stream, prependListener } = require("internal/streams/legacy"); -const { addAbortSignal } = require("internal/streams/add-abort-signal"); +const { addAbortSignal, addAbortSignalNoValidate } = require("internal/streams/add-abort-signal"); const eos = require("internal/streams/end-of-stream"); const destroyImpl = require("internal/streams/destroy"); const { getHighWaterMark, getDefaultHighWaterMark } = require("internal/streams/state"); @@ -21,7 +21,7 @@ const { kConstructed, } = require("internal/streams/utils"); const { aggregateTwoErrors } = require("internal/errors"); -const { validateObject } = require("internal/validators"); +const { validateAbortSignal, validateObject } = require("internal/validators"); const { StringDecoder } = require("node:string_decoder"); const from = require("internal/streams/from"); const { SafeSet } = require("internal/primordials"); @@ -561,8 +561,12 @@ function howMuchToRead(n, state) { if (n <= 0 || (state.length === 0 && (state[kState] & kEnded) !== 0)) return 0; if ((state[kState] & kObjectMode) !== 0) return 1; if (NumberIsNaN(n)) { + // Fast path for buffers. + if ((state[kState] & kDecoder) === 0 && state.length) return state.buffer[state.bufferIndex].length; + // Only flow one buffer at a time. if ((state[kState] & kFlowing) !== 0 && state.length) return state.buffer[state.bufferIndex].length; + return state.length; } if (n <= state.length) return n; @@ -768,7 +772,7 @@ function emitReadable_(stream) { // However, if we're not ended, or reading, and the length < hwm, // then go ahead and try to read some more preemptively. function maybeReadMore(stream, state) { - if ((state[kState] & (kReadingMore | kConstructed)) === kConstructed) { + if ((state[kState] & (kReadingMore | kReading | kConstructed)) === kConstructed) { state[kState] |= kReadingMore; process.nextTick(maybeReadMore_, stream, state); } @@ -1128,6 +1132,13 @@ function nReadingNextTick(self) { // If the user uses them, then switch into old mode. Readable.prototype.resume = function () { const state = this._readableState; + // Deliberate divergence from Node 26: upstream early-returns here (and in + // pause()) when the stream is destroyed. Legacy Readable subclasses like + // fd-slicer assign `this.destroyed = true` (the prototype setter) right + // before push(null), so with the guard a piped destination's drain can no + // longer resume the source and the final buffered chunk is never delivered — + // silently truncating yauzl/extract-zip/puppeteer downloads. Keep the + // Node 24 behavior of letting destroyed streams flush their buffer. if ((state[kState] & kFlowing) === 0) { $debug("resume"); // We flow only if there is no one listening @@ -1167,6 +1178,7 @@ function resume_(stream, state) { Readable.prototype.pause = function () { const state = this._readableState; + // No destroyed early-return: see the comment in resume() above. $debug("call pause"); if ((state[kState] & (kHasFlowing | kFlowing)) !== kHasFlowing) { $debug("pause"); @@ -1247,6 +1259,27 @@ Readable.prototype.iterator = function (options) { return streamToAsyncIterator(this, options); }; +let composeImpl; + +Readable.prototype.compose = function compose(stream, options) { + if (options != null) { + validateObject(options, "options"); + } + if (options?.signal != null) { + validateAbortSignal(options.signal, "options.signal"); + } + + composeImpl ??= require("internal/streams/compose"); + const composedStream = composeImpl(this, stream); + + if (options?.signal) { + // Not validating as we already validated before + addAbortSignalNoValidate(options.signal, composedStream); + } + + return composedStream; +}; + function streamToAsyncIterator(stream, options?) { if (typeof stream.read !== "function") { stream = Readable.wrap(stream, { objectMode: true }); @@ -1528,7 +1561,7 @@ function fromList(n, state) { n -= str.length; buf[idx++] = null; } else { - if (n === buf.length) { + if (n === str.length) { ret += str; buf[idx++] = null; } else { diff --git a/src/js/internal/streams/writable.ts b/src/js/internal/streams/writable.ts index 05bc6c594aa5..af5d7b7d4f52 100644 --- a/src/js/internal/streams/writable.ts +++ b/src/js/internal/streams/writable.ts @@ -431,6 +431,9 @@ function _write(stream, chunk, encoding, cb?) { } if (typeof chunk === "string") { + if (encoding === "buffer") { + throw $ERR_UNKNOWN_ENCODING(encoding); + } if ((state[kState] & kDecodeStrings) !== 0) { chunk = Buffer.from(chunk, encoding); encoding = "buffer"; diff --git a/src/js/internal/webstreams_adapters.ts b/src/js/internal/webstreams_adapters.ts index 86bc91551f7b..1772e726ead5 100644 --- a/src/js/internal/webstreams_adapters.ts +++ b/src/js/internal/webstreams_adapters.ts @@ -1,7 +1,7 @@ "use strict"; const { - SafePromiseAll, + SafePromiseAllReturnVoid, SafeSet, TypedArrayPrototypeGetBuffer, TypedArrayPrototypeGetByteOffset, @@ -14,14 +14,18 @@ const Duplex = require("internal/streams/duplex"); const { destroyer } = require("internal/streams/destroy"); const { isDestroyed, isReadable, isWritable, isWritableEnded } = require("internal/streams/utils"); const { kEmptyObject } = require("internal/shared"); -const { validateBoolean, validateObject } = require("internal/validators"); -const finished = require("internal/streams/end-of-stream"); +const { validateBoolean, validateObject, validateOneOf } = require("internal/validators"); +const { isAnyArrayBuffer } = require("node:util/types"); +const eos = require("internal/streams/end-of-stream"); +const { kEosNodeSynchronousCallback } = eos; const normalizeEncoding = $newZigFunction("node_util_binding.zig", "normalizeEncoding", 1); const ArrayPrototypeFilter = Array.prototype.filter; const ArrayPrototypeMap = Array.prototype.map; const ObjectEntries = Object.entries; +const ObjectDefineProperty = Object.defineProperty; +const StringPrototypeStartsWith = String.prototype.startsWith; const PromiseWithResolvers = Promise.withResolvers.bind(Promise); const PromiseResolve = Promise.$resolve.bind(Promise); const PromisePrototypeThen = $Promise.prototype.$then; @@ -29,6 +33,9 @@ const SafePromisePrototypeFinally = $Promise.prototype.finally; const constants_zlib = $processBindingConstants.zlib; +const kValidateChunk = Symbol("kValidateChunk"); +const kDestroyOnSyncError = Symbol("kDestroyOnSyncError"); + function tryTransferToNativeReadable(stream, options) { const ptr = stream.$bunNativePtr; if (!ptr || ptr === -1) { @@ -172,13 +179,42 @@ const ZLIB_FAILURES: Set = new SafeSet([ ]); function handleKnownInternalErrors(cause: Error | null): Error | null { + const causeCode = cause?.code; switch (true) { - case cause?.code === "ERR_STREAM_PREMATURE_CLOSE": { + case causeCode === "ERR_STREAM_PREMATURE_CLOSE": { return $makeAbortError(undefined, { cause }); } - case ZLIB_FAILURES.has(cause?.code): { - const error = new TypeError(undefined, { cause }); - error.code = cause.code; + case ZLIB_FAILURES.has(causeCode): + // Brotli decoder errors carry the BrotliDecoderErrorString() name. In + // Node these are formatted as 'ERR_' + '_ERROR_...' (= 'ERR__ERROR_*'); + // Bun's native brotli formats them as 'ERR_BROTLI_DECODER_' + + // 'ERROR_...' (= 'ERR_BROTLI_DECODER_ERROR_*'). Match both shapes. + // Falls through + case causeCode != null && + (StringPrototypeStartsWith.$call(causeCode, "ERR__ERROR_") || + StringPrototypeStartsWith.$call(causeCode, "ERR_BROTLI_DECODER_ERROR_")): { + // Upstream uses `new TypeError(undefined, { cause })`, but the builtins + // codegen rewrites `new TypeError` to $makeTypeError, which only accepts + // a message and silently drops the options bag. Pass an explicit empty + // message (matching the `undefined` message upstream produces) and + // define `cause` manually with the same attributes + // `new Error(msg, { cause })` would produce: own, writable, + // configurable, non-enumerable. + const error = new TypeError(""); + ObjectDefineProperty(error, "cause", { + __proto__: null, + configurable: true, + enumerable: false, + value: cause, + writable: true, + }); + ObjectDefineProperty(error, "code", { + __proto__: null, + configurable: true, + enumerable: true, + value: causeCode, + writable: true, + }); return error; } default: @@ -186,7 +222,9 @@ function handleKnownInternalErrors(cause: Error | null): Error | null { } } -function newWritableStreamFromStreamWritable(streamWritable) { +const noop = () => {}; + +function newWritableStreamFromStreamWritable(streamWritable, options = kEmptyObject) { // Not using the internal/streams/utils isWritableNodeStream utility // here because it will return false if streamWritable is a Duplex // whose writable option is false. For a Duplex that is not writable, @@ -215,7 +253,7 @@ function newWritableStreamFromStreamWritable(streamWritable) { if (backpressurePromise !== undefined) backpressurePromise.resolve(); } - const cleanup = finished(streamWritable, error => { + const cleanup = eos(streamWritable, error => { error = handleKnownInternalErrors(error); cleanup(); @@ -254,11 +292,31 @@ function newWritableStreamFromStreamWritable(streamWritable) { }, write(chunk) { - if (streamWritable.writableNeedDrain || !streamWritable.write(chunk)) { - backpressurePromise = PromiseWithResolvers(); - return SafePromisePrototypeFinally.$call(backpressurePromise.promise, () => { - backpressurePromise = undefined; - }); + try { + options[kValidateChunk]?.(chunk); + if (!streamWritable.writableObjectMode && isAnyArrayBuffer(chunk)) { + chunk = new Uint8Array(chunk); + } + if (streamWritable.writableNeedDrain || !streamWritable.write(chunk)) { + backpressurePromise = PromiseWithResolvers(); + if (!streamWritable.writableNeedDrain) { + backpressurePromise.resolve(); + } + return SafePromisePrototypeFinally.$call(backpressurePromise.promise, () => { + backpressurePromise = undefined; + }); + } + } catch (error) { + // When the kDestroyOnSyncError flag is set (e.g. for + // CompressionStream), a sync throw must also destroy the + // stream so the readable side is errored too. Without this + // the readable side hangs forever. This replicates the + // TransformStream semantics: error both sides on any throw + // in the transform path. + if (options[kDestroyOnSyncError]) { + destroyer(streamWritable, error); + } + throw error; } }, @@ -303,9 +361,8 @@ function newStreamWritableFromWritableStream(writableStream, options = kEmptyObj writev(chunks, callback) { function done(error) { - error = error.filter(e => e); try { - callback(error.length === 0 ? undefined : error); + callback(error); } catch (error) { // In a next tick because this is happening within // a promise context, and if there are any errors @@ -320,7 +377,7 @@ function newStreamWritableFromWritableStream(writableStream, options = kEmptyObj writer.ready, () => { return PromisePrototypeThen.$call( - SafePromiseAll(chunks, data => writer.write(data.chunk)), + SafePromiseAllReturnVoid(chunks, data => writer.write(data.chunk)), done, done, ); @@ -429,6 +486,8 @@ function newStreamWritableFromWritableStream(writableStream, options = kEmptyObj return writable; } +const kErrorSentinelAttached = Symbol("kErrorSentinelAttached"); + function newReadableStreamFromStreamReadable(streamReadable, options = kEmptyObject) { // Not using the internal/streams/utils isReadableNodeStream utility // here because it will return false if streamReadable is a Duplex @@ -437,77 +496,89 @@ function newReadableStreamFromStreamReadable(streamReadable, options = kEmptyObj if (typeof streamReadable?._readableState !== "object") { throw $ERR_INVALID_ARG_TYPE("streamReadable", "stream.Readable", streamReadable); } - - if (isDestroyed(streamReadable) || !isReadable(streamReadable)) { - const readable = new ReadableStream(); - readable.cancel(); - return readable; + validateObject(options, "options"); + if (options.type !== undefined) { + validateOneOf(options.type, "options.type", ["bytes", undefined]); } - const objectMode = streamReadable.readableObjectMode; - const highWaterMark = streamReadable.readableHighWaterMark; - - const evaluateStrategyOrFallback = strategy => { - // If there is a strategy available, use it - if (strategy) return strategy; - - if (objectMode) { - // When running in objectMode explicitly but no strategy, we just fall - // back to CountQueuingStrategy - return new CountQueuingStrategy({ highWaterMark }); - } - - return new ByteLengthQueuingStrategy({ highWaterMark }); - }; - - const strategy = evaluateStrategyOrFallback(options?.strategy); - + const isBYOB = options.type === "bytes"; let controller; let wasCanceled = false; + let strategy; - function onData(chunk) { - // Copy the Buffer to detach it from the pool. - if (Buffer.isBuffer(chunk) && !objectMode) chunk = new Uint8Array(chunk); - controller.enqueue(chunk); - if (controller.desiredSize <= 0) streamReadable.pause(); - } - - streamReadable.pause(); - - const cleanup = finished(streamReadable, error => { - error = handleKnownInternalErrors(error); - - cleanup(); - // This is a protection against non-standard, legacy streams - // that happen to emit an error event again after finished is called. - streamReadable.on("error", () => {}); - if (error) return controller.error(error); - // Was already canceled - if (wasCanceled) { - return; - } - controller.close(); - }); + const underlyingSource = { + __proto__: null, + type: isBYOB ? "bytes" : undefined, + start(c) { + controller = c; + }, + cancel(reason) { + wasCanceled = true; + destroyer(streamReadable, reason); + }, + }; - streamReadable.on("data", onData); + const readable = isReadable(streamReadable); + const objectMode = streamReadable.readableObjectMode; + if (readable) { + underlyingSource.pull = function pull() { + streamReadable.resume(); + }; + + const highWaterMark = streamReadable.readableHighWaterMark; + strategy = isBYOB + ? { highWaterMark } + : (options.strategy ?? new (objectMode ? CountQueuingStrategy : ByteLengthQueuingStrategy)({ highWaterMark })); + } + const readableStream = new ReadableStream(underlyingSource, strategy); - return new ReadableStream( + // When adapting a Duplex as a ReadableStream, readable completion should not + // wait for a half-open writable side to finish as well. + let cleanup = noop; + cleanup = eos( + streamReadable, { - start(c) { - controller = c; - }, + __proto__: null, + writable: false, + [kEosNodeSynchronousCallback]: true, + }, + error => { + error = handleKnownInternalErrors(error); - pull() { - streamReadable.resume(); - }, + // If eos calls the callback synchronously, cleanup is still a no-op here. + cleanup(); - cancel(reason) { - wasCanceled = true; - destroyer(streamReadable, reason); - }, + if (!(kErrorSentinelAttached in streamReadable)) { + // This is a protection against non-standard, legacy streams + // that happen to emit an error event again after finished is called. + streamReadable.on("error", noop); + streamReadable[kErrorSentinelAttached] = true; + } + if (wasCanceled) { + return; + } + wasCanceled = true; + if (error) return controller.error(error); + controller.close(); + if (isBYOB) controller.byobRequest?.respond(0); }, - strategy, ); + + if (wasCanceled) { + // `eos` called the callback synchronously + cleanup(); + } else if (readable) { + streamReadable.pause(); + + streamReadable.on("data", function onData(chunk) { + // Copy the Buffer to detach it from the pool. + if (Buffer.isBuffer(chunk) && !objectMode) chunk = new Uint8Array(chunk); + controller.enqueue(chunk); + if (controller.desiredSize <= 0) streamReadable.pause(); + }); + } + + return readableStream; } function newStreamReadableFromReadableStream(readableStream, options: Record = kEmptyObject) { @@ -538,7 +609,19 @@ function newStreamReadableFromReadableStream(readableStream, options: Record e); try { - callback(error.length === 0 ? undefined : error); + callback(error); } catch (error) { // In a next tick because this is happening within // a promise context, and if there are any errors @@ -618,7 +723,7 @@ function newStreamDuplexFromReadableWritablePair(pair = kEmptyObject, options = writer.ready, () => { return PromisePrototypeThen.$call( - SafePromiseAll(chunks, data => writer.write(data.chunk)), + SafePromiseAllReturnVoid(chunks, data => writer.write(data.chunk)), done, done, ); @@ -718,7 +823,7 @@ function newStreamDuplexFromReadableWritablePair(pair = kEmptyObject, options = } if (!writableClosed || !readableClosed) { - PromisePrototypeThen.$call(SafePromiseAll([closeWriter(), closeReader()]), done, done); + PromisePrototypeThen.$call(SafePromiseAllReturnVoid([closeWriter(), closeReader()]), done, done); return; } @@ -754,6 +859,22 @@ function newStreamDuplexFromReadableWritablePair(pair = kEmptyObject, options = return duplex; } +// Shared by CompressionStream and DecompressionStream: per the Compression +// Streams spec, chunks must be BufferSource (ArrayBuffer or ArrayBufferView +// not backed by SharedArrayBuffer), and an invalid chunk must error both +// sides of the pair synchronously. +function newBufferSourceTransformPairFromDuplex(duplex) { + const { isArrayBufferView, isSharedArrayBuffer } = require("node:util/types"); + return newReadableWritablePairFromDuplex(duplex, { + [kValidateChunk]: function validateBufferSourceChunk(chunk) { + if (isSharedArrayBuffer(isArrayBufferView(chunk) ? chunk.buffer : chunk)) { + throw $ERR_INVALID_ARG_TYPE("chunk", ["ArrayBuffer", "Buffer", "TypedArray", "DataView"], chunk); + } + }, + [kDestroyOnSyncError]: true, + }); +} + export default { newWritableStreamFromStreamWritable, newReadableStreamFromStreamReadable, @@ -761,5 +882,8 @@ export default { newStreamReadableFromReadableStream, newReadableWritablePairFromDuplex, newStreamDuplexFromReadableWritablePair, + newBufferSourceTransformPairFromDuplex, + kValidateChunk, + kDestroyOnSyncError, _ReadableFromWeb: ReadableFromWeb, }; diff --git a/src/js/node/_http_common.ts b/src/js/node/_http_common.ts index de6652939953..1a5256a09060 100644 --- a/src/js/node/_http_common.ts +++ b/src/js/node/_http_common.ts @@ -59,8 +59,11 @@ const MAX_HEADER_PAIRS = 2000; // called to process trailing HTTP headers. function parserOnHeaders(headers, url) { // Once we exceeded headers limit - stop collecting them - if (this.maxHeaderPairs <= 0 || this._headers.length < this.maxHeaderPairs) { + const capacity = this.maxHeaderPairs - this._headers.length; + if (this.maxHeaderPairs <= 0 || capacity >= headers.length) { this._headers.push(...headers); + } else if (capacity > 0) { + this._headers.push(...headers.slice(0, capacity)); } this._url += url; } @@ -185,8 +188,8 @@ function closeParserInstance(parser) { function freeParser(parser, req, socket) { if (parser) { if (parser._consumed) parser.unconsume(); - cleanParser(parser); parser.remove(); + cleanParser(parser); if (parsers.free(parser) === false) { // Make sure the parser's stack has unwound before deleting the // corresponding C++ object through .close(). diff --git a/src/js/node/_http_outgoing.ts b/src/js/node/_http_outgoing.ts index 4ae81798e034..632edfbe4d4e 100644 --- a/src/js/node/_http_outgoing.ts +++ b/src/js/node/_http_outgoing.ts @@ -27,6 +27,10 @@ const { _checkInvalidHeaderChar: checkInvalidHeaderChar, } = require("node:_http_common"); const kUniqueHeaders = Symbol("kUniqueHeaders"); +// Tracks setHeader("set-cookie", []): the FetchHeaders backing store cannot +// represent a present-but-empty set-cookie header, but Node keeps the raw [] +// and returns it from getHeader (nodejs/node#59734). +const kEmptySetCookie = Symbol("kEmptySetCookie"); const kBytesWritten = Symbol("kBytesWritten"); const kRejectNonStandardBodyWrites = Symbol("kRejectNonStandardBodyWrites"); const kCorked = Symbol("corked"); @@ -190,6 +194,8 @@ function OutgoingMessage(options) { this._closed = false; this._header = null; this._headerSent = false; + this.outputData = []; + this.outputSize = 0; this[kHighWaterMark] = options?.highWaterMark ?? (process.platform === "win32" ? 16 * 1024 : 64 * 1024); } const OutgoingMessagePrototype = { @@ -202,7 +208,10 @@ const OutgoingMessagePrototype = { shouldKeepAlive: true, _onPendingData: function nop() {}, outputSize: 0, - outputData: [], + // No outputData default on the prototype (a shared array would leak buffered + // writes across instances, and a lazy accessor would self-destruct when read + // directly off the prototype). The constructor creates the per-instance + // array; methods lazily init for subclasses that don't chain the constructor. strictContentLength: false, _removedTE: false, _removedContLen: false, @@ -212,6 +221,10 @@ const OutgoingMessagePrototype = { _headerNames: undefined, appendHeader(name, value) { validateString(name, "name"); + if (this[kEmptySetCookie] && name.length === 10 && name.toLowerCase() === "set-cookie") { + // An appended cookie supersedes the present-but-empty marker. + this[kEmptySetCookie] = false; + } var headers = (this[headersSymbol] ??= new Headers()); headers.append(name, value); return this; @@ -223,7 +236,11 @@ const OutgoingMessagePrototype = { flushHeaders() {}, getHeader(name) { validateString(name, "name"); - return getHeader(this[headersSymbol], name); + const value = getHeader(this[headersSymbol], name); + if (value === undefined && this[kEmptySetCookie] && name.toLowerCase() === "set-cookie") { + return []; + } + return value; }, // Overridden by ClientRequest and ServerResponse; this version will be called only if the user constructs OutgoingMessage directly. @@ -244,24 +261,40 @@ const OutgoingMessagePrototype = { getHeaderNames() { var headers = this[headersSymbol]; if (!headers) return []; - return Array.from(headers.keys()); + const names = Array.from(headers.keys()); + if (this[kEmptySetCookie] && !names.includes("set-cookie")) { + names.push("set-cookie"); + } + return names; }, getRawHeaderNames() { var headers = this[headersSymbol]; - if (!headers) return []; - return getRawKeys.$call(headers); + const emptySetCookie = this[kEmptySetCookie]; + if (!headers) return emptySetCookie ? [emptySetCookie] : []; + const names = getRawKeys.$call(headers); + if (emptySetCookie && !names.some(name => typeof name === "string" && name.toLowerCase() === "set-cookie")) { + names.push(emptySetCookie); + } + return names; }, getHeaders() { const headers = this[headersSymbol]; if (!headers) return kEmptyObject; - return headers.toJSON(); + const json = headers.toJSON(); + if (this[kEmptySetCookie] && json["set-cookie"] === undefined) { + json["set-cookie"] = []; + } + return json; }, removeHeader(name) { validateString(name, "name"); throwHeadersSentIfNecessary(this, "remove"); + if (this[kEmptySetCookie] && name.toLowerCase() === "set-cookie") { + this[kEmptySetCookie] = false; + } const headers = this[headersSymbol]; if (!headers) return; headers.delete(name); @@ -272,6 +305,17 @@ const OutgoingMessagePrototype = { validateHeaderName(name); validateHeaderValue(name, value); const headers = (this[headersSymbol] ??= new Headers()); + if (name.length === 10 && name.toLowerCase() === "set-cookie") { + if ($isArray(value) && value.length === 0) { + // Present-but-empty: nothing to store in the backing Headers (and + // nothing goes on the wire), but getHeader must return []. + headers.delete(name); + // Remember the original-case name so getRawHeaderNames can report it. + this[kEmptySetCookie] = name; + return this; + } + this[kEmptySetCookie] = false; + } setHeader(headers, name, value); return this; }, @@ -288,20 +332,22 @@ const OutgoingMessagePrototype = { // We also cannot safely split by comma. // To avoid setHeader overwriting the previous value we push // set-cookie values in array and set them all at once. - const cookies = []; + let cookies = null; for (const { 0: key, 1: value } of headers) { if (key === "set-cookie") { if ($isArray(value)) { + cookies ??= []; cookies.push(...value); } else { + cookies ??= []; cookies.push(value); } continue; } this.setHeader(key, value); } - if (cookies.length) { + if (cookies != null) { this.setHeader("set-cookie", cookies); } @@ -309,6 +355,7 @@ const OutgoingMessagePrototype = { }, hasHeader(name) { validateString(name, "name"); + if (this[kEmptySetCookie] && name.toLowerCase() === "set-cookie") return true; const headers = this[headersSymbol]; if (!headers) return false; return headers.has(name); @@ -316,10 +363,17 @@ const OutgoingMessagePrototype = { get headers() { const headers = this[headersSymbol]; - if (!headers) return kEmptyObject; - return headers.toJSON(); + if (!headers) return this[kEmptySetCookie] ? { "set-cookie": [] } : kEmptyObject; + const json = headers.toJSON(); + if (this[kEmptySetCookie] && json["set-cookie"] === undefined) { + json["set-cookie"] = []; + } + return json; }, set headers(value) { + // Replacing the whole header bag drops the present-but-empty set-cookie + // marker; the new Headers determines set-cookie state from here on. + this[kEmptySetCookie] = false; this[headersSymbol] = new Headers(value); }, @@ -448,7 +502,7 @@ const OutgoingMessagePrototype = { data = this._header + data; } else { const header = this._header; - this.outputData.unshift({ + (this.outputData ??= []).unshift({ data: header, encoding: "latin1", callback: null, @@ -475,18 +529,41 @@ const OutgoingMessagePrototype = { if (conn && conn._httpMessage === this && conn.writable) { // There might be pending data in the this.output buffer. - if (this.outputData.length) { + if (this.outputData?.length) { this._flushOutput(conn); } // Directly write to socket. return conn.write(data, encoding, callback); } // Buffer, as long as we're not destroyed. - this.outputData.push({ data, encoding, callback }); + (this.outputData ??= []).push({ data, encoding, callback }); this.outputSize += data.length; this._onPendingData(data.length); return this.outputSize < this[kHighWaterMark]; }, + _flushOutput(socket) { + const outputLength = this.outputData?.length ?? 0; + if (outputLength <= 0) return undefined; + + const outputData = this.outputData; + socket.cork(); + let ret; + // Retain for(;;) loop for performance reasons + // Refs: https://github.com/nodejs/node/pull/30958 + for (let i = 0; i < outputLength; i++) { + const { data, encoding, callback } = outputData[i]; + // Avoid any potential ref to Buffer in new generation from old generation + outputData[i].data = null; + ret = socket.write(data, encoding, callback); + } + socket.uncork(); + + this.outputData = []; + this._onPendingData(-this.outputSize); + this.outputSize = 0; + + return ret; + }, end(_chunk, _encoding, _callback) { return this; diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index c7c54c5582ca..05f41e3ad120 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -600,7 +600,10 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort } socket[kRequest] = http_req; - const is_upgrade = http_req.headers.upgrade; + // Like Node.js, only treat this as an upgrade when there is an + // 'upgrade' listener; otherwise the request falls through to the + // regular 'request' event. + const is_upgrade = !!http_req.headers.upgrade && server.listenerCount("upgrade") > 0; if (!is_upgrade) { if (canUseInternalAssignSocket) { // ~10% performance improvement in JavaScriptCore due to avoiding .once("close", ...) and removing a listener @@ -1524,8 +1527,6 @@ ServerResponse.prototype.write = function (chunk, encoding, callback) { if (callback) { process.nextTick(callback); } - this.emit("drain"); - return true; }; @@ -1820,8 +1821,6 @@ function ServerResponse_finalDeprecated(chunk, encoding, callback) { // ServerResponse.prototype._final = ServerResponse_finalDeprecated; -ServerResponse.prototype.writeHeader = ServerResponse.prototype.writeHead; - OriginalWriteHeadFn = ServerResponse.prototype.writeHead; OriginalImplicitHeadFn = ServerResponse.prototype._implicitHeader; diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 19afd513bb63..2173b9301df7 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -138,7 +138,7 @@ function validateSettings(settings: any) { if (settings.initialWindowSize !== undefined) { const v = settings.initialWindowSize; - if (typeof v !== "number" || v < 0 || v > kMaxInt || Number.isNaN(v)) { + if (typeof v !== "number" || v < 0 || v > kMaxWindowSize || Number.isNaN(v)) { throwSettingRangeError("initialWindowSize", v); } } @@ -352,6 +352,7 @@ const { validateInt32, validateBuffer, validateNumber, + validateAbortSignal, } = require("internal/validators"); let utcCache; @@ -380,6 +381,7 @@ function emitErrorNT(self: any, error: any, destroy: boolean) { function emitOutofStreamErrorNT(self: any) { self.destroy($ERR_HTTP2_OUT_OF_STREAMS()); } + function cache() { const d = new Date(); utcCache = d.toUTCString(); @@ -2478,7 +2480,7 @@ class ServerHttp2Stream extends Http2Stream { if (headers == undefined) { headers = {}; - } else if (!$isObject(headers)) { + } else if (!$isObject(headers) || $isArray(headers)) { throw $ERR_INVALID_ARG_TYPE("headers", "object", headers); } else { headers = { ...headers }; @@ -2519,7 +2521,7 @@ class ServerHttp2Stream extends Http2Stream { if (headers == undefined) { headers = {}; - } else if (!$isObject(headers)) { + } else if (!$isObject(headers) || $isArray(headers)) { throw $ERR_INVALID_ARG_TYPE("headers", "object", headers); } else { headers = { ...headers }; @@ -2638,7 +2640,10 @@ class ServerHttp2Stream extends Http2Stream { if (headers == undefined) { headers = {}; - } else if (!$isObject(headers)) { + } else if (!$isObject(headers) || $isArray(headers)) { + // TODO: support the v26 raw-headers array form ([name1, value1, name2, value2, ...]). + // Until then, reject arrays instead of spreading them into numeric-string keys + // and sending garbage header frames. throw $ERR_INVALID_ARG_TYPE("headers", "object", headers); } else { headers = { ...headers }; @@ -3010,11 +3015,17 @@ class ServerHttp2Session extends Http2Session { }, goaway(self: ServerHttp2Session, errorCode: number, lastStreamId: number, opaqueData: Buffer) { if (!self) return; + if (self.destroyed) return; self.emit("goaway", errorCode, lastStreamId, opaqueData || Buffer.allocUnsafe(0)); - if (errorCode !== 0) { - self.#parser.emitErrorToAllStreams(errorCode); + if (errorCode === NGHTTP2_NO_ERROR) { + // Graceful shutdown: no new streams, existing ones may finish. + self.close(); + } else { + self.#parser?.emitErrorToAllStreams(errorCode); + // Like Node, destroy with an error but send our own goaway with + // NGHTTP2_NO_ERROR since this side had no error. + self.destroy($ERR_HTTP2_SESSION_ERROR(errorCode), NGHTTP2_NO_ERROR); } - self.close(); }, end(self: ServerHttp2Session, errorCode: number, lastStreamId: number, opaqueData: Buffer) { if (!self) return; @@ -3037,10 +3048,18 @@ class ServerHttp2Session extends Http2Session { const parser = this.#parser; if (parser) { parser.emitAbortToAllStreams(); + parser.forEachStream(streamSocketClosed); parser.detach(); this.#parser = null; } + // Like Node's socketOnClose, a dead socket always tears the session down + // (close() followed by closeSession() upstream). close() alone is not + // enough: it early-returns once a received GOAWAY has already marked the + // session closed, and the destroy it deferred to the last stream's close + // never comes once the peer is gone — leaving the session (and the + // server's open-connection count) alive forever. this.close(); + this.destroy(); } #onError(error: Error) { this.destroy(error); @@ -3304,11 +3323,16 @@ class ServerHttp2Session extends Http2Session { // Gracefully closes the Http2Session, allowing any existing streams to complete on their own and preventing new Http2Stream instances from being created. Once closed, http2session.destroy() might be called if there are no open Http2Stream instances. // If specified, the callback function is registered as a handler for the 'close' event. close(callback?: Function) { + if (this.closed || this.destroyed) return; this.#closed = true; if (typeof callback === "function") { - this.on("close", callback); + this.once("close", callback); } + // Like Node, a graceful close sends GOAWAY immediately so the peer stops + // routing new work to this session; the session is destroyed once the + // existing streams finish. + this.goaway(); if (this.#connections === 0) { this.destroy(); } @@ -3352,6 +3376,17 @@ function emitTimeout(session: ClientHttp2Session) { function streamCancel(stream: Http2Stream) { stream.close(NGHTTP2_CANCEL); } + +// After the socket is gone a graceful close can never complete — the parser +// is detached, so the stream's writable side has nothing left to flush +// through and 'finish'/'close' would never fire. Mirror Node's closeSession, +// which hard-destroys every stream that is still alive after the +// close(NGHTTP2_CANCEL) pass. +function streamSocketClosed(stream: Http2Stream) { + if (!stream.destroyed) { + stream.destroy(); + } +} class ClientHttp2Session extends Http2Session { /// close indicates that we called closed #closed: boolean = false; @@ -3522,9 +3557,19 @@ class ClientHttp2Session extends Http2Session { }, goaway(self: ClientHttp2Session, errorCode: number, lastStreamId: number, opaqueData: Buffer) { if (!self) return; + if (self.destroyed) return; self.emit("goaway", errorCode, lastStreamId, opaqueData || Buffer.allocUnsafe(0)); - if (self.closed) return; - self.destroy(undefined, errorCode); + if (errorCode === NGHTTP2_NO_ERROR) { + // A no-error GOAWAY begins a graceful shutdown: no new streams + // permitted (request() throws ERR_HTTP2_GOAWAY_SESSION while the + // session is closed-but-not-destroyed), but existing streams may + // finish naturally. + self.close(); + } else { + // Mirror Node: destroy immediately with an error, but send our own + // goaway with NGHTTP2_NO_ERROR since this side had no error. + self.destroy($ERR_HTTP2_SESSION_ERROR(errorCode), NGHTTP2_NO_ERROR); + } }, end(self: ClientHttp2Session, errorCode: number, lastStreamId: number, opaqueData: Buffer) { if (!self) return; @@ -3602,6 +3647,7 @@ class ClientHttp2Session extends Http2Session { const err = this.connecting ? $ERR_SOCKET_CLOSED() : null; if (parser) { parser.forEachStream(streamCancel); + parser.forEachStream(streamSocketClosed); parser.detach(); this.#parser = null; } @@ -3712,7 +3758,7 @@ class ClientHttp2Session extends Http2Session { parser.ping(payload); return true; } - goaway(errorCode, lastStreamId, opaqueData) { + goaway(errorCode = NGHTTP2_NO_ERROR, lastStreamId = 0, opaqueData) { return this.#parser?.goaway(errorCode, lastStreamId, opaqueData); } @@ -3861,11 +3907,15 @@ class ClientHttp2Session extends Http2Session { // Gracefully closes the Http2Session, allowing any existing streams to complete on their own and preventing new Http2Stream instances from being created. Once closed, http2session.destroy() might be called if there are no open Http2Stream instances. // If specified, the callback function is registered as a handler for the 'close' event. close(callback: Function) { + if (this.closed || this.destroyed) return; this.#closed = true; if (typeof callback === "function") { this.once("close", callback); } + // Like Node, a graceful close sends GOAWAY immediately so the peer stops + // routing new work to this session. + this.goaway(); if (this.#connections === 0) { this.destroy(); } @@ -3898,8 +3948,11 @@ class ClientHttp2Session extends Http2Session { request(headers: any, options?: any) { try { - if (this.destroyed || this.closed) { - throw $ERR_HTTP2_INVALID_STREAM(); + if (this.destroyed) { + throw $ERR_HTTP2_INVALID_SESSION(); + } + if (this.closed) { + throw $ERR_HTTP2_GOAWAY_SESSION(); } if (this.sentTrailers) { @@ -3975,6 +4028,27 @@ class ClientHttp2Session extends Http2Session { options = { ...options, endStream: true }; } } + // Like Node, a request whose signal is already aborted never touches the + // wire: the stream is created without an id and destroyed with an + // AbortError on the next tick (_destroy skips the RST for id-less + // streams). Sending an RST for a stream the peer never saw is a + // connection error that makes conforming servers reply with GOAWAY. + if ($isObject(options) && options.signal) { + // Node validates the signal before reading .aborted: any object with an + // 'aborted' property passes (so a duck-typed { aborted: true } takes + // the abort fast path), while objects without one and non-objects + // throw ERR_INVALID_ARG_TYPE synchronously. + validateAbortSignal(options.signal, "options.signal"); + if (options.signal.aborted) { + const req = new ClientHttp2Stream(undefined, this, headers); + const signal = options.signal; + // The request never started, so the stream counts as aborted but the + // 'aborted' event is not emitted — only the AbortError. + req[kAborted] = true; + process.nextTick(() => req.destroy($makeAbortError(undefined, { cause: signal.reason }))); + return req; + } + } let stream_id: number = this.#parser.getNextStream(); if (stream_id < 0) { const req = new ClientHttp2Stream(undefined, this, headers); @@ -3992,7 +4066,10 @@ class ClientHttp2Session extends Http2Session { process.nextTick(emitEventNT, req, "ready"); return req; } catch (e: any) { - this.#connections--; + // #connections is incremented by the parser's streamStart callback, which + // never ran for a request that threw during validation — decrementing here + // would drive the counter negative and stop a closing session from ever + // reaching the #connections === 0 destroy. process.nextTick(emitErrorNT, this, e, this.#connections === 0 && this.#closed); throw e; } @@ -4109,14 +4186,14 @@ function initializeOptions(options) { } if (options.maxSessionInvalidFrames !== undefined) - validateUint32(options.maxSessionInvalidFrames, "maxSessionInvalidFrames"); + validateUint32(options.maxSessionInvalidFrames, "options.maxSessionInvalidFrames"); if (options.maxSessionRejectedStreams !== undefined) { - validateUint32(options.maxSessionRejectedStreams, "maxSessionRejectedStreams"); + validateUint32(options.maxSessionRejectedStreams, "options.maxSessionRejectedStreams"); } if (options.unknownProtocolTimeout !== undefined) - validateUint32(options.unknownProtocolTimeout, "unknownProtocolTimeout"); + validateUint32(options.unknownProtocolTimeout, "options.unknownProtocolTimeout"); else options.unknownProtocolTimeout = 10000; // Used only with allowHTTP1 @@ -4237,10 +4314,10 @@ class Http2SecureServer extends tls.Server { validateObject(settings, "options.settings"); } if (options.maxSessionInvalidFrames !== undefined) - validateUint32(options.maxSessionInvalidFrames, "maxSessionInvalidFrames"); + validateUint32(options.maxSessionInvalidFrames, "options.maxSessionInvalidFrames"); if (options.maxSessionRejectedStreams !== undefined) { - validateUint32(options.maxSessionRejectedStreams, "maxSessionRejectedStreams"); + validateUint32(options.maxSessionRejectedStreams, "options.maxSessionRejectedStreams"); } super(options, connectionListener); this[kSessions] = new SafeSet(); diff --git a/src/js/node/https.ts b/src/js/node/https.ts index f140f4583968..3a2c0eda82fb 100644 --- a/src/js/node/https.ts +++ b/src/js/node/https.ts @@ -35,14 +35,39 @@ function get(input, options, cb) { function Agent(options) { if (!(this instanceof Agent)) return new Agent(options); + options = { __proto__: null, ...options }; + options.defaultPort ??= 443; + options.protocol ??= "https:"; http.Agent.$apply(this, [options]); - this.defaultPort = 443; - this.protocol = "https:"; + this.maxCachedSessions = this.options.maxCachedSessions; if (this.maxCachedSessions === undefined) this.maxCachedSessions = 100; } $toClass(Agent, "Agent", http.Agent); -Agent.prototype.createConnection = http.createConnection; +Agent.prototype.createConnection = function createConnection(...args) { + // XXX: This signature (port, host, options) is different from all the other + // createConnection() methods. + let options; + if (args[0] !== null && typeof args[0] === "object") { + options = args[0]; + } else if (args[1] !== null && typeof args[1] === "object") { + options = { ...args[1] }; + } else if (args[2] === null || typeof args[2] !== "object") { + options = {}; + } else { + options = { ...args[2] }; + } + + if (typeof args[0] === "number") { + options.port = args[0]; + } + + if (typeof args[1] === "string") { + options.host = args[1]; + } + + return require("node:tls").connect(options); +}; var https = { Agent, diff --git a/src/jsc/ErrorCode.rs b/src/jsc/ErrorCode.rs index 468a3ea5b9fd..3be6659c1881 100644 --- a/src/jsc/ErrorCode.rs +++ b/src/jsc/ErrorCode.rs @@ -688,8 +688,11 @@ impl ErrorCode { /// `ERR_MYSQL_CONNECTION_REFUSED` (instanceof Error) pub const MYSQL_CONNECTION_REFUSED: ErrorCode = ErrorCode(315); + /// `ERR_HTTP2_GOAWAY_SESSION` + pub const HTTP2_GOAWAY_SESSION: ErrorCode = ErrorCode(316); + /// == C++ `NODE_ERROR_COUNT`. - pub const COUNT: u16 = 316; + pub const COUNT: u16 = 317; } // ────────────────────────────────────────────────────────────────────────── @@ -1048,6 +1051,7 @@ impl ErrorCode { ErrorCode::SECRETS_INTERACTION_NOT_ALLOWED; pub const ERR_SECRETS_AUTH_FAILED: ErrorCode = ErrorCode::SECRETS_AUTH_FAILED; pub const ERR_SECRETS_INTERACTION_REQUIRED: ErrorCode = ErrorCode::SECRETS_INTERACTION_REQUIRED; + pub const ERR_HTTP2_GOAWAY_SESSION: ErrorCode = ErrorCode::HTTP2_GOAWAY_SESSION; // NOTE: `ERR_SYSTEM_ERROR` / `ERR_CHILD_CLOSED_BEFORE_REPLY` intentionally // do NOT live here. They belong to the unrelated enum @@ -1381,6 +1385,7 @@ static CODE_STR: [&str; ErrorCode::COUNT as usize] = [ "ERR_MYSQL_CONNECTION_FAILED", "ERR_POSTGRES_CONNECTION_REFUSED", "ERR_MYSQL_CONNECTION_REFUSED", + "ERR_HTTP2_GOAWAY_SESSION", ]; // ────────────────────────────────────────────────────────────────────────── diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 86a9622c33ac..a4c96685d46b 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -244,7 +244,7 @@ static JSValue constructVersions(VM& vm, JSObject* processObject) // Use commit hash for zstd (semantic version extraction not working yet) object->putDirect(vm, JSC::Identifier::fromString(vm, "zstd"_s), JSC::jsOwnedString(vm, ASCIILiteral::fromLiteralUnsafe(BUN_VERSION_ZSTD_HASH)), 0); - object->putDirect(vm, JSC::Identifier::fromString(vm, "v8"_s), JSValue(JSC::jsOwnedString(vm, String("13.6.233.10-node.18"_s))), 0); + object->putDirect(vm, JSC::Identifier::fromString(vm, "v8"_s), JSValue(JSC::jsOwnedString(vm, String(ASCIILiteral::fromLiteralUnsafe(REPORTED_NODEJS_V8_VERSION)))), 0); #if OS(WINDOWS) object->putDirect(vm, JSC::Identifier::fromString(vm, "uv"_s), JSValue(JSC::jsOwnedString(vm, String::fromLatin1(uv_version_string()))), 0); #else @@ -2495,6 +2495,10 @@ static JSValue constructProcessConfigObject(VM& vm, JSObject* processObject) JSC::JSObject* variables = JSC::constructEmptyObject(globalObject, globalObject->objectPrototype(), 2); variables->putDirect(vm, JSC::Identifier::fromString(vm, "v8_enable_i8n_support"_s), JSC::jsNumber(1), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "enable_lto"_s), JSC::jsBoolean(false), 0); + // Node 26's common.gypi evaluates enable_thin_lto/lto_jobs conditions; gyp + // hard-fails on undefined variables, so node-gyp builds need them present. + variables->putDirect(vm, JSC::Identifier::fromString(vm, "enable_thin_lto"_s), JSC::jsBoolean(false), 0); + variables->putDirect(vm, JSC::Identifier::fromString(vm, "lto_jobs"_s), JSC::jsString(vm, String(""_s)), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "node_module_version"_s), JSC::jsNumber(REPORTED_NODEJS_ABI_VERSION), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "napi_build_version"_s), JSC::jsNumber(Napi::DEFAULT_NAPI_VERSION), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "node_builtin_shareable_builtins"_s), JSC::constructEmptyArray(globalObject, nullptr), 0); @@ -2511,6 +2515,10 @@ static JSValue constructProcessConfigObject(VM& vm, JSObject* processObject) variables->putDirect(vm, JSC::Identifier::fromString(vm, "debug_nghttp2"_s), JSC::jsBoolean(false), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "debug_node"_s), JSC::jsBoolean(false), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "enable_lto"_s), JSC::jsBoolean(false), 0); + // Node 26's common.gypi evaluates enable_thin_lto/lto_jobs conditions; gyp + // hard-fails on undefined variables, so node-gyp builds need them present. + variables->putDirect(vm, JSC::Identifier::fromString(vm, "enable_thin_lto"_s), JSC::jsBoolean(false), 0); + variables->putDirect(vm, JSC::Identifier::fromString(vm, "lto_jobs"_s), JSC::jsString(vm, String(""_s)), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "enable_pgo_generate"_s), JSC::jsBoolean(false), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "enable_pgo_use"_s), JSC::jsBoolean(false), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "error_on_warn"_s), JSC::jsBoolean(false), 0); @@ -2518,12 +2526,20 @@ static JSValue constructProcessConfigObject(VM& vm, JSObject* processObject) variables->putDirect(vm, JSC::Identifier::fromString(vm, "napi_build_version"_s), JSC::jsNumber(Napi::DEFAULT_NAPI_VERSION), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "nasm_version"_s), JSC::jsNumber(2), 0); #elif OS(MACOS) + // Real Node on macOS reports clang=1; common.gypi only applies + // CLANG_CXX_LANGUAGE_STANDARD (gnu++20) to addon builds when clang==1, + // and Apple clang's default standard is far older. + variables->putDirect(vm, JSC::Identifier::fromString(vm, "clang"_s), JSC::jsNumber(1), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "control_flow_guard"_s), JSC::jsBoolean(false), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "coverage"_s), JSC::jsBoolean(false), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "dcheck_always_on"_s), JSC::jsNumber(0), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "debug_nghttp2"_s), JSC::jsBoolean(false), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "debug_node"_s), JSC::jsBoolean(false), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "enable_lto"_s), JSC::jsBoolean(false), 0); + // Node 26's common.gypi evaluates enable_thin_lto/lto_jobs conditions; gyp + // hard-fails on undefined variables, so node-gyp builds need them present. + variables->putDirect(vm, JSC::Identifier::fromString(vm, "enable_thin_lto"_s), JSC::jsBoolean(false), 0); + variables->putDirect(vm, JSC::Identifier::fromString(vm, "lto_jobs"_s), JSC::jsString(vm, String(""_s)), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "enable_pgo_generate"_s), JSC::jsBoolean(false), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "enable_pgo_use"_s), JSC::jsBoolean(false), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "error_on_warn"_s), JSC::jsBoolean(false), 0); @@ -2538,6 +2554,10 @@ static JSValue constructProcessConfigObject(VM& vm, JSObject* processObject) variables->putDirect(vm, JSC::Identifier::fromString(vm, "debug_nghttp2"_s), JSC::jsBoolean(false), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "debug_node"_s), JSC::jsBoolean(false), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "enable_lto"_s), JSC::jsBoolean(false), 0); + // Node 26's common.gypi evaluates enable_thin_lto/lto_jobs conditions; gyp + // hard-fails on undefined variables, so node-gyp builds need them present. + variables->putDirect(vm, JSC::Identifier::fromString(vm, "enable_thin_lto"_s), JSC::jsBoolean(false), 0); + variables->putDirect(vm, JSC::Identifier::fromString(vm, "lto_jobs"_s), JSC::jsString(vm, String(""_s)), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "enable_pgo_generate"_s), JSC::jsBoolean(false), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "enable_pgo_use"_s), JSC::jsBoolean(false), 0); variables->putDirect(vm, JSC::Identifier::fromString(vm, "error_on_warn"_s), JSC::jsBoolean(false), 0); diff --git a/src/jsc/bindings/BunProcessReportObjectWindows.cpp b/src/jsc/bindings/BunProcessReportObjectWindows.cpp index 48e24d14b64d..6d6e4428f3e6 100644 --- a/src/jsc/bindings/BunProcessReportObjectWindows.cpp +++ b/src/jsc/bindings/BunProcessReportObjectWindows.cpp @@ -16,6 +16,9 @@ #include "JavaScriptCore/VM.h" #include "JavaScriptCore/NumberPrototype.h" #include "wtf-bindings.h" + +#define STRINGIFY_IMPL(x) #x +#define STRINGIFY(x) STRINGIFY_IMPL(x) #include "wtf/Scope.h" #include "wtf/text/WTFString.h" #include "wtf/text/StringView.h" @@ -97,9 +100,9 @@ JSValue constructReportObjectWindows(VM& vm, Zig::GlobalObject* globalObject, Pr // Component versions - just add the minimum needed JSObject* versions = constructEmptyObject(globalObject, globalObject->objectPrototype()); versions->putDirect(vm, Identifier::fromString(vm, "node"_s), jsString(vm, String(REPORTED_NODEJS_VERSION ""_s)), 0); - versions->putDirect(vm, Identifier::fromString(vm, "v8"_s), jsString(vm, String("13.6.233.10-node.18"_s)), 0); + versions->putDirect(vm, Identifier::fromString(vm, "v8"_s), jsString(vm, String(ASCIILiteral::fromLiteralUnsafe(REPORTED_NODEJS_V8_VERSION))), 0); versions->putDirect(vm, Identifier::fromString(vm, "uv"_s), jsString(vm, String::fromLatin1(uv_version_string())), 0); - versions->putDirect(vm, Identifier::fromString(vm, "modules"_s), jsString(vm, String("137"_s)), 0); + versions->putDirect(vm, Identifier::fromString(vm, "modules"_s), jsString(vm, String(ASCIILiteral::fromLiteralUnsafe(STRINGIFY(REPORTED_NODEJS_ABI_VERSION)))), 0); header->putDirect(vm, Identifier::fromString(vm, "componentVersions"_s), versions, 0); RETURN_IF_EXCEPTION(scope, {}); diff --git a/src/jsc/bindings/ErrorCode.cpp b/src/jsc/bindings/ErrorCode.cpp index 8aef2ccca1b1..02810d77a1d3 100644 --- a/src/jsc/bindings/ErrorCode.cpp +++ b/src/jsc/bindings/ErrorCode.cpp @@ -2483,6 +2483,8 @@ JSC_DEFINE_HOST_FUNCTION(Bun::jsFunctionMakeErrorWithCode, (JSC::JSGlobalObject return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP2_PING_LENGTH, "HTTP2 ping payload must be 8 bytes"_s)); case ErrorCode::ERR_HTTP2_OUT_OF_STREAMS: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP2_OUT_OF_STREAMS, "No stream ID is available because maximum stream ID has been reached"_s)); + case ErrorCode::ERR_HTTP2_GOAWAY_SESSION: + return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP2_GOAWAY_SESSION, "New streams cannot be created after receiving a GOAWAY"_s)); case ErrorCode::ERR_HTTP_BODY_NOT_ALLOWED: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP_BODY_NOT_ALLOWED, "Adding content for this request method or response status is not allowed."_s)); case ErrorCode::ERR_HTTP_SOCKET_ASSIGNED: diff --git a/src/jsc/bindings/ErrorCode.ts b/src/jsc/bindings/ErrorCode.ts index 261f06a313d5..172f12877a73 100644 --- a/src/jsc/bindings/ErrorCode.ts +++ b/src/jsc/bindings/ErrorCode.ts @@ -326,5 +326,8 @@ const errors: ErrorCodeMapping = [ ["ERR_MYSQL_CONNECTION_FAILED", Error, "MySQLError"], ["ERR_POSTGRES_CONNECTION_REFUSED", Error, "PostgresError"], ["ERR_MYSQL_CONNECTION_REFUSED", Error, "MySQLError"], + // Appended (not alphabetical): discriminants are index-aligned with the + // checked-in Rust mirror (src/jsc/ErrorCode.rs) — only ever append here. + ["ERR_HTTP2_GOAWAY_SESSION", Error], ]; export default errors; diff --git a/src/jsc/bindings/NodeHTTP.cpp b/src/jsc/bindings/NodeHTTP.cpp index 287742270eb1..37babada0901 100644 --- a/src/jsc/bindings/NodeHTTP.cpp +++ b/src/jsc/bindings/NodeHTTP.cpp @@ -1030,6 +1030,12 @@ JSC_DEFINE_HOST_FUNCTION(jsHTTPGetHeader, (JSGlobalObject * globalObject, CallFr WebCore::HTTPHeaderName headerName; if (WebCore::findHTTPHeaderName(name, headerName)) { if (headerName == WebCore::HTTPHeaderName::SetCookie) { + // Node's getHeader returns undefined for an absent header; + // Headers.getSetCookie()'s empty array is only correct once + // at least one Set-Cookie value exists. + if (impl->getSetCookieHeaders().isEmpty()) { + return JSValue::encode(jsUndefined()); + } RELEASE_AND_RETURN(scope, fetchHeadersGetSetCookie(globalObject, vm, impl)); } diff --git a/src/jsc/bindings/napi.cpp b/src/jsc/bindings/napi.cpp index 8fef5dcfa30f..aa4b5a78df55 100644 --- a/src/jsc/bindings/napi.cpp +++ b/src/jsc/bindings/napi.cpp @@ -2285,6 +2285,23 @@ napi_status napi_get_value_string_any_encoding(napi_env env, napi_value napiValu return napi_set_last_error(env, napi_ok); } + // An over-large bufsize (in particular NAPI_AUTO_LENGTH == SIZE_MAX) means the + // caller promises the buffer is big enough for the whole string; Node forwards + // such sizes to V8's WriteUtf8V2, which simply stops at the end of the string. + // Clamp to the worst-case number of code units the encoder can produce so that + // `bufsize - 1` (and `2 * (bufsize - 1)` for UTF-16, which would otherwise wrap + // around size_t) stays within the destination the caller actually guarantees. + // The encoders already stop at min(input, output), so this never changes how + // many code units get written for buffers that really are this large. + const size_t max_encoded_units = EncodeTo == NapiStringEncoding::utf8 + // Latin-1 → UTF-8 expands at most 2x per byte; UTF-16 → UTF-8 at most 3x per code unit + ? (view->is8Bit() ? 2 : 3) * static_cast(view->length()) + // latin1/utf16 destinations: at most one code unit per source code unit + : static_cast(view->length()); + if (bufsize - 1 > max_encoded_units) [[unlikely]] { + bufsize = max_encoded_units + 1; + } + size_t written; std::span writable_byte_slice(reinterpret_cast(buf), EncodeTo == NapiStringEncoding::utf16 diff --git a/src/jsc/bindings/v8/V8Array.cpp b/src/jsc/bindings/v8/V8Array.cpp index 2e6dcf09eea5..4ab770f33d4a 100644 --- a/src/jsc/bindings/v8/V8Array.cpp +++ b/src/jsc/bindings/v8/V8Array.cpp @@ -94,7 +94,9 @@ MaybeLocal Array::New(Local context, size_t length, JSArray* array = JSC::constructArray(globalObject, static_cast(nullptr), args); RETURN_IF_EXCEPTION(scope, MaybeLocal()); - Local result = handleScope.createLocal(vm, array); + // Note: createLocal must not be called on an EscapableHandleScope -- it does not own a + // buffer (its constructor does not push a Bun handle scope; see V8EscapableHandleScopeBase). + Local result = isolate->currentHandleScope()->createLocal(vm, array); return handleScope.Escape(result); } diff --git a/src/jsc/bindings/v8/V8EscapableHandleScopeBase.cpp b/src/jsc/bindings/v8/V8EscapableHandleScopeBase.cpp index e9c3c0196880..c9fdd48d01db 100644 --- a/src/jsc/bindings/v8/V8EscapableHandleScopeBase.cpp +++ b/src/jsc/bindings/v8/V8EscapableHandleScopeBase.cpp @@ -1,29 +1,63 @@ #include "V8EscapableHandleScopeBase.h" +#include "shim/GlobalInternals.h" #include "v8_compatibility_assertions.h" +#include "v8_handle_scope_data.h" ASSERT_V8_TYPE_LAYOUT_MATCHES(v8::EscapableHandleScopeBase) namespace v8 { EscapableHandleScopeBase::EscapableHandleScopeBase(Isolate* isolate) - : HandleScope(isolate) { - // at this point isolate->currentHandleScope() would just be this, so instead we have to get the - // previous one - auto& handle = m_previousHandleScope->m_buffer->createEmptyHandle(); - m_escapeSlot = &handle; + // This constructor must be ABI-neutral between header generations (see the comment in + // V8EscapableHandleScopeBase.h): with Node 26 headers the object is destroyed by V8's inline + // ~HandleScope, with older headers by Bun's exported ~HandleScope, and neither path can pop a + // Bun handle scope. So do not push one. Instead initialize the three V8-visible base words + // exactly like V8 14's inline HandleScope::Initialize (v8-local-handle.h): + // isolate_ <- isolate + // prev_next_ <- HandleScopeData::next + // prev_limit_ <- HandleScopeData::limit + // HandleScopeData::level++ + // The inline destructor then restores HandleScopeData from those words (our exported + // ~HandleScope does the same for old-ABI frames, see V8HandleScope.cpp). Outside of a running + // inline CreateHandle, next == limit always holds (Extend hands out one slot at a time and + // CreateHandle advances next past it), so the snapshot we restore preserves that invariant. + auto* data = shim::getHandleScopeData(isolate); + m_isolate = isolate; + m_previousHandleScope = reinterpret_cast(data->next); + m_buffer = reinterpret_cast(data->limit); + data->level++; + + // Handles created while this scope is alive land in the surrounding Bun scope's buffer (we + // did not push), so they outlive this scope; that is safe, just slightly longer-lived than + // real V8. An Escape()d value must survive this scope, which that same buffer provides -- + // capture it now so Escape still targets it even if (with old-ABI addons) a deeper scope is + // current by then. + // + // Reserve the escape slot NOW, like real V8: its storage index must be below every handle + // created inside this scope, or HandleScope::DeleteExtensions (run by V8 14's inline + // ~HandleScope) would sweep the just-escaped handle together with the scope's grants. The + // reservation is kept in a side registry keyed by `this` because the V8 ABI leaves exactly + // one Bun-usable word in this object (m_escapeBuffer). + auto* current = isolate->globalInternals()->currentHandleScope(); + RELEASE_ASSERT(current, "EscapableHandleScope created without an active handle scope"); + m_escapeBuffer = current->m_buffer; + shim::Handle* reserved = current->m_buffer->reserveEscapeHandle(); + isolate->globalInternals()->escapeReservations().set(this, shim::GlobalInternals::EscapeReservation { reserved, current->m_buffer }); } -// Store the handle escape_value in the escape slot that we have allocated from the parent -// HandleScope, and return the escape slot +// Fill the escape slot reserved at construction with escape_value and return its location. uintptr_t* EscapableHandleScopeBase::EscapeSlot(uintptr_t* escape_value) { - RELEASE_ASSERT(m_escapeSlot != nullptr, "EscapableHandleScope::Escape called multiple times"); - TaggedPointer* newHandle = m_previousHandleScope->m_buffer->createHandleFromExistingObject( + RELEASE_ASSERT(m_escapeBuffer != nullptr, "EscapableHandleScope::Escape called multiple times"); + auto reservation = m_isolate->globalInternals()->escapeReservations().take(this); + RELEASE_ASSERT(reservation.handle && reservation.buffer == m_escapeBuffer, + "EscapableHandleScope escape reservation missing"); + TaggedPointer* newHandle = m_escapeBuffer->createHandleFromExistingObject( TaggedPointer::fromRaw(*escape_value), m_isolate, - m_escapeSlot); - m_escapeSlot = nullptr; + reservation.handle); + m_escapeBuffer = nullptr; return newHandle->asRawPtrLocation(); } diff --git a/src/jsc/bindings/v8/V8EscapableHandleScopeBase.h b/src/jsc/bindings/v8/V8EscapableHandleScopeBase.h index 097f7aa0a572..9903cfd045fc 100644 --- a/src/jsc/bindings/v8/V8EscapableHandleScopeBase.h +++ b/src/jsc/bindings/v8/V8EscapableHandleScopeBase.h @@ -6,6 +6,19 @@ namespace v8 { +// In Node 26 (V8 14) headers, this class's constructor is the only out-of-line piece of an +// EscapableHandleScope's lifetime: ~EscapableHandleScopeBase and ~EscapableHandleScope are +// inline-defaulted, so destruction runs V8's inline ~HandleScope (v8-local-handle.h), which +// unwinds the isolate's HandleScopeData using this object's three base words as +// { isolate_, prev_next_, prev_limit_ }. Older Node headers (<= 24) instead reach Bun's exported +// ~HandleScope through their inline-defaulted destructors. Therefore this constructor must NOT +// push a Bun handle scope (nothing on either path would pop it); it initializes the base words +// V8-style, and Bun's exported ~HandleScope detects such frames and unwinds them the same way the +// inline destructor would. See V8EscapableHandleScopeBase.cpp and V8HandleScope.cpp. +// +// Consequently the inherited m_previousHandleScope/m_buffer words do NOT hold Bun pointers here, +// so inherited HandleScope methods that use them (like createLocal) must not be called on these +// objects; Bun-internal code should use isolate->currentHandleScope()->createLocal instead. class EscapableHandleScopeBase : public HandleScope { public: BUN_EXPORT EscapableHandleScopeBase(Isolate* isolate); @@ -14,7 +27,11 @@ class EscapableHandleScopeBase : public HandleScope { BUN_EXPORT uintptr_t* EscapeSlot(uintptr_t* escape_value); private: - shim::Handle* m_escapeSlot; + // The buffer of the Bun handle scope that was current when this scope was constructed (the + // scope an Escape()d value escapes to). Occupies the slot V8 uses for escape_slot_; like + // escape_slot_, it is only ever touched by out-of-line (Bun-compiled) code, and doubles as + // the "Escape called twice" flag. + shim::HandleScopeBuffer* m_escapeBuffer; }; } // namespace v8 diff --git a/src/jsc/bindings/v8/V8External.cpp b/src/jsc/bindings/v8/V8External.cpp index 23581c2ecf60..221790e23f75 100644 --- a/src/jsc/bindings/v8/V8External.cpp +++ b/src/jsc/bindings/v8/V8External.cpp @@ -17,6 +17,13 @@ Local External::New(Isolate* isolate, void* value) return isolate->currentHandleScope()->createLocal(vm, val); } +Local External::New(Isolate* isolate, void* value, uint16_t tag) +{ + // see V8External.h for why the tag is ignored + (void)tag; + return New(isolate, value); +} + void* External::Value() const { auto* external = localToObjectPointer(); @@ -26,4 +33,11 @@ void* External::Value() const return external->value(); } +void* External::Value(uint16_t tag) const +{ + // see V8External.h for why the tag is ignored + (void)tag; + return Value(); +} + } // namespace v8 diff --git a/src/jsc/bindings/v8/V8External.h b/src/jsc/bindings/v8/V8External.h index 3d9a0fccae3c..fb99e33ffafd 100644 --- a/src/jsc/bindings/v8/V8External.h +++ b/src/jsc/bindings/v8/V8External.h @@ -9,8 +9,18 @@ namespace v8 { class External : public Value { public: + // Kept for addons compiled against older Node headers, where this overload was out-of-line. + // In V8 14 it is an inline wrapper around the tagged overload below. BUN_EXPORT static Local New(Isolate* isolate, void* value); + // The tag is a v8::ExternalPointerTypeTag (uint16_t), used to type entries in V8's sandbox + // external pointer table so that sandboxed code cannot type-confuse one external pointer for + // another. We have no V8 sandbox and no external pointer table -- the pointer is stored + // directly in a NapiExternal cell -- so there is nothing for the tag to tag and it is ignored. + BUN_EXPORT static Local New(Isolate* isolate, void* value, uint16_t tag); BUN_EXPORT void* Value() const; + // Same deal as New: the tag selects the external pointer table tag to validate against, which + // does not exist here. V8 14's inline Value() forwards to this overload. + BUN_EXPORT void* Value(uint16_t tag) const; }; } // namespace v8 diff --git a/src/jsc/bindings/v8/V8FunctionCallbackInfo.cpp b/src/jsc/bindings/v8/V8FunctionCallbackInfo.cpp index f044d67ffdd8..194e33ce4e97 100644 --- a/src/jsc/bindings/v8/V8FunctionCallbackInfo.cpp +++ b/src/jsc/bindings/v8/V8FunctionCallbackInfo.cpp @@ -2,22 +2,35 @@ #include "real_v8.h" #include "v8_compatibility_assertions.h" -// Check that the offset of a field in our ImplicitArgs struct matches the array index -// that V8 uses to access that field -#define CHECK_IMPLICIT_ARG(BUN_NAME, V8_NAME) \ - static_assert(offsetof(v8::ImplicitArgs, BUN_NAME) \ - == sizeof(void*) * real_v8::FunctionCallbackInfo::V8_NAME, \ - "Position of `" #BUN_NAME "` in implicit arguments does not match V8"); +// Check that a slot index in our FunctionCallbackInfo matches the index V8's +// inline accessors use to read that slot of the ApiCallbackExitFrame +#define CHECK_FRAME_INDEX(NAME) \ + static_assert(static_cast(v8::FunctionCallbackInfo::NAME) \ + == static_cast(real_v8::FunctionCallbackInfo::NAME), \ + "Index of `" #NAME "` in the callback exit frame does not match V8"); -CHECK_IMPLICIT_ARG(unused, kUnusedIndex) -CHECK_IMPLICIT_ARG(isolate, kIsolateIndex) -CHECK_IMPLICIT_ARG(context, kContextIndex) -CHECK_IMPLICIT_ARG(return_value, kReturnValueIndex) -CHECK_IMPLICIT_ARG(target, kTargetIndex) -CHECK_IMPLICIT_ARG(new_target, kNewTargetIndex) +CHECK_FRAME_INDEX(kNewTargetIndex) +CHECK_FRAME_INDEX(kArgcIndex) +CHECK_FRAME_INDEX(kFrameSPIndex) +CHECK_FRAME_INDEX(kFrameTypeIndex) +CHECK_FRAME_INDEX(kFrameFPIndex) +CHECK_FRAME_INDEX(kFramePCIndex) +CHECK_FRAME_INDEX(kIsolateIndex) +CHECK_FRAME_INDEX(kReturnValueIndex) +CHECK_FRAME_INDEX(kContextIndex) +CHECK_FRAME_INDEX(kTargetIndex) +CHECK_FRAME_INDEX(kReceiverIndex) +CHECK_FRAME_INDEX(kFirstJSArgumentIndex) + +// Our enum folds kFrameConstantPoolIndex into kFrameFPIndex, which is only +// valid when no constant pool slot is present (true everywhere but PPC64) +static_assert(real_v8::internal::Internals::kFrameCPSlotCount == 0, + "Bun's v8::FunctionCallbackInfo assumes no constant pool slot in the exit frame"); + +static_assert(v8::FunctionCallbackInfo::kFrameTypeApiCallExit + == real_v8::internal::Internals::kFrameTypeApiCallExit, + "Frame type for API callback exit frames does not match V8"); ASSERT_V8_TYPE_LAYOUT_MATCHES(v8::FunctionCallbackInfo) -ASSERT_V8_TYPE_FIELD_OFFSET_MATCHES(v8::FunctionCallbackInfo, implicit_args, implicit_args_) ASSERT_V8_TYPE_FIELD_OFFSET_MATCHES(v8::FunctionCallbackInfo, values, values_) -ASSERT_V8_TYPE_FIELD_OFFSET_MATCHES(v8::FunctionCallbackInfo, length, length_) diff --git a/src/jsc/bindings/v8/V8FunctionCallbackInfo.h b/src/jsc/bindings/v8/V8FunctionCallbackInfo.h index bfb9f977798b..7baa3dbb76fe 100644 --- a/src/jsc/bindings/v8/V8FunctionCallbackInfo.h +++ b/src/jsc/bindings/v8/V8FunctionCallbackInfo.h @@ -8,32 +8,57 @@ class Isolate; class Context; class Value; -struct ImplicitArgs { - // v8-function-callback.h:149-154 - void* unused; // kUnusedIndex = 0 - Isolate* isolate; // kIsolateIndex = 1 - void* context; // kContextIndex = 2 - TaggedPointer return_value; // kReturnValueIndex = 3 - TaggedPointer target; // kTargetIndex = 4 - void* new_target; // kNewTargetIndex = 5 -}; - // T = return value +// +// Since V8 13.8 (crbug.com/326505377), FunctionCallbackInfo is no longer a +// {implicit_args, values, length} triple. It is a single-pointer-sized view +// into an ApiCallbackExitFrame: `this` points directly at the argc slot of a +// contiguous array of pointer-sized slots, and V8's inline accessors index +// `values_` both backwards (new.target) and forwards (frame words, API +// arguments, receiver, JS arguments) relative to that slot. template class FunctionCallbackInfo { public: - // V8 treats this as an array of pointers - ImplicitArgs* implicit_args; - // index -1 is this - TaggedPointer* values; - int length; - - FunctionCallbackInfo(ImplicitArgs* implicit_args_, TaggedPointer* values_, int length_) - : implicit_args(implicit_args_) - , values(values_) - , length(length_) - { - } + // Slot indices relative to `values`. These must match the private enum in + // V8's v8-function-callback.h (checked by static_asserts in + // V8FunctionCallbackInfo.cpp). kFrameConstantPoolIndex is folded into + // kFrameFPIndex because Internals::kFrameCPSlotCount == 0 on every + // architecture Bun supports (it is only 1 on PPC64). + enum { + // Optional frame arguments block (only for API_CONSTRUCT_EXIT frames). + kNewTargetIndex = -1, + + // Mandatory part. + kArgcIndex = 0, // raw integer, not a Smi + kFrameSPIndex = 1, + kFrameTypeIndex = 2, // Smi-encoded frame type + kFrameFPIndex = 3, + kFramePCIndex = 4, + + // API arguments block. + kIsolateIndex = 5, // raw Isolate* + kReturnValueIndex = 6, + kContextIndex = 7, // raw context pointer + kTargetIndex = 8, + + // JS arguments block. + kReceiverIndex = 9, + kFirstJSArgumentIndex = 10, + }; + + // v8::internal::Internals::kFrameTypeApiCallExit. Stored Smi-encoded in + // the kFrameTypeIndex slot; IsConstructCall() compares against it. + static constexpr int kFrameTypeApiCallExit = 18; + + // V8 declares this as `internal::Address values_[1]` and indexes it + // out-of-bounds in both directions; the object provides a view of the + // frame rather than owning any storage. Mutable for parity with V8 (GC + // may rewrite slots through a const view). + mutable TaggedPointer values[1]; + + FunctionCallbackInfo() = delete; + FunctionCallbackInfo(const FunctionCallbackInfo&) = delete; + FunctionCallbackInfo& operator=(const FunctionCallbackInfo&) = delete; }; using FunctionCallback = void (*)(const FunctionCallbackInfo&); diff --git a/src/jsc/bindings/v8/V8HandleScope.cpp b/src/jsc/bindings/v8/V8HandleScope.cpp index 9071d9621c5c..aaecbe0f2afc 100644 --- a/src/jsc/bindings/v8/V8HandleScope.cpp +++ b/src/jsc/bindings/v8/V8HandleScope.cpp @@ -1,10 +1,18 @@ #include "V8HandleScope.h" #include "shim/GlobalInternals.h" #include "v8_compatibility_assertions.h" +#include "v8_handle_scope_data.h" -// I haven't found an inlined function which accesses HandleScope fields, so I'm assuming the field -// offsets do *not* need to match (also, our fields have different types and meanings anyway). -// But the size must match, because if our HandleScope is too big it'll clobber other stack variables. +// The size must match, because if our HandleScope is too big it'll clobber other stack variables. +// The field offsets matter too since Node 26 (V8 14): the headers fully inline +// HandleScope's constructor, destructor and CreateHandle, so addon code reads and writes the +// three words of a HandleScope frame directly as { Isolate* isolate_; Address* prev_next_; +// Address* prev_limit_; }. Frames constructed by our exported HandleScope(Isolate*) constructor +// are never destroyed by that inline code (old-ABI addons call our exported destructor), so those +// keep Bun meanings for words 1 and 2 (m_previousHandleScope/m_buffer). Frames constructed by the +// exported EscapableHandleScopeBase constructor *are* unwound by the inline destructor, so that +// constructor initializes them with V8's meanings instead -- see V8EscapableHandleScopeBase.cpp +// and the comments in ~HandleScope below. ASSERT_V8_TYPE_LAYOUT_MATCHES(v8::HandleScope) namespace v8 { @@ -17,11 +25,60 @@ HandleScope::HandleScope(Isolate* isolate) isolate->globalInternals()->handleScopeBufferStructure(isolate->globalObject()))) { m_isolate->globalInternals()->setCurrentHandleScope(this); + // Snapshot the isolate's HandleScopeData so the pop can restore it; see + // the comment on HandleScopeBuffer::saveHandleScopeData. + auto* data = shim::getHandleScopeData(isolate); + m_buffer->saveHandleScopeData(data->next, data->limit); } HandleScope::~HandleScope() { + if (m_isolate->globalInternals()->currentHandleScope() != this) { + // This frame was not pushed onto Bun's handle scope stack, so it must have been + // initialized in V8's inline ABI style by the exported EscapableHandleScopeBase + // constructor (which is the only exported constructor that does not push; plain + // HandleScope frames built by the exported constructor above always have + // currentHandleScope() == this here under correct nesting). Old-ABI addons reach this + // destructor for such frames because their inline-defaulted ~EscapableHandleScopeBase / + // ~EscapableHandleScope call the out-of-line ~HandleScope. Unwind exactly like V8 14's + // inline ~HandleScope would: words 1 and 2 hold the constructor-time snapshot of + // HandleScopeData::next/limit, not Bun pointers. +#if ASSERT_ENABLED + // A Bun-pushed frame destroyed out of LIFO order would also land here and have its + // m_previousHandleScope/m_buffer pointers written into HandleScopeData below, silently + // corrupting the next inline CreateHandle. Fail loudly in debug builds instead. + for (auto* scope = m_isolate->globalInternals()->currentHandleScope(); scope; scope = scope->m_previousHandleScope) { + ASSERT_WITH_MESSAGE(scope != this, "v8::HandleScope destroyed out of LIFO order"); + } +#endif + auto* data = shim::getHandleScopeData(m_isolate); + data->next = reinterpret_cast(m_previousHandleScope); + data->limit = reinterpret_cast(m_buffer); + data->level--; + // Mirror V8 14's inline ~HandleScope: reclaim the slots Extend granted inside this + // frame (a no-op when the frame created no handles, since the newest remaining grant + // then already matches the restored limit). + if (auto* current = m_isolate->globalInternals()->currentHandleScope()) { + current->m_buffer->deleteGrantsBack(data->limit); + } + // This frame is an escapable scope going through the exported destructor (old ABI); + // drop its escape reservation if Escape() was never called. + m_isolate->globalInternals()->escapeReservations().remove(this); + return; + } m_isolate->globalInternals()->setCurrentHandleScope(m_previousHandleScope); + // Escape reservations in this buffer belong to scopes that are dead or dying (their slots + // are about to be cleared); purge them so stale stack-address keys can't alias new scopes. + m_isolate->globalInternals()->purgeEscapeReservations(m_buffer); + // Restore HandleScopeData to its push-time snapshot. If Extend granted + // slots from this buffer while this scope was current, next/limit would + // otherwise keep pointing into the buffer we are about to clear, and the + // next inline v8::HandleScope would capture that stale limit as its + // prev_limit_ — its DeleteExtensions would then pop every grant in the + // (foreign) enclosing buffer, killing handles of still-open outer scopes. + auto* data = shim::getHandleScopeData(m_isolate); + data->next = m_buffer->savedNext(); + data->limit = m_buffer->savedLimit(); m_buffer->clear(); m_buffer = nullptr; } @@ -35,4 +92,59 @@ uintptr_t* HandleScope::CreateHandle(internal::Isolate* i_isolate, uintptr_t val return newSlot->asRawPtrLocation(); } +uintptr_t* HandleScope::CreateHandle(Isolate* isolate, uintptr_t value) +{ + // Same object underneath; v8::Isolate* and internal::Isolate* are nominal + // views of our Isolate. + return CreateHandle(reinterpret_cast(isolate), value); +} + +void HandleScope::Initialize(Isolate* isolate) +{ + // Mirror V8 14's inline HandleScope::Initialize (v8-local-handle.h): + // stash the HandleScopeData snapshot in the V8-visible words and bump + // level. The frame is addon-owned and V8-laid-out — do not push a Bun + // scope and do not touch Bun-meaning members beyond the three words. + auto* data = shim::getHandleScopeData(isolate); + m_isolate = isolate; + m_previousHandleScope = reinterpret_cast(data->next); + m_buffer = reinterpret_cast(data->limit); + data->level++; +} + +uintptr_t* HandleScope::Extend(Isolate* isolate) +{ + // V8 14's inline HandleScope::CreateHandle (v8-local-handle.h) calls Extend when + // data->next == data->limit, then stores the value into the returned slot itself and sets + // data->next to one past the slot. The Isolate's HandleScopeData starts zeroed + // (next == limit == nullptr), and we always hand out exactly one slot with + // limit == slot + 1 == the next value the caller will store, so next == limit is reestablished + // after every inline allocation and every inline handle creation takes this path. The slots + // come from the current Bun handle scope's buffer, so the values stay alive (and GC-visited, + // see Handle::isCell) until that scope closes. + auto* handleScope = isolate->globalInternals()->currentHandleScope(); + RELEASE_ASSERT(handleScope); + TaggedPointer* slot = handleScope->m_buffer->createRawHandleSlot(); + uintptr_t* address = slot->asRawPtrLocation(); + auto* data = shim::getHandleScopeData(isolate); + data->next = address; + data->limit = address + 1; + return address; +} + +void HandleScope::DeleteExtensions(Isolate* isolate) +{ + // Called by V8 14's inline ~HandleScope after it restored HandleScopeData::next/limit, when + // the scope changed the limit (which Extend always does). Free the slots Extend granted inside + // the closing scope — without this, per-iteration v8::HandleScopes in a long native call never + // reclaim memory (everything would otherwise live until the enclosing Bun scope closes). + // `this` is the addon's V8-layout HandleScope, so our members must not be touched. + auto* handleScope = isolate->globalInternals()->currentHandleScope(); + if (!handleScope) { + return; + } + auto* data = shim::getHandleScopeData(isolate); + handleScope->m_buffer->deleteGrantsBack(data->limit); +} + } // namespace v8 diff --git a/src/jsc/bindings/v8/V8HandleScope.h b/src/jsc/bindings/v8/V8HandleScope.h index 0f91c2234d63..eae0556fa81f 100644 --- a/src/jsc/bindings/v8/V8HandleScope.h +++ b/src/jsc/bindings/v8/V8HandleScope.h @@ -44,6 +44,11 @@ class HandleScope { friend class EscapableHandleScopeBase; protected: + // Used by EscapableHandleScopeBase, whose constructor must initialize the fields itself + // (V8-style, without pushing a Bun handle scope). Mirrors V8's protected + // `HandleScope() = default`. + HandleScope() = default; + // must be 24 bytes to match V8 layout Isolate* m_isolate; HandleScope* m_previousHandleScope; @@ -51,6 +56,28 @@ class HandleScope { // is protected in v8, which matters on windows BUN_EXPORT static uintptr_t* CreateHandle(internal::Isolate* isolate, uintptr_t value); + // V8 14's headers also declare a V8_INLINE overload taking v8::Isolate* + // with an out-of-class body (v8-local-handle.h); MSVC debug builds import + // it instead of emitting it, so it must exist as a real export. Protected + // in V8 (affects the MSVC mangling). + BUN_EXPORT static uintptr_t* CreateHandle(Isolate* isolate, uintptr_t value); + // Same story for the inline constructor's Initialize: under MSVC /Ob0 the + // addon-side inline HandleScope constructor calls an imported Initialize. + // Initializes the frame in V8's inline style (snapshot next/limit, + // level++) — never pushes a Bun scope, mirroring EscapableHandleScopeBase. + BUN_EXPORT void Initialize(Isolate* isolate); + +private: + // Out-of-line slow path of V8 14's fully-inline HandleScope (v8-local-handle.h). The inline + // CreateHandle calls Extend whenever HandleScopeData::next == HandleScopeData::limit, and the + // inline destructor calls DeleteExtensions whenever the scope changed HandleScopeData::limit. + // Private to match V8's declarations, which affects the mangled name on MSVC. + // + // Note that when these are called, `this` (for DeleteExtensions) is a V8-layout HandleScope + // living in the addon's stack frame -- not one of ours -- so they must not touch our members + // through `this`. + BUN_EXPORT static uintptr_t* Extend(Isolate* isolate); + BUN_EXPORT void DeleteExtensions(Isolate* isolate); }; static_assert(sizeof(HandleScope) == 24, "HandleScope has wrong layout"); diff --git a/src/jsc/bindings/v8/V8Isolate.cpp b/src/jsc/bindings/v8/V8Isolate.cpp index 2f6928b49a8f..80f740bbcc50 100644 --- a/src/jsc/bindings/v8/V8Isolate.cpp +++ b/src/jsc/bindings/v8/V8Isolate.cpp @@ -43,6 +43,10 @@ Local Isolate::GetCurrentContext() Isolate::Isolate(shim::GlobalInternals* globalInternals) : m_globalInternals(globalInternals) , m_globalObject(globalInternals->m_globalObject) + // Zero the padding: V8 14's inline HandleScope code keeps the isolate's HandleScopeData + // (next/limit/level, see HandleScope::Extend) inside this region, and relies on it starting + // out zeroed just like real V8's HandleScopeData::Initialize() leaves it. + , m_padding {} { m_roots[kUndefinedValueRootIndex] = TaggedPointer(&globalInternals->m_undefinedValue); m_roots[kNullValueRootIndex] = TaggedPointer(&globalInternals->m_nullValue); diff --git a/src/jsc/bindings/v8/V8Isolate.h b/src/jsc/bindings/v8/V8Isolate.h index 5069cbd3e948..784c93e77bce 100644 --- a/src/jsc/bindings/v8/V8Isolate.h +++ b/src/jsc/bindings/v8/V8Isolate.h @@ -17,12 +17,12 @@ class GlobalInternals; // they need to have the correct layout. class Isolate final { public: - // v8-internal.h:775 - static constexpr int kUndefinedValueRootIndex = 4; - static constexpr int kTheHoleValueRootIndex = 5; - static constexpr int kNullValueRootIndex = 6; - static constexpr int kTrueValueRootIndex = 7; - static constexpr int kFalseValueRootIndex = 8; + // v8-internal.h:1107 + static constexpr int kUndefinedValueRootIndex = 0; + static constexpr int kTheHoleValueRootIndex = 1; + static constexpr int kNullValueRootIndex = 2; + static constexpr int kTrueValueRootIndex = 3; + static constexpr int kFalseValueRootIndex = 4; Isolate(shim::GlobalInternals* globalInternals); @@ -50,9 +50,12 @@ class Isolate final { shim::GlobalInternals* m_globalInternals; Zig::GlobalObject* m_globalObject; - uintptr_t m_padding[78]; + // Padding so that m_roots is at Internals::kIsolateRootsOffset (688 on 64-bit: 16 bytes of + // fields above plus 84 words). V8 14.x inserted kIsolateJSDispatchTableOffset + // (kExternalEntityTableSize) into the isolate-data layout ahead of the roots array. + uintptr_t m_padding[84]; - std::array m_roots; + std::array m_roots; }; } // namespace v8 diff --git a/src/jsc/bindings/v8/V8Number.cpp b/src/jsc/bindings/v8/V8Number.cpp index c870d1ef3876..ab52f2ed9aa4 100644 --- a/src/jsc/bindings/v8/V8Number.cpp +++ b/src/jsc/bindings/v8/V8Number.cpp @@ -11,6 +11,16 @@ Local Number::New(Isolate* isolate, double value) return isolate->currentHandleScope()->createLocal(isolate->vm(), JSC::jsNumber(value)); } +Local Number::NewFromInt32(Isolate* isolate, int32_t value) +{ + return isolate->currentHandleScope()->createLocal(isolate->vm(), JSC::jsNumber(value)); +} + +Local Number::NewFromUint32(Isolate* isolate, uint32_t value) +{ + return isolate->currentHandleScope()->createLocal(isolate->vm(), JSC::jsNumber(value)); +} + double Number::Value() const { return localToJSValue().asNumber(); diff --git a/src/jsc/bindings/v8/V8Number.h b/src/jsc/bindings/v8/V8Number.h index e85c3ae5ec93..e3a02e27c170 100644 --- a/src/jsc/bindings/v8/V8Number.h +++ b/src/jsc/bindings/v8/V8Number.h @@ -12,6 +12,13 @@ class Number : public Primitive { BUN_EXPORT static Local New(Isolate* isolate, double value); BUN_EXPORT double Value() const; + +private: + // Out-of-line targets of the inline templated Number::New integer overloads in + // v8-primitive.h. Private to match V8's declarations, which affects the mangled + // name on MSVC. + BUN_EXPORT static Local NewFromInt32(Isolate* isolate, int32_t value); + BUN_EXPORT static Local NewFromUint32(Isolate* isolate, uint32_t value); }; } // namespace v8 diff --git a/src/jsc/bindings/v8/V8String.cpp b/src/jsc/bindings/v8/V8String.cpp index 831547982252..8f61fd57ffb2 100644 --- a/src/jsc/bindings/v8/V8String.cpp +++ b/src/jsc/bindings/v8/V8String.cpp @@ -8,11 +8,12 @@ ASSERT_V8_TYPE_LAYOUT_MATCHES(v8::String) ASSERT_V8_ENUM_MATCHES(NewStringType, kNormal) ASSERT_V8_ENUM_MATCHES(NewStringType, kInternalized) -ASSERT_V8_ENUM_MATCHES(String::WriteOptions, NO_OPTIONS) -ASSERT_V8_ENUM_MATCHES(String::WriteOptions, HINT_MANY_WRITES_EXPECTED) -ASSERT_V8_ENUM_MATCHES(String::WriteOptions, NO_NULL_TERMINATION) -ASSERT_V8_ENUM_MATCHES(String::WriteOptions, PRESERVE_ONE_BYTE_NULL) -ASSERT_V8_ENUM_MATCHES(String::WriteOptions, REPLACE_INVALID_UTF8) +// V8 14 removed String::WriteOptions along with the legacy Write/WriteOneByte/WriteUtf8 +// APIs (crbug.com/373485796), so it can no longer be checked against the real headers. +// The replacement V2 write APIs take String::WriteFlags. +ASSERT_V8_ENUM_MATCHES(String::WriteFlags, kNone) +ASSERT_V8_ENUM_MATCHES(String::WriteFlags, kNullTerminate) +ASSERT_V8_ENUM_MATCHES(String::WriteFlags, kReplaceInvalidUtf8) using JSC::JSString; @@ -189,6 +190,140 @@ int String::WriteUtf8(Isolate* isolate, char* buffer, int length, int* nchars_re return written; } +void String::WriteV2(Isolate* isolate, uint32_t offset, uint32_t length, uint16_t* buffer, int flags) const +{ + auto jsString = localToObjectPointer(); + RELEASE_ASSERT(static_cast(offset) + length <= jsString->length()); + if (length > 0) { + auto str = jsString->view(isolate->globalObject()); + if (str->is8Bit()) { + WTF::copyElements(std::span(buffer, length), str->span8().subspan(offset, length)); + } else { + memcpy(buffer, str->span16().subspan(offset, length).data(), static_cast(length) * sizeof(uint16_t)); + } + } + if (flags & WriteFlags::kNullTerminate) { + buffer[length] = 0; + } +} + +void String::WriteOneByteV2(Isolate* isolate, uint32_t offset, uint32_t length, uint8_t* buffer, int flags) const +{ + auto jsString = localToObjectPointer(); + RELEASE_ASSERT(static_cast(offset) + length <= jsString->length()); + if (length > 0) { + auto str = jsString->view(isolate->globalObject()); + if (str->is8Bit()) { + memcpy(buffer, str->span8().subspan(offset, length).data(), length); + } else { + // like V8, only the least significant byte of each code unit is written + WTF::copyElements(std::span(buffer, length), str->span16().subspan(offset, length)); + } + } + if (flags & WriteFlags::kNullTerminate) { + buffer[length] = 0; + } +} + +size_t String::WriteUtf8V2(Isolate* isolate, char* buffer, size_t capacity, int flags, size_t* processed_characters_return) const +{ + auto jsString = localToObjectPointer(); + auto str = jsString->view(isolate->globalObject()); + + size_t writableCapacity = capacity; + if (flags & WriteFlags::kNullTerminate) { + RELEASE_ASSERT(capacity >= 1); + writableCapacity--; + } + + size_t read = 0; + size_t written = 0; + if (!str->isEmpty()) { + // TextEncoder__encodeInto never writes partial UTF-8 sequences, and replaces + // unpaired surrogates with U+FFFD (same byte length as the WTF-8 encoding V8 + // uses when kReplaceInvalidUtf8 is not set, so the result size matches either + // way). + if (str->is8Bit()) { + // Latin-1 expands at most 2x: 2 * (2^31 - 1) < 2^32, so the packed + // 32-bit counts cannot wrap. + const auto span = str->span8(); + uint64_t result = TextEncoder__encodeInto8(span.data(), span.size(), buffer, writableCapacity); + read = static_cast(result); + written = static_cast(result >> 32); + } else { + // UTF-16 expands up to 3x, which can exceed the 32-bit counts + // TextEncoder__encodeInto packs its result into (3 * (2^31 - 1) > + // 2^32). Encode in chunks small enough that each chunk's counts + // fit, accumulating in size_t. + const auto span = str->span16(); + const size_t total = span.size(); + constexpr size_t maxChunk = static_cast(1) << 30; // <= 3 GiB UTF-8 per chunk + while (read < total) { + size_t chunkLength = std::min(maxChunk, total - read); + // Never split a surrogate pair across chunks: the encoder + // would see two unpaired halves and write U+FFFD twice. + if (read + chunkLength < total && U16_IS_LEAD(span[read + chunkLength - 1])) { + chunkLength--; + } + uint64_t result = TextEncoder__encodeInto16(span.data() + read, chunkLength, buffer + written, writableCapacity - written); + const uint32_t chunkRead = static_cast(result); + const uint32_t chunkWritten = static_cast(result >> 32); + read += chunkRead; + written += chunkWritten; + if (chunkRead < chunkLength) { + // Ran out of output capacity. + break; + } + } + } + } + + if (processed_characters_return) { + *processed_characters_return = read; + } + if (flags & WriteFlags::kNullTerminate) { + buffer[written] = '\0'; + written++; + } + return written; +} + +size_t String::Utf8LengthV2(Isolate* isolate) const +{ + auto jsString = localToObjectPointer(); + if (jsString->length() == 0) { + return 0; + } + + auto str = jsString->view(isolate->globalObject()); + if (str->is8Bit()) { + const auto span = str->span8(); + return simdutf::utf8_length_from_latin1(reinterpret_cast(span.data()), span.size()); + } + + const auto span = str->span16(); + size_t len = simdutf::utf8_length_from_utf16(span.data(), span.size()); + // simdutf counts every surrogate code unit as 2 bytes, so a valid pair + // totals 4 (matching its UTF-8 encoding) but an unpaired surrogate only + // counts 2. V8 replaces each unpaired surrogate with U+FFFD, which + // encodes as 3 bytes (the same size WriteUtf8V2's replacement behavior + // produces), so add one byte for each unpaired surrogate code unit. + // Valid UTF-16 (the overwhelmingly common case) needs no adjustment; + // check with SIMD before falling back to the scalar surrogate count. + if (simdutf::validate_utf16(span.data(), span.size())) { + return len; + } + for (size_t i = 0; i < span.size(); i++) { + const char16_t c = span[i]; + if (U16_IS_LEAD(c) && i + 1 < span.size() && U16_IS_TRAIL(span[i + 1])) { + i++; + } else if (U16_IS_SURROGATE(c)) { + len++; + } + } + return len; +} + int String::Length() const { auto jsString = localToObjectPointer(); diff --git a/src/jsc/bindings/v8/V8String.h b/src/jsc/bindings/v8/V8String.h index 6c0ff9eed641..bddf0d6f6acf 100644 --- a/src/jsc/bindings/v8/V8String.h +++ b/src/jsc/bindings/v8/V8String.h @@ -14,6 +14,8 @@ enum class NewStringType { class String : Primitive { public: + // V8 14 removed WriteOptions and the legacy Write/WriteOneByte/WriteUtf8 APIs + // (crbug.com/373485796). Kept for addons compiled against older Node headers. enum WriteOptions { NO_OPTIONS = 0, HINT_MANY_WRITES_EXPECTED = 1, @@ -22,6 +24,14 @@ class String : Primitive { REPLACE_INVALID_UTF8 = 8, }; + struct WriteFlags { + enum { + kNone = 0, + kNullTerminate = 1, + kReplaceInvalidUtf8 = 2, + }; + }; + BUN_EXPORT static MaybeLocal NewFromUtf8(Isolate* isolate, char const* data, NewStringType type, int length = -1); BUN_EXPORT static MaybeLocal NewFromOneByte(Isolate* isolate, const uint8_t* data, NewStringType type, int length); @@ -32,6 +42,30 @@ class String : Primitive { // if string ends in a surrogate pair, but buffer is one byte too small to store it, instead // endcode the unpaired lead surrogate with WTF-8 BUN_EXPORT int WriteUtf8(Isolate* isolate, char* buffer, int length = -1, int* nchars_ref = nullptr, int options = NO_OPTIONS) const; + + /** + * Write the contents of the string to an external buffer. + * + * Copies length characters into the output buffer starting at offset. The + * output buffer must have sufficient space for all characters and the null + * terminator if null termination is requested through the flags. + */ + BUN_EXPORT void WriteV2(Isolate* isolate, uint32_t offset, uint32_t length, uint16_t* buffer, int flags = WriteFlags::kNone) const; + BUN_EXPORT void WriteOneByteV2(Isolate* isolate, uint32_t offset, uint32_t length, uint8_t* buffer, int flags = WriteFlags::kNone) const; + + /** + * Encode the contents of the string as Utf8 into an external buffer. + * + * Encodes the characters of this string as Utf8 and writes them into the + * output buffer until either all characters were encoded or the buffer is + * full. Will not write partial UTF-8 sequences, preferring to stop before + * the end of the buffer. If null termination is requested, the output + * buffer will always be null terminated even if not all characters fit. In + * that case, the capacity must be at least one. Returns the number of + * bytes copied to the buffer including the null terminator (if written). + */ + BUN_EXPORT size_t WriteUtf8V2(Isolate* isolate, char* buffer, size_t capacity, int flags = WriteFlags::kNone, size_t* processed_characters_return = nullptr) const; + BUN_EXPORT int Length() const; /** @@ -40,6 +74,13 @@ class String : Primitive { */ BUN_EXPORT int Utf8Length(Isolate* isolate) const; + /** + * Returns the number of bytes needed for the Utf8 encoding of this string. + * Unpaired surrogates are counted as the 3-byte U+FFFD replacement + * character, matching the Write*V2 replacement behavior. + */ + BUN_EXPORT size_t Utf8LengthV2(Isolate* isolate) const; + /** * Returns whether this string is known to contain only one byte data, * i.e. ISO-8859-1 code points. diff --git a/src/jsc/bindings/v8/V8Value.cpp b/src/jsc/bindings/v8/V8Value.cpp index 469f86ca4414..3ccf3a830cb3 100644 --- a/src/jsc/bindings/v8/V8Value.cpp +++ b/src/jsc/bindings/v8/V8Value.cpp @@ -34,6 +34,31 @@ bool Value::IsUndefined() const return localToJSValue().isUndefined(); } +// The QuickIs* functions are V8_INLINE with out-of-class bodies in +// v8-value.h. MSVC debug builds (/Ob0) import such members of a dllimport +// class instead of emitting them, so addons compiled --debug on Windows +// need them as real exports. Semantically they are the corresponding Is* +// checks (the "quick" part only matters for real V8's object layout). +bool Value::QuickIsUndefined() const +{ + return localToJSValue().isUndefined(); +} + +bool Value::QuickIsNull() const +{ + return localToJSValue().isNull(); +} + +bool Value::QuickIsNullOrUndefined() const +{ + return localToJSValue().isUndefinedOrNull(); +} + +bool Value::QuickIsString() const +{ + return localToJSValue().isString(); +} + bool Value::IsNull() const { return localToJSValue().isNull(); diff --git a/src/jsc/bindings/v8/V8Value.h b/src/jsc/bindings/v8/V8Value.h index 265ba925b72e..0027496503e2 100644 --- a/src/jsc/bindings/v8/V8Value.h +++ b/src/jsc/bindings/v8/V8Value.h @@ -35,6 +35,13 @@ class Value : public Data { // non-inlined versions of these BUN_EXPORT bool FullIsTrue() const; BUN_EXPORT bool FullIsFalse() const; + // V8_INLINE in the headers but with out-of-class bodies, which MSVC debug + // builds import instead of emitting locally; private to match V8's + // declarations (affects the MSVC mangling). + BUN_EXPORT bool QuickIsUndefined() const; + BUN_EXPORT bool QuickIsNull() const; + BUN_EXPORT bool QuickIsNullOrUndefined() const; + BUN_EXPORT bool QuickIsString() const; }; } // namespace v8 diff --git a/src/jsc/bindings/v8/shim/FunctionTemplate.cpp b/src/jsc/bindings/v8/shim/FunctionTemplate.cpp index 6de66fabebf3..1bf00a097ce4 100644 --- a/src/jsc/bindings/v8/shim/FunctionTemplate.cpp +++ b/src/jsc/bindings/v8/shim/FunctionTemplate.cpp @@ -62,8 +62,6 @@ JSC::EncodedJSValue FunctionTemplate::functionCall(JSC::JSGlobalObject* globalOb auto* isolate = uncheckedDowncast(globalObject)->V8GlobalInternals()->isolate(); auto& vm = JSC::getVM(globalObject); - WTF::Vector args(callFrame->argumentCount() + 1); - HandleScope hs(isolate); // V8 function calls always run in "sloppy mode," even if the JS side is in strict mode. So if @@ -75,36 +73,62 @@ JSC::EncodedJSValue FunctionTemplate::functionCall(JSC::JSGlobalObject* globalOb jscThis = callFrame->thisValue().toObject(globalObject); } Local thisObject = hs.createLocal(vm, jscThis); - args[0] = thisObject.tagged(); - - for (size_t i = 0; i < callFrame->argumentCount(); i++) { - Local argValue = hs.createLocal(vm, callFrame->argument(i)); - args[i + 1] = argValue.tagged(); - } // In V8, the target is the function being called Local target = hs.createLocal(vm, callee); - ImplicitArgs implicit_args = { - .unused = nullptr, - .isolate = isolate, - // Context is always a reinterpret pointer to Zig::GlobalObject - .context = reinterpret_cast(globalObject), - .return_value = TaggedPointer(), - // target holds the Function being called, which contains the FunctionTemplate - .target = target.tagged(), - .new_target = nullptr, + // Build a synthetic ApiCallbackExitFrame: one contiguous array of + // pointer-sized slots that V8's inline FunctionCallbackInfo accessors index + // relative to the argc slot. The view starts one slot into the array so + // that kNewTargetIndex (-1) stays in bounds. + using Info = FunctionCallbackInfo; + // One slot below the view base: kNewTargetIndex is the only negative + // index, so the buffer needs exactly that much headroom before it. + constexpr size_t viewOffset = 1; + static_assert(viewOffset + Info::kNewTargetIndex == 0, + "viewOffset must cover the most negative FunctionCallbackInfo index"); + const size_t argc = callFrame->argumentCount(); + WTF::Vector frame(viewOffset + Info::kFirstJSArgumentIndex + argc); + auto slot = [&](ptrdiff_t index) -> TaggedPointer& { + return frame[viewOffset + index]; }; - FunctionCallbackInfo info(&implicit_args, args.begin() + 1, callFrame->argumentCount()); + // Bun never reports a construct call here, so V8's NewTarget() always + // returns undefined without reading this slot + slot(Info::kNewTargetIndex) = TaggedPointer(); + // Length() reads this as a raw integer, not a Smi + slot(Info::kArgcIndex) = TaggedPointer::fromRaw(argc); + // SP/FP/PC are only used by V8's stack walker, which never sees this frame + slot(Info::kFrameSPIndex) = TaggedPointer::fromRaw(0); + // IsConstructCall() compares this Smi against kFrameTypeApiConstructExit + slot(Info::kFrameTypeIndex) = TaggedPointer(Info::kFrameTypeApiCallExit); + slot(Info::kFrameFPIndex) = TaggedPointer::fromRaw(0); + slot(Info::kFramePCIndex) = TaggedPointer::fromRaw(0); + // GetIsolate() reads this slot as a raw, untagged pointer + slot(Info::kIsolateIndex) = TaggedPointer::fromRaw(reinterpret_cast(isolate)); + slot(Info::kReturnValueIndex) = TaggedPointer(); + // Context is always a reinterpret pointer to Zig::GlobalObject + slot(Info::kContextIndex) = TaggedPointer::fromRaw(reinterpret_cast(globalObject)); + // target holds the Function being called, which contains the FunctionTemplate + slot(Info::kTargetIndex) = target.tagged(); + slot(Info::kReceiverIndex) = thisObject.tagged(); + + for (size_t i = 0; i < argc; i++) { + Local argValue = hs.createLocal(vm, callFrame->argument(i)); + slot(Info::kFirstJSArgumentIndex + i) = argValue.tagged(); + } + + // The FunctionCallbackInfo object is a view located at the argc slot + const auto& info = *reinterpret_cast(&slot(Info::kArgcIndex)); functionTemplate->m_callback(info); - if (implicit_args.return_value.isEmpty()) { + TaggedPointer& return_value = slot(Info::kReturnValueIndex); + if (return_value.isEmpty()) { // callback forgot to set a return value, so return undefined return JSValue::encode(JSC::jsUndefined()); } else { - Local local_ret(&implicit_args.return_value); + Local local_ret(&return_value); return JSValue::encode(local_ret->localToJSValue()); } } diff --git a/src/jsc/bindings/v8/shim/GlobalInternals.h b/src/jsc/bindings/v8/shim/GlobalInternals.h index e55ac4af20f5..2387e6d7f6a6 100644 --- a/src/jsc/bindings/v8/shim/GlobalInternals.h +++ b/src/jsc/bindings/v8/shim/GlobalInternals.h @@ -1,6 +1,7 @@ #pragma once #include "BunClientData.h" +#include #include "../V8Isolate.h" #include "Oddball.h" @@ -12,6 +13,7 @@ class HandleScope; namespace shim { class HandleScopeBuffer; +struct Handle; class GlobalInternals : public JSC::JSCell { public: @@ -61,6 +63,23 @@ class GlobalInternals : public JSC::JSCell { HandleScope* currentHandleScope() const { return m_currentHandleScope; } + // Escape-slot reservations for live EscapableHandleScopes, keyed by the + // scope's stack address. The slot is reserved at scope construction (so it + // sits below any handles created inside the scope and survives + // HandleScope::DeleteExtensions) and consumed by EscapeSlot(). Entries are + // purged when their owning buffer clears (scope close) — a scope destroyed + // by V8's inline destructor without calling Escape() has no other hook — + // and a reused stack address simply overwrites the stale entry. + struct EscapeReservation { + Handle* handle { nullptr }; + HandleScopeBuffer* buffer { nullptr }; + }; + WTF::HashMap& escapeReservations() { return m_escapeReservations; } + void purgeEscapeReservations(HandleScopeBuffer* buffer) + { + m_escapeReservations.removeIf([buffer](auto& entry) { return entry.value.buffer == buffer; }); + } + void setCurrentHandleScope(HandleScope* handleScope) { m_currentHandleScope = handleScope; } Isolate* isolate() { return &m_isolate; } @@ -78,6 +97,7 @@ class GlobalInternals : public JSC::JSCell { JSC::LazyClassStructure m_functionTemplateStructure; JSC::LazyClassStructure m_v8FunctionStructure; HandleScope* m_currentHandleScope; + WTF::HashMap m_escapeReservations; JSC::LazyProperty m_globalHandles; Oddball m_undefinedValue; diff --git a/src/jsc/bindings/v8/shim/Handle.h b/src/jsc/bindings/v8/shim/Handle.h index 9164adce3cce..66716c9fd537 100644 --- a/src/jsc/bindings/v8/shim/Handle.h +++ b/src/jsc/bindings/v8/shim/Handle.h @@ -78,6 +78,12 @@ struct Handle { if (m_toV8Object.tag() == TaggedPointer::Tag::Smi) { return false; } + if (m_toV8Object.getPtr() != &m_object) { + // This slot was written directly by V8's inline CreateHandle code (see + // HandleScope::Extend): it aliases an ObjectLayout owned by some other handle (or an + // oddball/root), and that owner is the one responsible for keeping the cell alive. + return false; + } const Map* map_ptr = m_object.map(); // TODO(@190n) exhaustively switch on InstanceType if (map_ptr == &Map::object_map() || map_ptr == &Map::string_map()) { diff --git a/src/jsc/bindings/v8/shim/HandleScopeBuffer.cpp b/src/jsc/bindings/v8/shim/HandleScopeBuffer.cpp index cea327117c72..d67369990c8e 100644 --- a/src/jsc/bindings/v8/shim/HandleScopeBuffer.cpp +++ b/src/jsc/bindings/v8/shim/HandleScopeBuffer.cpp @@ -69,6 +69,37 @@ TaggedPointer* HandleScopeBuffer::createDoubleHandle(double value) return handle.slot(); } +TaggedPointer* HandleScopeBuffer::createRawHandleSlot() +{ + WTF::Locker locker { m_gcLock }; + m_storage.append(Handle {}); + TaggedPointer* slot = m_storage.last().slot(); + m_rawGrants.append({ slot, m_storage.size() - 1 }); + return slot; +} + +Handle* HandleScopeBuffer::reserveEscapeHandle() +{ + return &createEmptyHandle(); +} + +void HandleScopeBuffer::deleteGrantsBack(const uintptr_t* limit) +{ + WTF::Locker locker { m_gcLock }; + // Pop grants (and every handle created after each, which V8 semantics also + // scope to the closing inline HandleScope) until the newest remaining grant + // is the one the restored limit points one past — i.e. the last grant made + // before the closing scope opened. A null/foreign limit pops all grants. + while (!m_rawGrants.isEmpty() && m_rawGrants.last().first->asRawPtrLocation() + 1 != limit) { + size_t position = m_rawGrants.last().second; + m_rawGrants.removeLast(); + while (m_storage.size() > position) { + m_storage.last() = Handle(); + m_storage.removeLast(); + } + } +} + TaggedPointer* HandleScopeBuffer::createHandleFromExistingObject(TaggedPointer address, Isolate* isolate, Handle* reuseHandle) { int32_t smi; @@ -115,6 +146,7 @@ void HandleScopeBuffer::clear() handle = Handle(); } m_storage.clear(); + m_rawGrants.clear(); } } // namespace shim diff --git a/src/jsc/bindings/v8/shim/HandleScopeBuffer.h b/src/jsc/bindings/v8/shim/HandleScopeBuffer.h index f30c535c2315..fe7caeda2b03 100644 --- a/src/jsc/bindings/v8/shim/HandleScopeBuffer.h +++ b/src/jsc/bindings/v8/shim/HandleScopeBuffer.h @@ -43,6 +43,38 @@ class HandleScopeBuffer : public JSC::JSCell { TaggedPointer* createSmiHandle(int32_t smi); TaggedPointer* createDoubleHandle(double value); + // Reserve a slot whose value will be written directly by V8's inline CreateHandle code after + // HandleScope::Extend returns it. The written value is either a Smi or a pointer to an + // ObjectLayout owned by some other handle, so the handle backing this slot does not own (or + // visit) anything itself (see Handle::isCell). + TaggedPointer* createRawHandleSlot(); + + // Free every handle created after the raw slot whose address + 1 equals `limit` (the + // HandleScopeData::limit value V8's inline ~HandleScope just restored). Called from + // HandleScope::DeleteExtensions so per-iteration inline v8::HandleScopes inside a single + // native call reclaim their handles instead of accumulating until the enclosing Bun scope + // closes. + void deleteGrantsBack(const uintptr_t* limit); + + // Reserve an empty handle for an EscapableHandleScope's escape slot. + // Called from the scope's constructor so the slot's storage index is below + // every handle created inside the scope (deleteGrantsBack then can't sweep + // it); EscapeSlot() fills it via createHandleFromExistingObject(reuseHandle). + Handle* reserveEscapeHandle(); + + // HandleScopeData::{next,limit} as they were when the owning Bun + // HandleScope was pushed. ~HandleScope writes them back when it pops so + // the isolate's HandleScopeData never dangles into this (cleared) buffer + // — otherwise the next inline v8::HandleScope would snapshot a stale + // limit and its DeleteExtensions would sweep a foreign buffer's grants. + void saveHandleScopeData(uintptr_t* next, uintptr_t* limit) + { + m_savedNext = next; + m_savedLimit = limit; + } + uintptr_t* savedNext() const { return m_savedNext; } + uintptr_t* savedLimit() const { return m_savedLimit; } + // Given a tagged pointer from V8, create a handle around the same object or the same // numeric value // @@ -62,6 +94,14 @@ class HandleScopeBuffer : public JSC::JSCell { private: WTF::Lock m_gcLock; WTF::SegmentedVector m_storage; + // (slot, index in m_storage) for every createRawHandleSlot grant, in creation order. + // No inline capacity: in-cell inline Vector storage would leave stale ASAN + // container annotations behind (this cell type is swept without running + // C++ destructors), tripping container-overflow on cell reuse. The heap + // buffer is released in clear(). + WTF::Vector> m_rawGrants; + uintptr_t* m_savedNext { nullptr }; + uintptr_t* m_savedLimit { nullptr }; Handle& createEmptyHandle(); diff --git a/src/jsc/bindings/v8/v8_handle_scope_data.h b/src/jsc/bindings/v8/v8_handle_scope_data.h new file mode 100644 index 000000000000..2c6a74f5cf73 --- /dev/null +++ b/src/jsc/bindings/v8/v8_handle_scope_data.h @@ -0,0 +1,39 @@ +#pragma once + +// Access to the v8::internal::HandleScopeData that V8 14's inline HandleScope code +// (v8-local-handle.h) reads and writes directly at a fixed offset inside the Isolate +// (internal::Internals::GetHandleScopeData). That offset lands inside our Isolate's padding, +// which the Isolate constructor zeroes (matching real V8's HandleScopeData::Initialize()). +// +// The same warning as in real_v8.h applies: only include this in source files in the v8 +// directory, never in headers. + +#include "real_v8.h" +#include "V8Isolate.h" + +#include + +namespace v8 { +namespace shim { + +// Use the real V8 struct directly so the layout cannot drift: +// { Address* next; Address* limit; int level; int sealed_level; } where Address is uintptr_t. +using HandleScopeData = real_v8::internal::HandleScopeData; + +static_assert(std::is_same_v, + "V8's Address type is expected to be uintptr_t"); +static_assert(real_v8::internal::Internals::kIsolateHandleScopeDataOffset + >= offsetof(::v8::Isolate, m_padding), + "HandleScopeData would overlap the Isolate's leading fields"); +static_assert(real_v8::internal::Internals::kIsolateHandleScopeDataOffset + sizeof(HandleScopeData) + <= offsetof(::v8::Isolate, m_roots), + "HandleScopeData does not fit inside the Isolate's padding"); + +inline HandleScopeData* getHandleScopeData(Isolate* isolate) +{ + return reinterpret_cast( + reinterpret_cast(isolate) + real_v8::internal::Internals::kIsolateHandleScopeDataOffset); +} + +} // namespace shim +} // namespace v8 diff --git a/src/jsc/bindings/webcore/JSFetchHeaders.cpp b/src/jsc/bindings/webcore/JSFetchHeaders.cpp index 2d3531e7cedf..9df23192caa3 100644 --- a/src/jsc/bindings/webcore/JSFetchHeaders.cpp +++ b/src/jsc/bindings/webcore/JSFetchHeaders.cpp @@ -597,11 +597,19 @@ JSC_DEFINE_HOST_FUNCTION(jsFetchHeaders_getRawKeys, (JSC::JSGlobalObject * lexic } FetchHeaders& headers = thisObject->wrapped(); - JSArray* outArray = JSC::JSArray::create(vm, lexicalGlobalObject->arrayStructureForIndexingTypeDuringAllocation(JSC::ArrayWithContiguous), headers.size()); - - for (unsigned int i = 0; const auto& header : headers.internalHeaders()) { + // HTTPHeaderMap's iterator covers only the common and uncommon segments; + // set-cookie values live in their own segment, so size() (which counts + // every cookie) used to leave trailing holes in the array. Size for one + // entry per unique name and append "set-cookie" explicitly. + JSArray* outArray = JSC::JSArray::create(vm, lexicalGlobalObject->arrayStructureForIndexingTypeDuringAllocation(JSC::ArrayWithContiguous), headers.sizeAfterJoiningSetCookieHeader()); + + unsigned int i = 0; + for (const auto& header : headers.internalHeaders()) { outArray->putDirectIndex(lexicalGlobalObject, i++, jsString(vm, header.name())); } + if (!headers.internalHeaders().getSetCookieHeaders().isEmpty()) { + outArray->putDirectIndex(lexicalGlobalObject, i++, jsString(vm, WTF::httpHeaderNameDefaultCaseStringImpl(HTTPHeaderName::SetCookie))); + } RELEASE_AND_RETURN(scope, JSValue::encode(outArray)); } diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index 7e8b9c096c28..127829b1073e 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -5144,12 +5144,15 @@ impl H2FrameParser { ); } let id = last_stream_arg.to_int32(); - if id < 0 && id as u32 > MAX_STREAM_ID { - return Err(global_object.throw(format_args!( - "Expected lastStreamId to be a number between 1 and 2147483647" - ))); + // Like Node's native goaway, a lastStreamId <= 0 means "use the + // actual last processed stream id" — Node's JS layer defaults the + // argument to 0 and relies on this correction (validateNumber + // imposes no range, so negative values reach this path too), and + // sending a literal 0 would tell the peer no streams were + // processed. + if id > 0 { + last_stream_id = id as u32; } - last_stream_id = u32::try_from(id).expect("int cast"); } if args_list.len >= 3 { let opaque_data_arg = args_list.ptr[2]; @@ -7279,12 +7282,34 @@ impl H2FrameParser { if end_stream { stream.end_after_headers = true; - stream.state = StreamState::HALF_CLOSED_LOCAL; if wait_for_trailers { + stream.state = StreamState::HALF_CLOSED_LOCAL; this.dispatch(JSH2FrameParser::Gc::onWantTrailers, stream.get_identifier()); return Ok(JSValue::js_number(stream_id as f64)); } + + // A HEADERS frame carrying END_STREAM half-closes our side; when + // the peer already half-closed (a server responding after the + // request body finished) the stream is now fully closed. Mirror + // send_data / send_trailers: transition the state forward and + // dispatch onStreamEnd — without this a headers-only END_STREAM + // response regressed the state to HALF_CLOSED_LOCAL and never + // told JS, leaking the stream (and the session's connection + // count) until socket close. + let identifier = stream.get_identifier(); + identifier.ensure_still_alive(); + if stream.state == StreamState::HALF_CLOSED_REMOTE { + stream.state = StreamState::CLOSED; + stream.free_resources::(this); + } else { + stream.state = StreamState::HALF_CLOSED_LOCAL; + } + this.dispatch_with_extra( + JSH2FrameParser::Gc::onStreamEnd, + identifier, + JSValue::js_number(stream.state as u8 as f64), + ); } else { stream.wait_for_trailers = wait_for_trailers; } diff --git a/src/runtime/napi/napi_body.rs b/src/runtime/napi/napi_body.rs index 600d9dcfac17..c85396748d4e 100644 --- a/src/runtime/napi/napi_body.rs +++ b/src/runtime/napi/napi_body.rs @@ -2974,6 +2974,8 @@ mod v8_api { pub(super) fn _ZN4node28RemoveEnvironmentCleanupHookEPN2v87IsolateEPFvPvES3_() -> *mut c_void; pub(super) fn _ZN2v86Number3NewEPNS_7IsolateEd() -> *mut c_void; pub(super) fn _ZNK2v86Number5ValueEv() -> *mut c_void; + pub(super) fn _ZN2v86Number12NewFromInt32EPNS_7IsolateEi() -> *mut c_void; + pub(super) fn _ZN2v86Number13NewFromUint32EPNS_7IsolateEj() -> *mut c_void; pub(super) fn _ZN2v86String11NewFromUtf8EPNS_7IsolateEPKcNS_13NewStringTypeEi() -> *mut c_void; pub(super) fn _ZNK2v86String9WriteUtf8EPNS_7IsolateEPciPii() -> *mut c_void; @@ -2981,6 +2983,8 @@ mod v8_api { pub(super) fn _ZNK2v86String6LengthEv() -> *mut c_void; pub(super) fn _ZN2v88External3NewEPNS_7IsolateEPv() -> *mut c_void; pub(super) fn _ZNK2v88External5ValueEv() -> *mut c_void; + pub(super) fn _ZN2v88External3NewEPNS_7IsolateEPvt() -> *mut c_void; + pub(super) fn _ZNK2v88External5ValueEt() -> *mut c_void; pub(super) fn _ZN2v86Object3NewEPNS_7IsolateE() -> *mut c_void; pub(super) fn _ZN2v86Object3SetENS_5LocalINS_7ContextEEENS1_INS_5ValueEEES5_() -> *mut c_void; pub(super) fn _ZN2v86Object3SetENS_5LocalINS_7ContextEEEjNS1_INS_5ValueEEE() -> *mut c_void; @@ -2989,6 +2993,14 @@ mod v8_api { pub(super) fn _ZN2v86Object3GetENS_5LocalINS_7ContextEEENS1_INS_5ValueEEE() -> *mut c_void; pub(super) fn _ZN2v86Object3GetENS_5LocalINS_7ContextEEEj() -> *mut c_void; pub(super) fn _ZN2v811HandleScope12CreateHandleEPNS_8internal7IsolateEm() -> *mut c_void; + pub(super) fn _ZN2v811HandleScope12CreateHandleEPNS_7IsolateEm() -> *mut c_void; + pub(super) fn _ZN2v811HandleScope10InitializeEPNS_7IsolateE() -> *mut c_void; + pub(super) fn _ZNK2v85Value16QuickIsUndefinedEv() -> *mut c_void; + pub(super) fn _ZNK2v85Value11QuickIsNullEv() -> *mut c_void; + pub(super) fn _ZNK2v85Value22QuickIsNullOrUndefinedEv() -> *mut c_void; + pub(super) fn _ZNK2v85Value13QuickIsStringEv() -> *mut c_void; + pub(super) fn _ZN2v811HandleScope6ExtendEPNS_7IsolateE() -> *mut c_void; + pub(super) fn _ZN2v811HandleScope16DeleteExtensionsEPNS_7IsolateE() -> *mut c_void; pub(super) fn _ZN2v811HandleScopeC1EPNS_7IsolateE() -> *mut c_void; pub(super) fn _ZN2v811HandleScopeD1Ev() -> *mut c_void; pub(super) fn _ZN2v811HandleScopeD2Ev() -> *mut c_void; @@ -3040,6 +3052,10 @@ mod v8_api { pub(super) fn _ZNK2v86String17IsExternalTwoByteEv() -> *mut c_void; pub(super) fn _ZNK2v86String9IsOneByteEv() -> *mut c_void; pub(super) fn _ZNK2v86String19ContainsOnlyOneByteEv() -> *mut c_void; + pub(super) fn _ZNK2v86String7WriteV2EPNS_7IsolateEjjPti() -> *mut c_void; + pub(super) fn _ZNK2v86String14WriteOneByteV2EPNS_7IsolateEjjPhi() -> *mut c_void; + pub(super) fn _ZNK2v86String11WriteUtf8V2EPNS_7IsolateEPcmiPm() -> *mut c_void; + pub(super) fn _ZNK2v86String12Utf8LengthV2EPNS_7IsolateE() -> *mut c_void; pub(super) fn _ZN2v812api_internal18GlobalizeReferenceEPNS_8internal7IsolateEm() -> *mut c_void; pub(super) fn _ZN2v812api_internal13DisposeGlobalEPm() -> *mut c_void; @@ -3087,6 +3103,10 @@ mod v8_api { pub(super) fn v8_Number_New() -> *mut c_void; #[link_name = "?Value@Number@v8@@QEBANXZ"] pub(super) fn v8_Number_Value() -> *mut c_void; + #[link_name = "?NewFromInt32@Number@v8@@CA?AV?$Local@VNumber@v8@@@2@PEAVIsolate@2@H@Z"] + pub(super) fn v8_Number_NewFromInt32() -> *mut c_void; + #[link_name = "?NewFromUint32@Number@v8@@CA?AV?$Local@VNumber@v8@@@2@PEAVIsolate@2@I@Z"] + pub(super) fn v8_Number_NewFromUint32() -> *mut c_void; #[link_name = "?NewFromUtf8@String@v8@@SA?AV?$MaybeLocal@VString@v8@@@2@PEAVIsolate@2@PEBDW4NewStringType@2@H@Z"] pub(super) fn v8_String_NewFromUtf8() -> *mut c_void; #[link_name = "?WriteUtf8@String@v8@@QEBAHPEAVIsolate@2@PEADHPEAHH@Z"] @@ -3099,6 +3119,10 @@ mod v8_api { pub(super) fn v8_External_New() -> *mut c_void; #[link_name = "?Value@External@v8@@QEBAPEAXXZ"] pub(super) fn v8_External_Value() -> *mut c_void; + #[link_name = "?New@External@v8@@SA?AV?$Local@VExternal@v8@@@2@PEAVIsolate@2@PEAXG@Z"] + pub(super) fn v8_External_New_tagged() -> *mut c_void; + #[link_name = "?Value@External@v8@@QEBAPEAXG@Z"] + pub(super) fn v8_External_Value_tagged() -> *mut c_void; #[link_name = "?New@Object@v8@@SA?AV?$Local@VObject@v8@@@2@PEAVIsolate@2@@Z"] pub(super) fn v8_Object_New() -> *mut c_void; #[link_name = "?Set@Object@v8@@QEAA?AV?$Maybe@_N@2@V?$Local@VContext@v8@@@2@V?$Local@VValue@v8@@@2@1@Z"] @@ -3115,6 +3139,10 @@ mod v8_api { pub(super) fn v8_Object_Get_key() -> *mut c_void; #[link_name = "?CreateHandle@HandleScope@v8@@KAPEA_KPEAVIsolate@internal@2@_K@Z"] pub(super) fn v8_HandleScope_CreateHandle() -> *mut c_void; + #[link_name = "?Extend@HandleScope@v8@@CAPEA_KPEAVIsolate@2@@Z"] + pub(super) fn v8_HandleScope_Extend() -> *mut c_void; + #[link_name = "?DeleteExtensions@HandleScope@v8@@AEAAXPEAVIsolate@2@@Z"] + pub(super) fn v8_HandleScope_DeleteExtensions() -> *mut c_void; #[link_name = "??0HandleScope@v8@@QEAA@PEAVIsolate@1@@Z"] pub(super) fn v8_HandleScope_ctor() -> *mut c_void; #[link_name = "??1HandleScope@v8@@QEAA@XZ"] @@ -3205,6 +3233,14 @@ mod v8_api { pub(super) fn v8_String_Utf8Length() -> *mut c_void; #[link_name = "?ContainsOnlyOneByte@String@v8@@QEBA_NXZ"] pub(super) fn v8_String_ContainsOnlyOneByte() -> *mut c_void; + #[link_name = "?WriteV2@String@v8@@QEBAXPEAVIsolate@2@IIPEAGH@Z"] + pub(super) fn v8_String_WriteV2() -> *mut c_void; + #[link_name = "?WriteOneByteV2@String@v8@@QEBAXPEAVIsolate@2@IIPEAEH@Z"] + pub(super) fn v8_String_WriteOneByteV2() -> *mut c_void; + #[link_name = "?WriteUtf8V2@String@v8@@QEBA_KPEAVIsolate@2@PEAD_KHPEA_K@Z"] + pub(super) fn v8_String_WriteUtf8V2() -> *mut c_void; + #[link_name = "?Utf8LengthV2@String@v8@@QEBA_KPEAVIsolate@2@@Z"] + pub(super) fn v8_String_Utf8LengthV2() -> *mut c_void; #[link_name = "?GlobalizeReference@api_internal@v8@@YAPEA_KPEAVIsolate@internal@2@_K@Z"] pub(super) fn v8_api_internal_GlobalizeReference() -> *mut c_void; #[link_name = "?DisposeGlobal@api_internal@v8@@YAXPEA_K@Z"] @@ -4082,10 +4118,13 @@ pub fn fix_dead_code_elimination() { _ZN4node25AddEnvironmentCleanupHookEPN2v87IsolateEPFvPvES3_, _ZN4node28RemoveEnvironmentCleanupHookEPN2v87IsolateEPFvPvES3_, _ZN2v86Number3NewEPNS_7IsolateEd, _ZNK2v86Number5ValueEv, + _ZN2v86Number12NewFromInt32EPNS_7IsolateEi, + _ZN2v86Number13NewFromUint32EPNS_7IsolateEj, _ZN2v86String11NewFromUtf8EPNS_7IsolateEPKcNS_13NewStringTypeEi, _ZNK2v86String9WriteUtf8EPNS_7IsolateEPciPii, _ZN2v812api_internal12ToLocalEmptyEv, _ZNK2v86String6LengthEv, _ZN2v88External3NewEPNS_7IsolateEPv, _ZNK2v88External5ValueEv, _ZN2v86Object3NewEPNS_7IsolateE, + _ZN2v88External3NewEPNS_7IsolateEPvt, _ZNK2v88External5ValueEt, _ZN2v86Object3SetENS_5LocalINS_7ContextEEENS1_INS_5ValueEEES5_, _ZN2v86Object3SetENS_5LocalINS_7ContextEEEjNS1_INS_5ValueEEE, _ZN2v86Object16SetInternalFieldEiNS_5LocalINS_4DataEEE, @@ -4093,6 +4132,14 @@ pub fn fix_dead_code_elimination() { _ZN2v86Object3GetENS_5LocalINS_7ContextEEENS1_INS_5ValueEEE, _ZN2v86Object3GetENS_5LocalINS_7ContextEEEj, _ZN2v811HandleScope12CreateHandleEPNS_8internal7IsolateEm, + _ZN2v811HandleScope12CreateHandleEPNS_7IsolateEm, + _ZN2v811HandleScope10InitializeEPNS_7IsolateE, + _ZNK2v85Value16QuickIsUndefinedEv, + _ZNK2v85Value11QuickIsNullEv, + _ZNK2v85Value22QuickIsNullOrUndefinedEv, + _ZNK2v85Value13QuickIsStringEv, + _ZN2v811HandleScope6ExtendEPNS_7IsolateE, + _ZN2v811HandleScope16DeleteExtensionsEPNS_7IsolateE, _ZN2v811HandleScopeC1EPNS_7IsolateE, _ZN2v811HandleScopeD1Ev, _ZN2v811HandleScopeD2Ev, _ZN2v816FunctionTemplate11GetFunctionENS_5LocalINS_7ContextEEE, @@ -4123,6 +4170,10 @@ pub fn fix_dead_code_elimination() { _ZNK2v86String10Utf8LengthEPNS_7IsolateE, _ZNK2v86String10IsExternalEv, _ZNK2v86String17IsExternalOneByteEv, _ZNK2v86String17IsExternalTwoByteEv, _ZNK2v86String9IsOneByteEv, _ZNK2v86String19ContainsOnlyOneByteEv, + _ZNK2v86String7WriteV2EPNS_7IsolateEjjPti, + _ZNK2v86String14WriteOneByteV2EPNS_7IsolateEjjPhi, + _ZNK2v86String11WriteUtf8V2EPNS_7IsolateEPcmiPm, + _ZNK2v86String12Utf8LengthV2EPNS_7IsolateE, _ZN2v812api_internal18GlobalizeReferenceEPNS_8internal7IsolateEm, _ZN2v812api_internal13DisposeGlobalEPm, _ZN2v812api_internal23GetFunctionTemplateDataEPNS_7IsolateENS_5LocalINS_4DataEEE, @@ -4142,12 +4193,16 @@ pub fn fix_dead_code_elimination() { node_RemoveEnvironmentCleanupHook, v8_Number_New, v8_Number_Value, + v8_Number_NewFromInt32, + v8_Number_NewFromUint32, v8_String_NewFromUtf8, v8_String_WriteUtf8, v8_api_internal_ToLocalEmpty, v8_String_Length, v8_External_New, v8_External_Value, + v8_External_New_tagged, + v8_External_Value_tagged, v8_Object_New, v8_Object_Set_key, v8_Object_Set_index, @@ -4156,6 +4211,8 @@ pub fn fix_dead_code_elimination() { v8_Object_Get_index, v8_Object_Get_key, v8_HandleScope_CreateHandle, + v8_HandleScope_Extend, + v8_HandleScope_DeleteExtensions, v8_HandleScope_ctor, v8_HandleScope_dtor, v8_FunctionTemplate_GetFunction, @@ -4201,6 +4258,10 @@ pub fn fix_dead_code_elimination() { v8_String_IsOneByte, v8_String_Utf8Length, v8_String_ContainsOnlyOneByte, + v8_String_WriteV2, + v8_String_WriteOneByteV2, + v8_String_WriteUtf8V2, + v8_String_Utf8LengthV2, v8_api_internal_GlobalizeReference, v8_api_internal_DisposeGlobal, v8_api_internal_GetFunctionTemplateData, diff --git a/src/symbols.def b/src/symbols.def index dc727488e86e..ce1042f2ff97 100644 --- a/src/symbols.def +++ b/src/symbols.def @@ -580,12 +580,20 @@ EXPORTS ?RemoveEnvironmentCleanupHook@node@@YAXPEAVIsolate@v8@@P6AXPEAX@Z1@Z ?New@Number@v8@@SA?AV?$Local@VNumber@v8@@@2@PEAVIsolate@2@N@Z ?Value@Number@v8@@QEBANXZ + ?NewFromInt32@Number@v8@@CA?AV?$Local@VNumber@v8@@@2@PEAVIsolate@2@H@Z + ?NewFromUint32@Number@v8@@CA?AV?$Local@VNumber@v8@@@2@PEAVIsolate@2@I@Z ?NewFromUtf8@String@v8@@SA?AV?$MaybeLocal@VString@v8@@@2@PEAVIsolate@2@PEBDW4NewStringType@2@H@Z ?WriteUtf8@String@v8@@QEBAHPEAVIsolate@2@PEADHPEAHH@Z + ?WriteV2@String@v8@@QEBAXPEAVIsolate@2@IIPEAGH@Z + ?WriteOneByteV2@String@v8@@QEBAXPEAVIsolate@2@IIPEAEH@Z + ?WriteUtf8V2@String@v8@@QEBA_KPEAVIsolate@2@PEAD_KHPEA_K@Z + ?Utf8LengthV2@String@v8@@QEBA_KPEAVIsolate@2@@Z ?ToLocalEmpty@api_internal@v8@@YAXXZ ?Length@String@v8@@QEBAHXZ ?New@External@v8@@SA?AV?$Local@VExternal@v8@@@2@PEAVIsolate@2@PEAX@Z ?Value@External@v8@@QEBAPEAXXZ + ?New@External@v8@@SA?AV?$Local@VExternal@v8@@@2@PEAVIsolate@2@PEAXG@Z + ?Value@External@v8@@QEBAPEAXG@Z ?New@Object@v8@@SA?AV?$Local@VObject@v8@@@2@PEAVIsolate@2@@Z ?Set@Object@v8@@QEAA?AV?$Maybe@_N@2@V?$Local@VContext@v8@@@2@V?$Local@VValue@v8@@@2@1@Z ?Set@Object@v8@@QEAA?AV?$Maybe@_N@2@V?$Local@VContext@v8@@@2@IV?$Local@VValue@v8@@@2@@Z @@ -594,6 +602,14 @@ EXPORTS ?SetInternalField@Object@v8@@QEAAXHV?$Local@VData@v8@@@2@@Z ?SlowGetInternalField@Object@v8@@AEAA?AV?$Local@VData@v8@@@2@H@Z ?CreateHandle@HandleScope@v8@@KAPEA_KPEAVIsolate@internal@2@_K@Z + ?CreateHandle@HandleScope@v8@@KAPEA_KPEAVIsolate@2@_K@Z + ?Initialize@HandleScope@v8@@IEAAXPEAVIsolate@2@@Z + ?QuickIsUndefined@Value@v8@@AEBA_NXZ + ?QuickIsNull@Value@v8@@AEBA_NXZ + ?QuickIsNullOrUndefined@Value@v8@@AEBA_NXZ + ?QuickIsString@Value@v8@@AEBA_NXZ + ?Extend@HandleScope@v8@@CAPEA_KPEAVIsolate@2@@Z + ?DeleteExtensions@HandleScope@v8@@AEAAXPEAVIsolate@2@@Z ??0HandleScope@v8@@QEAA@PEAVIsolate@1@@Z ??1HandleScope@v8@@QEAA@XZ ?GetFunction@FunctionTemplate@v8@@QEAA?AV?$MaybeLocal@VFunction@v8@@@2@V?$Local@VContext@v8@@@2@@Z @@ -620,6 +636,10 @@ EXPORTS ??0EscapableHandleScope@v8@@QEAA@PEAVIsolate@1@@Z ?IsObject@Value@v8@@QEBA_NXZ ?IsNumber@Value@v8@@QEBA_NXZ + ?IsArray@Value@v8@@QEBA_NXZ + ?IsBigInt@Value@v8@@QEBA_NXZ + ?IsInt32@Value@v8@@QEBA_NXZ + ?IsMap@Value@v8@@QEBA_NXZ ?IsUint32@Value@v8@@QEBA_NXZ ?Uint32Value@Value@v8@@QEBA?AV?$Maybe@I@2@V?$Local@VContext@v8@@@2@@Z ?IsUndefined@Value@v8@@QEBA_NXZ diff --git a/src/symbols.dyn b/src/symbols.dyn index b44c02651bd0..ded971cdc0e0 100644 --- a/src/symbols.dyn +++ b/src/symbols.dyn @@ -1,5 +1,13 @@ { __ZN2v811HandleScope12CreateHandleEPNS_8internal7IsolateEm; + __ZN2v811HandleScope12CreateHandleEPNS_7IsolateEm; + __ZN2v811HandleScope10InitializeEPNS_7IsolateE; + __ZNK2v85Value16QuickIsUndefinedEv; + __ZNK2v85Value11QuickIsNullEv; + __ZNK2v85Value22QuickIsNullOrUndefinedEv; + __ZNK2v85Value13QuickIsStringEv; + __ZN2v811HandleScope16DeleteExtensionsEPNS_7IsolateE; + __ZN2v811HandleScope6ExtendEPNS_7IsolateE; __ZN2v811HandleScopeC1EPNS_7IsolateE; __ZN2v811HandleScopeD1Ev; __ZN2v811HandleScopeD2Ev; @@ -25,6 +33,8 @@ __ZN2v85Array3NewENS_5LocalINS_7ContextEEEmSt8functionIFNS_10MaybeLocalINS_5ValueEEEvEE; __ZN2v85Array7IterateENS_5LocalINS_7ContextEEEPFNS0_14CallbackResultEjNS1_INS_5ValueEEEPvES7_; __ZN2v85Array9CheckCastEPNS_5ValueE; + __ZN2v86Number12NewFromInt32EPNS_7IsolateEi; + __ZN2v86Number13NewFromUint32EPNS_7IsolateEj; __ZN2v86Number3NewEPNS_7IsolateEd; __ZN2v86Object16GetInternalFieldEi; __ZN2v86Object16SetInternalFieldEiNS_5LocalINS_4DataEEE; @@ -42,6 +52,7 @@ __ZN2v87Isolate13TryGetCurrentEv; __ZN2v87Isolate17GetCurrentContextEv; __ZN2v88External3NewEPNS_7IsolateEPv; + __ZN2v88External3NewEPNS_7IsolateEPvt; __ZN2v88Function7SetNameENS_5LocalINS_6StringEEE; __ZN2v88internal35IsolateFromNeverReadOnlySpaceObjectEm; __ZN3JSC9CallFrame13describeFrameEv; @@ -70,13 +81,18 @@ __ZNK2v86Number5ValueEv; __ZNK2v86String10IsExternalEv; __ZNK2v86String10Utf8LengthEPNS_7IsolateE; + __ZNK2v86String11WriteUtf8V2EPNS_7IsolateEPcmiPm; + __ZNK2v86String12Utf8LengthV2EPNS_7IsolateE; + __ZNK2v86String14WriteOneByteV2EPNS_7IsolateEjjPhi; __ZNK2v86String17IsExternalOneByteEv; __ZNK2v86String17IsExternalTwoByteEv; __ZNK2v86String19ContainsOnlyOneByteEv; __ZNK2v86String6LengthEv; + __ZNK2v86String7WriteV2EPNS_7IsolateEjjPti; __ZNK2v86String9IsOneByteEv; __ZNK2v86String9WriteUtf8EPNS_7IsolateEPciPii; __ZNK2v87Boolean5ValueEv; + __ZNK2v88External5ValueEt; __ZNK2v88External5ValueEv; __ZNK2v88Function7GetNameEv; _dumpBtjsTrace; diff --git a/src/symbols.txt b/src/symbols.txt index 462b099dd071..d314660ec934 100644 --- a/src/symbols.txt +++ b/src/symbols.txt @@ -1,4 +1,12 @@ __ZN2v811HandleScope12CreateHandleEPNS_8internal7IsolateEm +__ZN2v811HandleScope12CreateHandleEPNS_7IsolateEm +__ZN2v811HandleScope10InitializeEPNS_7IsolateE +__ZNK2v85Value16QuickIsUndefinedEv +__ZNK2v85Value11QuickIsNullEv +__ZNK2v85Value22QuickIsNullOrUndefinedEv +__ZNK2v85Value13QuickIsStringEv +__ZN2v811HandleScope16DeleteExtensionsEPNS_7IsolateE +__ZN2v811HandleScope6ExtendEPNS_7IsolateE __ZN2v811HandleScopeC1EPNS_7IsolateE __ZN2v811HandleScopeD1Ev __ZN2v811HandleScopeD2Ev @@ -24,6 +32,8 @@ __ZN2v85Array3NewEPNS_7IsolateEi __ZN2v85Array3NewENS_5LocalINS_7ContextEEEmNSt3__18functionIFNS_10MaybeLocalINS_5ValueEEEvEEE __ZN2v85Array7IterateENS_5LocalINS_7ContextEEEPFNS0_14CallbackResultEjNS1_INS_5ValueEEEPvES7_ __ZN2v85Array9CheckCastEPNS_5ValueE +__ZN2v86Number12NewFromInt32EPNS_7IsolateEi +__ZN2v86Number13NewFromUint32EPNS_7IsolateEj __ZN2v86Number3NewEPNS_7IsolateEd __ZN2v86Object16GetInternalFieldEi __ZN2v86Object16SetInternalFieldEiNS_5LocalINS_4DataEEE @@ -41,6 +51,7 @@ __ZN2v87Isolate10GetCurrentEv __ZN2v87Isolate13TryGetCurrentEv __ZN2v87Isolate17GetCurrentContextEv __ZN2v88External3NewEPNS_7IsolateEPv +__ZN2v88External3NewEPNS_7IsolateEPvt __ZN2v88Function7SetNameENS_5LocalINS_6StringEEE __ZN2v88internal35IsolateFromNeverReadOnlySpaceObjectEm __ZN3JSC9CallFrame13describeFrameEv @@ -69,13 +80,18 @@ __ZNK2v85Value12StrictEqualsENS_5LocalIS0_EE __ZNK2v86Number5ValueEv __ZNK2v86String10IsExternalEv __ZNK2v86String10Utf8LengthEPNS_7IsolateE +__ZNK2v86String11WriteUtf8V2EPNS_7IsolateEPcmiPm +__ZNK2v86String12Utf8LengthV2EPNS_7IsolateE +__ZNK2v86String14WriteOneByteV2EPNS_7IsolateEjjPhi __ZNK2v86String17IsExternalOneByteEv __ZNK2v86String17IsExternalTwoByteEv __ZNK2v86String19ContainsOnlyOneByteEv __ZNK2v86String6LengthEv +__ZNK2v86String7WriteV2EPNS_7IsolateEjjPti __ZNK2v86String9IsOneByteEv __ZNK2v86String9WriteUtf8EPNS_7IsolateEPciPii __ZNK2v87Boolean5ValueEv +__ZNK2v88External5ValueEt __ZNK2v88External5ValueEv __ZNK2v88Function7GetNameEv _dumpBtjsTrace diff --git a/test/cli/install/migration/complex-workspace.test.ts b/test/cli/install/migration/complex-workspace.test.ts index ce411c328d1a..2c8eb4f5f8d5 100644 --- a/test/cli/install/migration/complex-workspace.test.ts +++ b/test/cli/install/migration/complex-workspace.test.ts @@ -52,7 +52,13 @@ test("the install succeeds", async () => { throw new Error("Failed to install"); } - subprocess = Bun.spawn([bunExe(), "install"], { + // On Windows CI, sharp's install script falls back to a node-gyp source + // build (no win32-arm64 prebuilt), which the system clang-cl-built Node 26 + // breaks (its process.config leaks thin-LTO flags that MSVC's link.exe + // rejects). This test exercises lockfile migration, not lifecycle scripts, + // so skip them there. + const installArgs = process.platform === "win32" ? [bunExe(), "install", "--ignore-scripts"] : [bunExe(), "install"]; + subprocess = Bun.spawn(installArgs, { env: bunEnv, cwd, stdio: ["inherit", "inherit", "inherit"], diff --git a/test/harness.ts b/test/harness.ts index ba13c607199b..5f1538db5de8 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -5,6 +5,7 @@ * without always needing to run `bun install` in development. */ +import * as numeric from "_util/numeric.ts"; import { gc as bunGC, sleepSync, spawnSync, unsafe, which, write } from "bun"; import { heapStats } from "bun:jsc"; import { beforeAll, describe, expect } from "bun:test"; @@ -13,7 +14,6 @@ import { readdir, rm, writeFile } from "fs/promises"; import fs, { closeSync, openSync, rmSync } from "node:fs"; import os from "node:os"; import { dirname, isAbsolute, join } from "path"; -import * as numeric from "_util/numeric.ts"; export const BREAKING_CHANGES_BUN_1_2 = false; @@ -126,6 +126,150 @@ export function nodeExe(): string | null { return which("node") || null; } +let abiMatchingNode: Promise | undefined; + +/** + * Path to a Node.js executable whose native-addon ABI (NODE_MODULE_VERSION, + * `process.versions.modules`) matches the Node version Bun reports. Addons + * that node-gyp compiles against Bun's reported headers can only load in such + * a Node. When the system Node's ABI differs (e.g. a machine whose installed + * Node lags the version Bun reports), the matching official build is + * downloaded once into a per-version directory under the OS temp dir and + * reused. + */ +export function nodeExeMatchingAbi(): Promise { + return (abiMatchingNode ??= findOrDownloadAbiMatchingNode()); +} + +async function findOrDownloadAbiMatchingNode(): Promise { + const system = nodeExe(); + if (system) { + const probe = Bun.spawnSync({ + cmd: [system, "-p", "process.versions.modules"], + env: bunEnv, + stdout: "pipe", + stderr: "ignore", + }); + if (probe.exitCode === 0 && probe.stdout.toString().trim() === process.versions.modules) { + return system; + } + } + + const version = process.versions.node; + const name = `node-v${version}-${isWindows ? "win" : process.platform}-${process.arch}`; + // Cache under the home directory: the machines that need the download (the + // persistent macOS fleet) then pay for it once ever, not once per boot. + const baseDir = join(os.homedir() || os.tmpdir(), ".cache", "bun-test-node"); + const dir = join(baseDir, name); + const exe = isWindows ? join(dir, "node.exe") : join(dir, "bin", "node"); + if (fs.existsSync(exe)) { + return exe; + } + + const archiveExt = isWindows ? "zip" : "tar.gz"; + const url = `https://nodejs.org/dist/v${version}/${name}.${archiveExt}`; + console.warn(`System node does not match ABI ${process.versions.modules}, downloading ${url}`); + // Download and extract under unique names, then atomically rename into + // place so concurrent test files (or a previous interrupted run) can't + // observe a half-extracted directory. + const stagingDir = join(baseDir, `staging-${process.pid}-${Date.now()}`); + fs.mkdirSync(stagingDir, { recursive: true }); + try { + const archive = join(stagingDir, `${name}.${archiveExt}`); + // Download with curl (ships on every CI platform, including Windows + // System32) rather than streaming through the runtime under test, and + // bound it so a stalled transfer fails instead of eating the hook + // timeout of whichever test file got here first. + const curl = (...args: string[]) => + Bun.spawnSync({ + cmd: ["curl", "-fsSL", "--retry", "3", "--max-time", "180", ...args], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const download = curl("-o", archive, url); + if (download.exitCode !== 0) { + throw new Error(`Failed to download ${url}: ${download.stderr.toString()}`); + } + // Verify against the official checksum manifest before executing anything + // from the archive. + const shasumsUrl = `https://nodejs.org/dist/v${version}/SHASUMS256.txt`; + const shasumsResult = curl(shasumsUrl); + if (shasumsResult.exitCode !== 0) { + throw new Error(`Failed to download ${shasumsUrl}: ${shasumsResult.stderr.toString()}`); + } + const expectedHash = shasumsResult.stdout + .toString() + .split("\n") + .find(line => line.endsWith(` ${name}.${archiveExt}`)) + ?.split(" ")[0]; + if (!expectedHash) { + throw new Error(`No checksum for ${name}.${archiveExt} in ${shasumsUrl}`); + } + const actualHash = new Bun.CryptoHasher("sha256").update(await Bun.file(archive).arrayBuffer()).digest("hex"); + if (actualHash !== expectedHash) { + throw new Error(`SHA-256 mismatch for ${url}: expected ${expectedHash}, got ${actualHash}`); + } + // bsdtar (shipped with Windows 10+) extracts zip archives too. + const tar = Bun.spawnSync({ + cmd: ["tar", "-xf", archive, "-C", stagingDir], + env: bunEnv, + stdout: "ignore", + stderr: "pipe", + }); + if (tar.exitCode !== 0) { + throw new Error(`Failed to extract ${archive}: ${tar.stderr.toString()}`); + } + try { + fs.renameSync(join(stagingDir, name), dir); + } catch (error) { + // A concurrent download may have won the rename; that copy is as good. + if (!fs.existsSync(exe)) throw error; + } + } finally { + fs.rmSync(stagingDir, { recursive: true, force: true }); + } + return exe; +} + +let canBuildNodeAddonsCached: boolean | undefined; + +/** + * Whether the system C++ toolchain can compile native addons against the + * Node headers Bun reports. Node >= 26 headers unconditionally include + * C++20's ``, which older Apple Xcode/CLT libc++ versions + * do not ship — real Node 26 has the same minimum-toolchain requirement, so + * addon-building tests should skip (not fail) on such machines. + */ +export function canBuildNodeAddons(): boolean { + if (canBuildNodeAddonsCached === undefined) { + if (!isMacOS) { + // Linux and Windows CI toolchains are provisioned by the bootstrap + // scripts in lockstep with the reported Node version; only macOS test + // boxes have independently-managed Xcode installs. + canBuildNodeAddonsCached = true; + } else { + const dir = fs.mkdtempSync(join(os.tmpdir(), "bun-addon-toolchain-probe-")); + try { + const probeFile = join(dir, "probe.cpp"); + fs.writeFileSync(probeFile, "#include \nint main() { return 0; }\n"); + const probe = Bun.spawnSync({ + cmd: ["c++", "-std=gnu++20", "-fsyntax-only", probeFile], + env: bunEnv, + stdout: "ignore", + stderr: "ignore", + }); + canBuildNodeAddonsCached = probe.exitCode === 0; + } catch { + canBuildNodeAddonsCached = false; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + } + return canBuildNodeAddonsCached; +} + export function shellExe(): string { return isWindows ? "pwsh" : "bash"; } @@ -1997,3 +2141,35 @@ export function nodeModulesPackages(nodeModulesPath: string): string { return packages.join("\n"); } + +/** + * Env additions for `bun install` in tests whose dependencies trigger + * puppeteer's browser download. The dev-server-puppeteer launcher prefers a + * system Chromium when one is installed (CI bootstraps one on every Linux + * flavor), and several platforms have no Chrome for Testing build at all + * (linux-arm64, windows-arm64 CI). Skip the download there: it wastes ~150MB + * per run, and a half-extracted download left in the shared agent cache by an + * earlier failed run makes @puppeteer/browsers refuse every later install + * ("browser folder exists but the executable is missing"). + */ +export function getPuppeteerInstallEnv(): Record { + const hasSystemChromium = !!( + Bun.which("chromium-browser") || + Bun.which("chromium") || + Bun.which("chrome") || + Bun.which("google-chrome-stable") || + Bun.which("google-chrome") + ); + const skipBrowserDownload = + hasSystemChromium || + (process.platform === "linux" && process.arch === "arm64") || + (process.platform === "win32" && (!!process.env.CI || !!process.env.BUILDKITE)); + if (skipBrowserDownload) { + return { PUPPETEER_SKIP_DOWNLOAD: "1" }; + } + // No system browser: download into a fresh per-run cache instead of the + // shared agent-global one — a half-extracted download left there by an + // earlier failed run otherwise blocks every later install. Pass the same + // env to whatever later launches puppeteer so it finds the browser. + return { PUPPETEER_CACHE_DIR: tmpdirSync("puppeteer-cache") }; +} diff --git a/test/integration/next-pages/test/dev-server-puppeteer.ts b/test/integration/next-pages/test/dev-server-puppeteer.ts index 9d4c0feaa8fd..87b6c7418c51 100644 --- a/test/integration/next-pages/test/dev-server-puppeteer.ts +++ b/test/integration/next-pages/test/dev-server-puppeteer.ts @@ -13,7 +13,13 @@ if (process.argv.length > 2) { url = process.argv[2]; } -const browserPath = which("chromium-browser") || which("chromium") || which("chrome") || undefined; +const browserPath = + which("chromium-browser") || + which("chromium") || + which("chrome") || + which("google-chrome-stable") || + which("google-chrome") || + undefined; if (!browserPath) { console.warn("Since a Chromium browser was not found, it will be downloaded by Puppeteer."); } @@ -22,16 +28,19 @@ if (!browserPath) { // macOS quarantines downloaded binaries. Remove the quarantine attribute // from all binaries, and also ensure they are executable. if (process.platform === "darwin") { - try { - const { execSync } = require("child_process"); - const cachePath = join(process.env.HOME || "~", ".cache", "puppeteer"); - // Remove quarantine from the entire puppeteer cache - execSync(`xattr -rd com.apple.quarantine "${cachePath}" 2>/dev/null || true`, { stdio: "ignore" }); - // Also ensure all chrome/chromium binaries in the cache are executable - execSync(`find "${cachePath}" -type f -name "Google Chrome for Testing" -exec chmod +x {} + 2>/dev/null || true`, { stdio: "ignore" }); - execSync(`find "${cachePath}" -type f -name "chrome-headless-shell" -exec chmod +x {} + 2>/dev/null || true`, { stdio: "ignore" }); - execSync(`find "${cachePath}" -type f -name "chrome" -exec chmod +x {} + 2>/dev/null || true`, { stdio: "ignore" }); - } catch {} + const cachePath = process.env.PUPPETEER_CACHE_DIR || join(process.env.HOME || "~", ".cache", "puppeteer"); + const { execFileSync } = require("child_process"); + const run = (file: string, args: string[]) => { + try { + execFileSync(file, args, { stdio: "ignore" }); + } catch {} + }; + // Remove quarantine from the entire puppeteer cache + run("xattr", ["-rd", "com.apple.quarantine", cachePath]); + // Also ensure all chrome/chromium binaries in the cache are executable + for (const name of ["Google Chrome for Testing", "chrome-headless-shell", "chrome"]) { + run("find", [cachePath, "-type", "f", "-name", name, "-exec", "chmod", "+x", "{}", "+"]); + } } const isMacOS = process.platform === "darwin"; diff --git a/test/integration/next-pages/test/dev-server-ssr-100.test.ts b/test/integration/next-pages/test/dev-server-ssr-100.test.ts index 2dbef083eb4b..0e24bc994324 100644 --- a/test/integration/next-pages/test/dev-server-ssr-100.test.ts +++ b/test/integration/next-pages/test/dev-server-ssr-100.test.ts @@ -6,13 +6,15 @@ import { cp, rm } from "fs/promises"; import PQueue from "p-queue"; import { join } from "path"; import { StringDecoder } from "string_decoder"; -import { bunEnv, bunExe, tmpdirSync, toMatchNodeModulesAt } from "../../../harness"; +import { bunEnv, bunExe, getPuppeteerInstallEnv, tmpdirSync, toMatchNodeModulesAt } from "../../../harness"; const { parseLockfile } = install_test_helpers; expect.extend({ toMatchNodeModulesAt }); let root = tmpdirSync(); +const puppeteerInstallEnv = getPuppeteerInstallEnv(); + beforeAll(async () => { await rm(root, { recursive: true, force: true }); await cp(join(import.meta.dir, "../"), root, { recursive: true, force: true }); @@ -93,7 +95,7 @@ async function startDevServer() { const install = Bun.spawnSync([bunExe(), "i"], { cwd: root, - env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(root, "bunstall") }, + env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(root, "bunstall"), ...puppeteerInstallEnv }, stdout: "inherit", stderr: "inherit", stdin: "inherit", diff --git a/test/integration/next-pages/test/dev-server.test.ts b/test/integration/next-pages/test/dev-server.test.ts index b59484744637..f5de4dae0371 100644 --- a/test/integration/next-pages/test/dev-server.test.ts +++ b/test/integration/next-pages/test/dev-server.test.ts @@ -5,13 +5,23 @@ import { copyFileSync } from "fs"; import { cp, rm } from "fs/promises"; import { join } from "path"; import { StringDecoder } from "string_decoder"; -import { bunEnv, bunExe, isCI, isWindows, tmpdirSync, toMatchNodeModulesAt } from "../../../harness"; +import { + bunEnv, + bunExe, + getPuppeteerInstallEnv, + isCI, + isWindows, + tmpdirSync, + toMatchNodeModulesAt, +} from "../../../harness"; const { parseLockfile } = install_test_helpers; expect.extend({ toMatchNodeModulesAt }); let root = tmpdirSync(); +const puppeteerInstallEnv = getPuppeteerInstallEnv(); + beforeAll(async () => { await rm(root, { recursive: true, force: true }); await cp(join(import.meta.dir, "../"), root, { recursive: true, force: true }); @@ -92,7 +102,7 @@ beforeAll(async () => { const install = Bun.spawnSync([bunExe(), "i"], { cwd: root, - env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(root, ".bun-install") }, + env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(root, ".bun-install"), ...puppeteerInstallEnv }, stdout: "inherit", stderr: "inherit", stdin: "inherit", @@ -150,7 +160,7 @@ test.skipIf(puppeteer_unsupported || (isWindows && isCI))( ({ exited, pid } = Bun.spawn([bunExe(), "test/dev-server-puppeteer.ts", baseUrl], { cwd: root, - env: bunEnv, + env: { ...bunEnv, ...puppeteerInstallEnv }, stdio: ["ignore", "inherit", "inherit"], })); diff --git a/test/integration/next-pages/test/next-build.test.ts b/test/integration/next-pages/test/next-build.test.ts index 6f3bbb3ab321..5245f80720eb 100644 --- a/test/integration/next-pages/test/next-build.test.ts +++ b/test/integration/next-pages/test/next-build.test.ts @@ -3,13 +3,15 @@ import { expect, test } from "bun:test"; import { copyFileSync, cpSync, promises as fs, readFileSync, rmSync } from "fs"; import { cp } from "fs/promises"; import { join } from "path"; -import { bunEnv, bunExe, isDebug, tmpdirSync, toMatchNodeModulesAt } from "../../../harness"; +import { bunEnv, bunExe, getPuppeteerInstallEnv, isDebug, tmpdirSync, toMatchNodeModulesAt } from "../../../harness"; const { parseLockfile } = install_test_helpers; expect.extend({ toMatchNodeModulesAt }); const root = join(import.meta.dir, "../"); +const puppeteerInstallEnv = getPuppeteerInstallEnv(); + async function tempDirToBuildIn() { const dir = tmpdirSync( "next-" + Math.ceil(performance.now() * 1000).toString(36) + Math.random().toString(36).substring(2, 8), @@ -32,7 +34,7 @@ async function tempDirToBuildIn() { const install = Bun.spawnSync([bunExe(), "i"], { cwd: dir, - env: bunEnv, + env: { ...bunEnv, ...puppeteerInstallEnv }, stdin: "inherit", stdout: "inherit", stderr: "inherit", diff --git a/test/js/bun/crypto/cipheriv-decipheriv.test.ts b/test/js/bun/crypto/cipheriv-decipheriv.test.ts index fd9cb7f2733e..af8211d02348 100644 --- a/test/js/bun/crypto/cipheriv-decipheriv.test.ts +++ b/test/js/bun/crypto/cipheriv-decipheriv.test.ts @@ -65,13 +65,21 @@ it("should encrypt & decrypt using streaming interface", () => { const key = randomBytes(32); const iv = randomBytes(16); + // Since Node 26, read() with no size returns one buffered chunk at a time, + // so drain the stream instead of assuming a single read returns everything. + const readAll = stream => { + const chunks = []; + for (let chunk; (chunk = stream.read()) !== null; ) chunks.push(chunk); + return Buffer.concat(chunks); + }; + const cipher = createCipheriv("aes-256-cbc", key, iv); cipher.end(plaintext); - let ciph = cipher.read(); + let ciph = readAll(cipher); const decipher = createDecipheriv("aes-256-cbc", key, iv); decipher.end(ciph); - let txt = decipher.read().toString("utf8"); + let txt = readAll(decipher).toString("utf8"); expect(txt).toBe(plaintext); }); diff --git a/test/js/node/crypto/crypto.test.ts b/test/js/node/crypto/crypto.test.ts index 16336cdcdaa2..16b4be3a91a9 100644 --- a/test/js/node/crypto/crypto.test.ts +++ b/test/js/node/crypto/crypto.test.ts @@ -258,16 +258,24 @@ it("should send cipher events in the right order", async () => { const key = Buffer.from("3fad401bb178066f201b55368712530229d6329a5e2c05f48ff36ca65792d21d", "hex"); const iv = Buffer.from("22371787d3e04a6589d8a1de50c81208", "hex"); + // Since Node 26, read() with no size returns one buffered chunk at a time, + // so drain the stream instead of assuming a single read returns everything. + function readAll(stream) { + const chunks = []; + for (let chunk; (chunk = stream.read()) !== null; ) chunks.push(chunk); + return Buffer.concat(chunks); + } + const cipher = crypto.createCipheriv("aes-256-cbc", key, iv); patchEmitter(cipher, "cipher"); cipher.end(plaintext); - let ciph = cipher.read(); + let ciph = readAll(cipher); console.log([1, ciph.toString("hex")]); const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv); patchEmitter(decipher, "decipher"); decipher.end(ciph); - let dciph = decipher.read(); + let dciph = readAll(decipher); console.log([2, dciph.toString("hex")]); let txt = dciph.toString("utf8"); @@ -286,12 +294,13 @@ it("should send cipher events in the right order", async () => { const err = await stderr.text(); expect(err).toBeEmpty(); const out = await stdout.text(); - // TODO: prefinish and readable (on both cipher and decipher) should be flipped - // This seems like a bug in our crypto code, which + // Matches Node 26 output for the same fixture (verified byte-for-byte + // modulo quote style). expect(out.split("\n")).toEqual([ `[ "cipher", "readable" ]`, `[ "cipher", "prefinish" ]`, `[ "cipher", "data" ]`, + `[ "cipher", "data" ]`, `[ 1, "dfb6b7e029be3ad6b090349ed75931f28f991b52ca9a89f5bf6f82fa1c87aa2d624bd77701dcddfcceaf3add7d66ce06ced17aebca4cb35feffc4b8b9008b3c4" ]`, `[ "decipher", "readable" ]`, `[ "decipher", "prefinish" ]`, diff --git a/test/js/node/http/node-http-parser.test.ts b/test/js/node/http/node-http-parser.test.ts index dd8adfe8f916..eab3610c1bd3 100644 --- a/test/js/node/http/node-http-parser.test.ts +++ b/test/js/node/http/node-http-parser.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; const { HTTPParser, ConnectionsList } = process.binding("http_parser"); +const { parsers } = require("node:_http_common"); const kOnHeaders = HTTPParser.kOnHeaders; const kOnHeadersComplete = HTTPParser.kOnHeadersComplete; @@ -248,3 +249,33 @@ describe("ConnectionsList", () => { expect(list.all()).toEqual([p1, p4, p3]); }); }); + +describe("parserOnHeaders maxHeaderPairs clamp (nodejs/node#61285)", () => { + test("only fills remaining capacity instead of pushing the whole batch", () => { + const parser = parsers.alloc(); + try { + const onHeaders = parser[kOnHeaders]; + parser._headers = ["x", "1"]; + parser._url = ""; + parser.maxHeaderPairs = 4; + + onHeaders.call(parser, ["a", "2", "b", "3"], ""); + expect(parser._headers).toEqual(["x", "1", "a", "2"]); + + // At capacity: nothing more is collected. + onHeaders.call(parser, ["c", "4"], ""); + expect(parser._headers).toEqual(["x", "1", "a", "2"]); + + // maxHeaderPairs <= 0 means no limit. + parser.maxHeaderPairs = 0; + onHeaders.call(parser, ["c", "4"], ""); + expect(parser._headers).toEqual(["x", "1", "a", "2", "c", "4"]); + + parser.maxHeaderPairs = -1; + onHeaders.call(parser, ["d", "5"], ""); + expect(parser._headers).toEqual(["x", "1", "a", "2", "c", "4", "d", "5"]); + } finally { + parser.close(); + } + }); +}); diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index 6c3b2a1d25ae..d2963164d58f 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -2252,3 +2252,209 @@ it("http.request rejects an options.port that is not a valid port number", async server.close(); } }); + +// Node.js v26 removed res.writeHeader (DEP0063 end-of-life, nodejs/node#60635). +it("ServerResponse.prototype.writeHeader was removed (DEP0063 EOL)", () => { + expect("writeHeader" in ServerResponse.prototype).toBe(false); +}); + +it("setHeaders stores an empty set-cookie array (nodejs/node#59734)", () => { + const msg = new OutgoingMessage(); + msg.setHeaders(new Map([["set-cookie", []]])); + expect(msg.getHeader("set-cookie")).toEqual([]); + expect(msg.hasHeader("set-cookie")).toBe(true); + expect(msg.getHeaders()["set-cookie"]).toEqual([]); + expect(msg.getHeaderNames()).toContain("set-cookie"); + expect(msg.getRawHeaderNames()).toContain("set-cookie"); + msg.removeHeader("set-cookie"); + expect(msg.getHeader("set-cookie")).toBeUndefined(); + expect(msg.hasHeader("set-cookie")).toBe(false); + + // Headers without a set-cookie entry never call setHeader("set-cookie", ...) + const msg2 = new OutgoingMessage(); + msg2.setHeaders(new Map([["x-test", "1"]])); + expect(msg2.getHeader("set-cookie")).toBeUndefined(); + expect(msg2.getHeader("x-test")).toBe("1"); + + // getRawHeaderNames preserves the original casing, like Node. + const msg3 = new OutgoingMessage(); + msg3.setHeader("Set-Cookie", []); + expect(msg3.getRawHeaderNames()).toEqual(["Set-Cookie"]); + expect(msg3.getHeaderNames()).toEqual(["set-cookie"]); + // The Bun-specific headers accessor agrees with getHeaders(). + expect(msg3.headers).toEqual({ "set-cookie": [] }); + + // Appending a cookie supersedes the present-but-empty marker (no duplicate + // name in getRawHeaderNames, value visible everywhere). + msg3.appendHeader("Set-Cookie", "a=1"); + expect(msg3.getHeader("set-cookie")).toEqual(["a=1"]); + expect(msg3.getRawHeaderNames().filter(n => n.toLowerCase() === "set-cookie")).toHaveLength(1); + expect(msg3.getHeaders()["set-cookie"]).toEqual(["a=1"]); + + // Replacing the whole header bag drops the marker. + const msg4 = new OutgoingMessage(); + msg4.setHeader("set-cookie", []); + (msg4 as any).headers = { "x-test": "1" }; + expect(msg4.getHeader("set-cookie")).toBeUndefined(); + expect(msg4.hasHeader("set-cookie")).toBe(false); + expect(msg4.getHeaderNames()).toEqual(["x-test"]); +}); + +it("https.Agent applies defaultPort/protocol through options (nodejs/node#58980)", () => { + const a = new https.Agent(); + try { + expect(a.defaultPort).toBe(443); + expect(a.protocol).toBe("https:"); + // v26 sets the defaults on the (null-prototype) options object before + // calling the base constructor. + expect(a.options.defaultPort).toBe(443); + expect(a.options.protocol).toBe("https:"); + expect(Object.getPrototypeOf(a.options)).toBe(null); + } finally { + a.destroy(); + } + + const b = new https.Agent({ defaultPort: 8443 }); + try { + expect(b.defaultPort).toBe(8443); + expect(b.protocol).toBe("https:"); + } finally { + b.destroy(); + } +}); + +it("upgrade request with no 'upgrade' listener falls through to 'request'", async () => { + // Mirrors Node.js behavior (see Node's _http_server.js shouldUpgradeCallback + // default): when the server has no 'upgrade' listener, an Upgrade request is + // handled as a regular request instead of disappearing. + const server = createServer((req, res) => { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("regular response"); + }); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + + const result = await new Promise((resolve, reject) => { + const socket = connect(port, "127.0.0.1", () => { + socket.write("GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n"); + }); + let data = ""; + socket.setEncoding("utf8"); + socket.on("data", chunk => { + data += chunk; + if (data.includes("regular response")) { + socket.destroy(); + resolve(data); + } + }); + socket.on("error", reject); + socket.on("close", () => resolve(data)); + }); + + expect(result).toContain("HTTP/1.1 200"); + expect(result).toContain("regular response"); + } finally { + server.close(); + } +}); + +it("ServerResponse does not emit 'drain' after a successful (non-backpressured) write", async () => { + // Node.js only emits 'drain' after a write() that returned false. + let drains = 0; + let writeReturned: boolean | undefined; + const server = createServer((req, res) => { + res.on("drain", () => drains++); + writeReturned = res.write("hello"); + // Give a synchronously-emitted 'drain' a chance to fire before ending. + process.nextTick(() => { + res.end(" world"); + }); + }); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + + const body = await new Promise((resolve, reject) => { + const req = http.request({ host: "127.0.0.1", port }, res => { + let data = ""; + res.setEncoding("utf8"); + res.on("data", chunk => (data += chunk)); + res.on("end", () => resolve(data)); + }); + req.on("error", reject); + req.end(); + }); + + expect(body).toBe("hello world"); + expect(writeReturned).toBe(true); + expect(drains).toBe(0); + } finally { + server.close(); + } +}); + +it("https.Agent.prototype.createConnection creates a TLS connection", async () => { + expect(typeof https.Agent.prototype.createConnection).toBe("function"); + + const server = createHttpsServer({ key: tlsCert.key, cert: tlsCert.cert }, (req, res) => { + res.end("secure"); + }); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + + const socket: any = https.globalAgent.createConnection({ + host: "127.0.0.1", + port, + rejectUnauthorized: false, + }); + try { + await once(socket, "secureConnect"); + // It's a TLS socket, not a plain net.Socket. + expect(socket.encrypted).toBe(true); + } finally { + socket.destroy(); + } + } finally { + server.close(); + } +}); + +it("http.Agent with proxyEnv does not write to a literal 'undefined' property", () => { + // Regression: the kProxyConfig symbol destructured from internal/http was + // undefined, so the proxy config was stored as agent["undefined"]. + const agent = new Agent({ proxyEnv: { http_proxy: "http://localhost:4873" } } as any); + try { + expect(Object.hasOwn(agent, "undefined")).toBe(false); + } finally { + agent.destroy(); + } +}); + +it("OutgoingMessage outputData is per-instance and _flushOutput is defined", () => { + expect(typeof OutgoingMessage.prototype._flushOutput).toBe("function"); + + const a = new OutgoingMessage(); + const b = new OutgoingMessage(); + expect(a.outputData).not.toBe(b.outputData); + + // Buffered writes on one message must not leak into other instances + // (outputData used to be a shared array on the prototype). + a.outputData.push({ data: "x", encoding: "utf8", callback: null }); + expect(a.outputData.length).toBe(1); + expect(b.outputData.length).toBe(0); + expect(new OutgoingMessage().outputData.length).toBe(0); + + // Like Node, the prototype has no outputData property at all; reading it off + // the prototype must not materialize shared state on the prototype. + expect(Object.getOwnPropertyDescriptor(OutgoingMessage.prototype, "outputData")).toBeUndefined(); + void (OutgoingMessage.prototype as any).outputData; + const c = new OutgoingMessage(); + const d = new OutgoingMessage(); + c.outputData.push({ data: "y", encoding: "utf8", callback: null }); + expect(d.outputData.length).toBe(0); +}); diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index 84b0ee3cd448..3281f44dfed5 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -747,6 +747,30 @@ for (const nodeExecutable of [nodeExe(), bunExe()]) { expect(req.aborted).toBeTrue(); // will be true in this case }); + it("signal validation matches node: non-signal objects throw, duck-typed { aborted } is accepted", async () => { + const client = http2.connect(HTTPS_SERVER, TLS_OPTIONS); + client.on("error", () => {}); + try { + // node's validateAbortSignal accepts any object with an 'aborted' + // property ('aborted' in signal), so a duck-typed { aborted: true } + // takes the pre-aborted fast path instead of throwing... + const { promise, resolve, reject } = Promise.withResolvers(); + const req = client.request({ ":path": "/" }, { signal: { aborted: true } }); + req.on("error", err => (err.name === "AbortError" ? resolve() : reject(err))); + await promise; + // ...while objects without 'aborted' (and non-objects) throw + // ERR_INVALID_ARG_TYPE synchronously, before the fast path. + expect(() => client.request({ ":path": "/" }, { signal: {} })).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + expect(() => client.request({ ":path": "/" }, { signal: 42 })).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + } finally { + client.close(); + } + }); + it("state should work", async () => { const { promise, resolve, reject } = Promise.withResolvers(); const client = http2.connect(HTTPS_SERVER, TLS_OPTIONS); @@ -1787,9 +1811,13 @@ it("http2 client receives 'goaway' when the server rejects a stream", async () = const { code } = await goawayReceived; expect(code).toBe(http2.constants.NGHTTP2_ENHANCE_YOUR_CALM); - expect(sessionError?.code).not.toBe("ERR_HTTP2_SESSION_ERROR"); await clientClosed; + // Like Node, a non-NO_ERROR GOAWAY destroys the session with + // ERR_HTTP2_SESSION_ERROR (verified against Node 26: a server-sent + // ENHANCE_YOUR_CALM goaway yields exactly this error on the client). + expect(sessionError?.code).toBe("ERR_HTTP2_SESSION_ERROR"); + expect(sessionError?.message).toBe("Session closed with error code 11"); } finally { server.close(); } @@ -2711,3 +2739,155 @@ it("http2 client keeps parsing a socket chunk whose ArrayBuffer is transferred b expect(stdout).toContain('PINGS:["4141414141414141","4242424242424242"]'); expect(exitCode).toBe(0); }); + +it("http2 option range error messages use the options. prefix", () => { + for (const opt of ["maxSessionInvalidFrames", "maxSessionRejectedStreams", "unknownProtocolTimeout"]) { + let error; + try { + http2.createServer({ [opt]: -1 }); + } catch (e) { + error = e; + } + expect(error?.code).toBe("ERR_OUT_OF_RANGE"); + expect(error?.message).toContain(`"options.${opt}"`); + } +}); + +it("getPackedSettings caps initialWindowSize at 2**31-1", () => { + // The cap itself is valid. + http2.getPackedSettings({ initialWindowSize: 2 ** 31 - 1 }); + + let error; + try { + http2.getPackedSettings({ initialWindowSize: 2 ** 31 }); + } catch (e) { + error = e; + } + expect(error?.code).toBe("ERR_HTTP2_INVALID_SETTING_VALUE"); + expect(error?.message).toBe('Invalid value for setting "initialWindowSize": 2147483648'); + + error = undefined; + try { + http2.getUnpackedSettings(Buffer.from([0x00, 0x04, 0xff, 0xff, 0xff, 0xff]), { validate: true }); + } catch (e) { + error = e; + } + expect(error?.code).toBe("ERR_HTTP2_INVALID_SETTING_VALUE"); +}); + +it("http2 stream.respond/respondWithFD/respondWithFile reject raw-headers arrays", async () => { + // Passing an array of headers used to be spread into an object with + // numeric-string keys ("0", "1", ...) and sent as garbage header frames. + // Node v24 rejects arrays with ERR_INVALID_ARG_TYPE; v26 added support for + // the [name1, value1, ...] raw form for respond() (not yet implemented here) + // while still rejecting arrays in respondWithFD/respondWithFile. + const errors = []; + const server = http2.createServer(); + server.on("stream", stream => { + for (const invoke of [ + () => stream.respond([":status", "200", "x-foo", "bar"]), + () => stream.respondWithFD(0, ["x-foo", "bar"]), + () => stream.respondWithFile(import.meta.path, ["x-foo", "bar"]), + ]) { + try { + invoke(); + errors.push(null); + } catch (e) { + errors.push(e); + } + } + stream.respond({ ":status": 200 }); + stream.end("ok"); + }); + + await new Promise(resolve => server.listen(0, resolve)); + const port = server.address().port; + const client = http2.connect(`http://localhost:${port}`); + client.on("error", () => {}); + + try { + const req = client.request({ ":path": "/" }); + const response = await new Promise((resolve, reject) => { + req.on("error", reject); + req.on("response", resolve); + req.end(); + }); + let body = ""; + req.on("data", chunk => (body += chunk)); + await new Promise(resolve => req.on("end", resolve)); + + expect(errors).toHaveLength(3); + for (const err of errors) { + expect(err).not.toBeNull(); + expect(err.code).toBe("ERR_INVALID_ARG_TYPE"); + expect(err).toBeInstanceOf(TypeError); + } + // The real respond() afterwards still worked and no bogus "0"/"1" headers leaked. + expect(response[":status"]).toBe(200); + expect(response["0"]).toBeUndefined(); + expect(body).toBe("ok"); + } finally { + client.close(); + server.close(); + } +}); + +it("http2 client.request() on a destroyed or closed session uses the right error codes", async () => { + // Node: destroyed session -> ERR_HTTP2_INVALID_SESSION, + // closed (GOAWAY-pending) session -> ERR_HTTP2_GOAWAY_SESSION. + // The error may surface synchronously or on the returned stream. + function captureRequestError(session) { + try { + const req = session.request({ ":path": "/" }); + return new Promise(resolve => req.on("error", resolve)); + } catch (e) { + return Promise.resolve(e); + } + } + + const server = http2.createServer(); + let endHangingStream; + server.on("stream", (stream, headers) => { + stream.respond({ ":status": 200 }); + if (headers[":path"] === "/hang") { + endHangingStream = () => stream.end("done"); + } else { + stream.end("ok"); + } + }); + await new Promise(resolve => server.listen(0, resolve)); + const port = server.address().port; + + try { + // Closed session (graceful close with a stream still in flight). + const client = http2.connect(`http://localhost:${port}`); + client.on("error", () => {}); + await new Promise(resolve => client.on("connect", resolve)); + const inflight = client.request({ ":path": "/hang" }); + inflight.on("error", () => {}); + inflight.resume(); + await new Promise(resolve => inflight.on("response", resolve)); + client.close(); + expect(client.closed).toBe(true); + expect(client.destroyed).toBe(false); + + const goawayError = await captureRequestError(client); + expect(goawayError.code).toBe("ERR_HTTP2_GOAWAY_SESSION"); + expect(goawayError.message).toBe("New streams cannot be created after receiving a GOAWAY"); + + endHangingStream(); + await new Promise(resolve => inflight.on("close", resolve)); + + // Destroyed session. + const client2 = http2.connect(`http://localhost:${port}`); + client2.on("error", () => {}); + await new Promise(resolve => client2.on("connect", resolve)); + client2.destroy(); + + const destroyedError = await captureRequestError(client2); + expect(destroyedError.code).toBe("ERR_HTTP2_INVALID_SESSION"); + expect(destroyedError.message).toBe("The session has been destroyed"); + } finally { + server.close(); + } +}); diff --git a/test/js/node/process/dlopen-duplicate-load.test.ts b/test/js/node/process/dlopen-duplicate-load.test.ts index 5d9d5951d3b2..0827e1aff180 100644 --- a/test/js/node/process/dlopen-duplicate-load.test.ts +++ b/test/js/node/process/dlopen-duplicate-load.test.ts @@ -1,13 +1,13 @@ import { spawnSync } from "bun"; import { beforeAll, describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDirWithFiles } from "harness"; +import { bunEnv, bunExe, canBuildNodeAddons, tempDirWithFiles } from "harness"; import { join } from "path"; // This test verifies that Bun can load the same native module multiple times // Previously, the second load would fail with "symbol 'napi_register_module_v1' not found" // because static constructors only run once, so the module registration wasn't replayed -describe("process.dlopen duplicate loads", () => { +describe.skipIf(!canBuildNodeAddons())("process.dlopen duplicate loads", () => { let addonPath: string; beforeAll(() => { @@ -60,7 +60,13 @@ NODE_MODULE_CONTEXT_AWARE(addon, demo::Initialize) version: "1.0.0", gypfile: true, scripts: { - install: "node-gyp rebuild", + // Run node-gyp under the bun being tested: the system Node on Windows + // is built with clang-cl and its process.config leaks thin-LTO flags + // into addon builds (link.exe fails on /opt:lldltojobs), and the + // system Node's ABI may not match ours at all (e.g. older macOS CI + // machines). gyp -D defines can't override target_defaults, so use + // bun's clean process.config instead. + install: `${JSON.stringify(bunExe())} --bun node-gyp rebuild`, }, devDependencies: { "node-gyp": "^11.2.0", @@ -82,7 +88,7 @@ NODE_MODULE_CONTEXT_AWARE(addon, demo::Initialize) } addonPath = join(dir, "build", "Release", "addon.node"); - }); + }, 180_000); test("should load the same module twice successfully", async () => { const testScript = ` diff --git a/test/js/node/process/dlopen-non-object-exports.test.ts b/test/js/node/process/dlopen-non-object-exports.test.ts index a1772a008c4d..06bc1313029b 100644 --- a/test/js/node/process/dlopen-non-object-exports.test.ts +++ b/test/js/node/process/dlopen-non-object-exports.test.ts @@ -1,12 +1,12 @@ import { spawnSync } from "bun"; import { beforeAll, describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDirWithFiles } from "harness"; +import { bunEnv, bunExe, canBuildNodeAddons, tempDirWithFiles } from "harness"; import { join } from "path"; // This test verifies that Bun properly handles non-object exports when loading native modules // Previously, this would cause a segfault when exports was null, undefined, or a primitive -describe("process.dlopen with non-object exports", () => { +describe.skipIf(!canBuildNodeAddons())("process.dlopen with non-object exports", () => { let addonPath: string; beforeAll(() => { @@ -59,7 +59,13 @@ NODE_MODULE_CONTEXT_AWARE(addon, demo::Initialize) version: "1.0.0", gypfile: true, scripts: { - install: "node-gyp rebuild", + // Run node-gyp under the bun being tested: the system Node on Windows + // is built with clang-cl and its process.config leaks thin-LTO flags + // into addon builds (link.exe fails on /opt:lldltojobs), and the + // system Node's ABI may not match ours at all (e.g. older macOS CI + // machines). gyp -D defines can't override target_defaults, so use + // bun's clean process.config instead. + install: `${JSON.stringify(bunExe())} --bun node-gyp rebuild`, }, devDependencies: { "node-gyp": "^11.2.0", @@ -81,7 +87,7 @@ NODE_MODULE_CONTEXT_AWARE(addon, demo::Initialize) } addonPath = join(dir, "build", "Release", "addon.node"); - }); + }, 180_000); test("should throw error when exports is null", async () => { const testScript = ` diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index f8389d0401a5..0a1804213eaf 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -426,7 +426,7 @@ describe.concurrent(() => { }); let [out, exited] = await Promise.all([new Response(subprocess.stdout).text(), subprocess.exited]); - expect(out.trim()).toEqual("v24.3.0"); + expect(out.trim()).toEqual("v26.3.0"); expect(exited).toBe(0); }); @@ -1175,10 +1175,10 @@ it.each(["stdin", "stdout", "stderr"])("%s stream accessor should handle excepti }); it("process.versions", () => { - expect(process.versions.node).toEqual("24.3.0"); - expect(process.versions.v8).toEqual("13.6.233.10-node.18"); + expect(process.versions.node).toEqual("26.3.0"); + expect(process.versions.v8).toEqual("14.6.202.34-node.20"); expect(process.versions.napi).toEqual("10"); - expect(process.versions.modules).toEqual("137"); + expect(process.versions.modules).toEqual("147"); }); // On Windows, env var names are case-insensitive. The proxy-related vars diff --git a/test/js/node/stream/node-stream-uint8array.test.ts b/test/js/node/stream/node-stream-uint8array.test.ts index 5072706bd948..981fb8cff9e9 100644 --- a/test/js/node/stream/node-stream-uint8array.test.ts +++ b/test/js/node/stream/node-stream-uint8array.test.ts @@ -91,9 +91,11 @@ describe("Readable", () => { readable.push(DEF); readable.unshift(ABC); + // read() with no size returns one buffered chunk at a time. const buf = readable.read(); expect(buf instanceof Buffer).toBe(true); - expect([...buf]).toEqual([...ABC, ...DEF]); + expect([...buf]).toEqual([...ABC]); + expect([...readable.read()]).toEqual([...DEF]); }); it("should work with setEncoding()", () => { diff --git a/test/js/node/stream/node-stream.test.js b/test/js/node/stream/node-stream.test.js index 3312d054796d..43633a56456c 100644 --- a/test/js/node/stream/node-stream.test.js +++ b/test/js/node/stream/node-stream.test.js @@ -545,6 +545,233 @@ it("should emit prefinish on current tick", done => { }); }); +describe("webstreams adapters (Node v26 sync)", () => { + // Upstream: test-whatwg-webstreams-adapters-to-writablestream.js + // (nodejs/node#61197, fixes nodejs/node#61145) + it("Writable.toWeb does not hang when 'drain' is emitted synchronously during write()", async () => { + const writable = new Writable({ + write(chunk, encoding, callback) { + callback(); + }, + }); + + // Force synchronous 'drain' emission during write() to simulate a + // stream that doesn't have Node.js's built-in kSync protection. + writable.write = function (chunk) { + this.emit("drain"); + return false; + }; + + const writableStream = Writable.toWeb(writable); + const writer = writableStream.getWriter(); + await writer.write(new Uint8Array([1, 2, 3])); + await writer.write(new Uint8Array([4, 5, 6])); + }); + + // Upstream: v26 newStreamWritableFromWritableStream writev done() shape — + // a rejected chunk write during a corked writev must error the stream with + // the original error and must not produce an unhandled rejection. + it("Writable.fromWeb writev rejection errors the stream with the original error", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { Writable } = require("node:stream"); + const theError = new Error("boom"); + const ws = new WritableStream({ + write() { + return Promise.reject(theError); + }, + }); + const w = Writable.fromWeb(ws); + process.on("unhandledRejection", () => { + console.log("UNHANDLED"); + process.exit(2); + }); + w.on("error", e => { + console.log("error-is-original:" + (e === theError)); + }); + w.cork(); + w.write("a"); + w.write("b"); + process.nextTick(() => w.uncork()); + `, + ], + env: bunEnv, + }); + + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout.trim()).toBe("error-is-original:true"); + expect(exitCode).toBe(0); + }); + + // Upstream: test-stream-readable-to-web.js (v26) — options.type: 'bytes' + it("Readable.toWeb supports options.type: 'bytes' (BYOB)", async () => { + const readable = Readable.from([new Uint8Array([1, 2, 3])]); + const rs = Readable.toWeb(readable, { type: "bytes" }); + const reader = rs.getReader({ mode: "byob" }); + + const first = await reader.read(new Uint8Array(10)); + expect(first.done).toBe(false); + expect(Array.from(first.value)).toEqual([1, 2, 3]); + + const second = await reader.read(new Uint8Array(10)); + expect(second.done).toBe(true); + }); + + it("Readable.toWeb validates options", () => { + const readable = Readable.from(["x"]); + expect(() => Readable.toWeb(readable, null)).toThrow(); + try { + Readable.toWeb(readable, null); + } catch (e) { + expect(e.code).toBe("ERR_INVALID_ARG_TYPE"); + } + try { + Readable.toWeb(readable, { type: "banana" }); + expect.unreachable(); + } catch (e) { + expect(e.code).toBe("ERR_INVALID_ARG_VALUE"); + } + readable.destroy(); + }); + + // Upstream: test-stream-readable-to-web-termination.js (v26) — a readable + // already destroyed with an error must produce an errored ReadableStream, + // not a canceled empty one. + it("Readable.toWeb propagates the destroy error of an already-destroyed readable", async () => { + const readable = new Readable({ read() {} }); + const theError = new Error("destroy-err"); + readable.on("error", () => {}); + readable.destroy(theError); + await new Promise(resolve => readable.on("close", resolve)); + + const rs = Readable.toWeb(readable); + await expect(rs.getReader().read()).rejects.toBe(theError); + }); + + it("Readable.toWeb closes cleanly for an already-ended readable", async () => { + const readable = new Readable({ read() {} }); + readable.push(null); + readable.read(); + await new Promise(resolve => readable.on("close", resolve)); + + const rs = Readable.toWeb(readable); + const { done } = await rs.getReader().read(); + expect(done).toBe(true); + }); + + // Upstream: v26 adapters use eos(stream, { writable: false }) so a Duplex + // readable side completes without waiting for the half-open writable side. + it("Readable.toWeb of a half-open Duplex closes when the readable side ends", async () => { + const duplex = new Duplex({ + read() { + this.push(null); + }, + write(chunk, encoding, callback) { + callback(); + }, + }); + const rs = Readable.toWeb(duplex); + const { done } = await rs.getReader().read(); + expect(done).toBe(true); + expect(duplex.writable).toBe(true); + }); + + // Upstream: Duplex.toWeb(duplex, { readableType: 'bytes' }) + it("Duplex.toWeb supports options.readableType: 'bytes'", async () => { + const duplex = new PassThrough(); + const pair = Duplex.toWeb(duplex, { readableType: "bytes" }); + duplex.end(new Uint8Array([5, 6])); + + const reader = pair.readable.getReader({ mode: "byob" }); + const { value } = await reader.read(new Uint8Array(4)); + expect(Array.from(value)).toEqual([5, 6]); + }); + + // Upstream: DEP0201 — options.type is a deprecated alias for options.readableType + it("Duplex.toWeb emits DEP0201 for the deprecated options.type alias", async () => { + const warning = new Promise(resolve => process.once("warning", resolve)); + const duplex = new PassThrough(); + Duplex.toWeb(duplex, { type: "bytes" }); + const w = await warning; + expect(w.name).toBe("DeprecationWarning"); + expect(w.code).toBe("DEP0201"); + duplex.destroy(); + }); + + // Upstream: v26 Writable.toWeb wraps (Shared)ArrayBuffer chunks in a + // Uint8Array before writing to the Node stream. + it("Writable.toWeb accepts ArrayBuffer chunks", async () => { + const chunks = []; + const writable = new Writable({ + write(chunk, encoding, callback) { + chunks.push(chunk); + callback(); + }, + }); + const writer = Writable.toWeb(writable).getWriter(); + await writer.write(new TextEncoder().encode("ab").buffer); + await writer.close(); + expect(chunks.length).toBe(1); + expect(Buffer.concat(chunks).toString()).toBe("ab"); + }); + + // Upstream: v26 end-of-stream only snapshots the AsyncLocalStorage context + // when one is active at registration time; a callback registered outside + // any context observes the context active when the stream settles. + it("finished() callback registered outside an ALS context observes the firing context", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { Readable, finished } = require("node:stream"); + const { AsyncLocalStorage } = require("node:async_hooks"); + const als = new AsyncLocalStorage(); + const r = new Readable({ read() {} }); + finished(r, () => { + console.log("store:" + als.getStore()); + }); + als.run("ctx", () => r.destroy()); + `, + ], + env: bunEnv, + }); + + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout.trim()).toBe("store:ctx"); + expect(exitCode).toBe(0); + }); + + it("finished() callback registered inside an ALS context observes the registration context", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { Readable, finished } = require("node:stream"); + const { AsyncLocalStorage } = require("node:async_hooks"); + const als = new AsyncLocalStorage(); + const r = new Readable({ read() {} }); + als.run("reg-ctx", () => { + finished(r, () => { + console.log("store:" + als.getStore()); + }); + }); + r.destroy(); + `, + ], + env: bunEnv, + }); + + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout.trim()).toBe("store:reg-ctx"); + expect(exitCode).toBe(0); + }); +}); + for (const size of [0x10, 0xffff, 0x10000, 0x1f000, 0x20000, 0x20010, 0x7ffff, 0x80000, 0xa0000, 0xa0010]) { it(`should emit 'readable' with null data and 'close' exactly once each, 0x${size.toString(16)} bytes`, async () => { const path = `${tmpdir()}/${Date.now()}.readable_and_close.txt`; @@ -567,3 +794,276 @@ for (const size of [0x10, 0xffff, 0x10000, 0x1f000, 0x20000, 0x20010, 0x7ffff, 0 await Promise.all([close_resolvers.promise, readable_resolvers.promise]); }); } + +// Node.js v26 semver-major stream semantics. +describe("node v26 stream semantics", () => { + // Upstream: v26 howMuchToRead() fast path; covered upstream by the updated + // test-stream2-readable-non-empty-end.js / test-stream-readable-emittedReadable.js. + it("read() with no size returns one buffered chunk at a time in paused mode", async () => { + const r = new Readable({ read() {} }); + r.push(Buffer.from("abc")); + r.push(Buffer.from("de")); + r.push(null); + await new Promise(resolve => setImmediate(resolve)); + expect(r.read().toString()).toBe("abc"); + expect(r.read().toString()).toBe("de"); + expect(r.read()).toBeNull(); + }); + + it("read() with no size still concatenates when setEncoding is active", async () => { + const r = new Readable({ read() {} }); + r.setEncoding("utf8"); + r.push("abc"); + r.push("de"); + r.push(null); + await new Promise(resolve => setImmediate(resolve)); + expect(r.read()).toBe("abcde"); + expect(r.read()).toBeNull(); + }); + + // Deliberate divergence from Node 26 (nodejs/node#62557 made pause/resume + // no-ops on destroyed streams): legacy Readable subclasses like fd-slicer + // (yauzl → extract-zip → puppeteer/electron tooling) assign + // `this.destroyed = true` via the prototype setter right before push(null). + // With the upstream guard, a piped destination's drain can no longer resume + // the source, so the final buffered chunk is silently dropped and the + // pipeline never finishes. We keep the Node 24 behavior: a destroyed-flagged + // stream still flushes its buffered data to a piped destination. + it("drain still resumes a source that flagged itself destroyed before EOF (fd-slicer pattern)", async () => { + const chunks = [Buffer.alloc(65536, 1), Buffer.alloc(65536, 2), Buffer.alloc(40000, 3)]; + const src = new Readable({ + read() { + const chunk = chunks.shift(); + if (chunk) { + this.push(chunk); + } else { + // fd-slicer's ReadStream._read: sets the destroyed flag (which hits + // the prototype setter on modern streams) and then pushes EOF. + this.destroyed = true; + this.push(null); + } + }, + }); + // Small writableHighWaterMark forces write() to return false so the pipe + // pauses and must be revived by 'drain' → src.resume(). + const slow = new Transform({ + writableHighWaterMark: 1024, + transform(chunk, encoding, callback) { + setImmediate(() => callback(null, chunk)); + }, + }); + let received = 0; + slow.on("data", c => (received += c.length)); + const ended = new Promise((resolve, reject) => { + slow.on("end", resolve); + slow.on("error", reject); + }); + src.pipe(slow); + await ended; + expect(received).toBe(65536 * 2 + 40000); + }); + + // Upstream: nodejs/node#60907 (test-stream-compose-operator.js). + it("compose returns the composed Duplex directly", () => { + expect(Object.hasOwn(Readable.prototype, "compose")).toBe(true); + const composed = Readable.from(["a"]).compose( + new Transform({ + transform(chunk, encoding, callback) { + callback(null, chunk); + }, + }), + ); + expect(composed).toBeInstanceOf(Duplex); + }); + + it("compose rejects a non-writable destination with the streams[1] arg name", () => { + let err; + try { + Readable.from(["a"]).compose(new Readable({ read() {} })); + } catch (e) { + err = e; + } + expect(err?.code).toBe("ERR_INVALID_ARG_VALUE"); + expect(err?.message).toContain("streams[1]"); + }); + + it("compose validates the options argument", () => { + let err; + try { + Readable.from(["a"]).compose(new PassThrough(), 42); + } catch (e) { + err = e; + } + expect(err?.code).toBe("ERR_INVALID_ARG_TYPE"); + }); + + it("compose with an already-aborted signal errors the composed stream", async () => { + const controller = new AbortController(); + controller.abort(); + const composed = Readable.from(["a"]).compose(new PassThrough(), { signal: controller.signal }); + const { promise, resolve } = Promise.withResolvers(); + composed.on("error", resolve); + composed.resume(); + const err = await promise; + expect(err.name).toBe("AbortError"); + expect(err.code).toBe("ABORT_ERR"); + }); + + // Upstream: v26 test-stream-writable-decoded-encoding.js. + it("write(string, 'buffer') throws ERR_UNKNOWN_ENCODING", () => { + for (const opts of [{ decodeStrings: false }, {}]) { + const w = new Writable({ + ...opts, + write(chunk, encoding, callback) { + callback(); + }, + }); + let err; + try { + w.write("hi", "buffer"); + } catch (e) { + err = e; + } + expect(err?.code).toBe("ERR_UNKNOWN_ENCODING"); + + // Buffer chunks with 'buffer' encoding still work. + const w2 = new Writable({ + ...opts, + write(chunk, encoding, callback) { + callback(); + }, + }); + expect(w2.write(Buffer.from("x"), "buffer")).toBe(true); + } + }); +}); + +describe("fromList string chunk boundary (nodejs/node#61884)", () => { + it("read(n) with setEncoding does not over-read when n equals the buffered array length", () => { + const r = new Readable({ read() {} }); + r.setEncoding("utf8"); + r.push("a"); + r.push("bcd"); + // With the v24 bug (`n === buf.length` instead of `n === str.length`), + // read(3) returned "abcd". + expect(r.read(3)).toBe("abc"); + expect(r.read(1)).toBe("d"); + }); +}); + +describe("maybeReadMore is a no-op while a read is in flight (nodejs/node#60454)", () => { + it("does not schedule a redundant _read while kReading is set", async () => { + let reads = 0; + const r = new Readable({ + highWaterMark: 1024, + read() { + reads++; + }, + }); + + r.read(10); // _read #1 is now in flight (kReading set, no sync push) + expect(reads).toBe(1); + + // Old gate ((kReadingMore | kConstructed) === kConstructed) scheduled + // maybeReadMore_ HERE, while the read was still in flight. + r.unshift("x"); + + // Queued between the buggy (unshift-time) and fixed (push-time) schedule + // points: with the old gate maybeReadMore_ ran BEFORE this tick, saw the + // read completed and the stream not yet ended, and issued a redundant + // stream.read(0) -> _read #2. With the v26 gate the schedule happens at + // push("y") below, so this tick ends the stream first and no extra _read + // is issued. Verified against node v26.3.0 (reads === 1) and the old + // gate (reads === 2). + process.nextTick(() => r.push(null)); + + r.push("y"); // completes the in-flight read; v26 schedules maybeReadMore_ here + + // All process.nextTick callbacks (including maybeReadMore_) run before + // setImmediate fires, so this is a deterministic ordering, not a timeout. + await new Promise(resolve => setImmediate(resolve)); + expect(reads).toBe(1); + }); +}); + +describe("Duplex.from({ readable, writable }) destroy propagation (nodejs/node#62824)", () => { + it("destroys the writable side when the readable side errors", async () => { + const r = new Readable({ read() {} }); + const w = new Writable({ + write(chunk, enc, cb) { + cb(); + }, + }); + const d = Duplex.from({ readable: r, writable: w }); + + const writableError = Promise.withResolvers(); + const writableClose = Promise.withResolvers(); + const duplexError = Promise.withResolvers(); + w.on("error", writableError.resolve); + w.on("close", writableClose.resolve); + d.on("error", duplexError.resolve); + + const err = new Error("boom"); + r.destroy(err); + + expect(await writableError.promise).toBe(err); + await writableClose.promise; + expect(w.destroyed).toBe(true); + expect(await duplexError.promise).toBe(err); + }); +}); + +describe("pipeline real error overrides AbortError (nodejs/node#62113)", () => { + it("reports the real error when a destroy callback errors after abort", async () => { + const ac = new AbortController(); + const r = new Readable({ read() {} }); + const w = new Writable({ + write(chunk, enc, cb) { + cb(); + }, + destroy(err, cb) { + cb(new Error("realboom")); + }, + }); + const p = Stream.promises.pipeline(r, w, { signal: ac.signal }); + setImmediate(() => ac.abort()); + let caught; + await p.catch(e => { + caught = e; + }); + expect(caught.name).toBe("Error"); + expect(caught.message).toBe("realboom"); + }); +}); + +describe("stream operators argument validation (nodejs/node#59529)", () => { + it("map/filter throw synchronously with the validateFunction message", () => { + for (const method of ["map", "filter"]) { + const r = Readable.from([1]); + expect(() => r[method](123)).toThrow( + expect.objectContaining({ + code: "ERR_INVALID_ARG_TYPE", + message: 'The "fn" argument must be of type function. Received type number (123)', + }), + ); + r.destroy(); + } + }); + + it("forEach/every/reduce reject asynchronously with the validateFunction message", async () => { + for (const [method, name] of [ + ["forEach", "fn"], + ["every", "fn"], + ["reduce", "reducer"], + ]) { + const r = Readable.from([1]); + let caught; + await r[method](123).catch(e => { + caught = e; + }); + expect(caught.code).toBe("ERR_INVALID_ARG_TYPE"); + expect(caught.message).toBe(`The "${name}" argument must be of type function. Received type number (123)`); + r.destroy(); + } + }); +}); diff --git a/test/js/node/test/parallel/test-crypto-cipheriv-decipheriv.js b/test/js/node/test/parallel/test-crypto-cipheriv-decipheriv.js index 9a6440b63ca8..2e0a72fbce83 100644 --- a/test/js/node/test/parallel/test-crypto-cipheriv-decipheriv.js +++ b/test/js/node/test/parallel/test-crypto-cipheriv-decipheriv.js @@ -31,11 +31,11 @@ function testCipher1(key, iv) { // quite small, so there's no harm. const cStream = crypto.createCipheriv('des-ede3-cbc', key, iv); cStream.end(plaintext); - ciph = cStream.read(); + ciph = cStream.read(cStream.readableLength); const dStream = crypto.createDecipheriv('des-ede3-cbc', key, iv); dStream.end(ciph); - txt = dStream.read().toString('utf8'); + txt = dStream.read(dStream.readableLength).toString('utf8'); assert.strictEqual(txt, plaintext, `streaming cipher with key ${key} and iv ${iv}`); diff --git a/test/js/node/test/parallel/test-http2-getpackedsettings.js b/test/js/node/test/parallel/test-http2-getpackedsettings.js index 77a8640587c8..872e66b0ede7 100644 --- a/test/js/node/test/parallel/test-http2-getpackedsettings.js +++ b/test/js/node/test/parallel/test-http2-getpackedsettings.js @@ -20,7 +20,7 @@ assert.deepStrictEqual(val, check); ['headerTableSize', 0], ['headerTableSize', 2 ** 32 - 1], ['initialWindowSize', 0], - ['initialWindowSize', 2 ** 32 - 1], + ['initialWindowSize', 2 ** 31 - 1], ['maxFrameSize', 16384], ['maxFrameSize', 2 ** 24 - 1], ['maxConcurrentStreams', 0], @@ -42,7 +42,7 @@ http2.getPackedSettings({ enablePush: false }); ['headerTableSize', -1], ['headerTableSize', 2 ** 32], ['initialWindowSize', -1], - ['initialWindowSize', 2 ** 32], + ['initialWindowSize', 2 ** 31], ['maxFrameSize', 16383], ['maxFrameSize', 2 ** 24], ['maxConcurrentStreams', -1], diff --git a/test/js/node/test/parallel/test-stream-compose.js b/test/js/node/test/parallel/test-stream-compose.js index d7a54e177668..a4517a294b0d 100644 --- a/test/js/node/test/parallel/test-stream-compose.js +++ b/test/js/node/test/parallel/test-stream-compose.js @@ -490,7 +490,8 @@ const assert = require('assert'); newStream.end(); - assert.deepStrictEqual(await newStream.toArray(), [Buffer.from('Steve RogersOn your left')]); + assert.deepStrictEqual(await newStream.toArray(), + [Buffer.from('Steve Rogers'), Buffer.from('On your left')]); })().then(common.mustCall()); } diff --git a/test/js/node/test/parallel/test-stream-push-strings.js b/test/js/node/test/parallel/test-stream-push-strings.js index d582c8add005..5fece74a115c 100644 --- a/test/js/node/test/parallel/test-stream-push-strings.js +++ b/test/js/node/test/parallel/test-stream-push-strings.js @@ -59,7 +59,7 @@ ms.on('readable', function() { results.push(String(chunk)); }); -const expect = [ 'first chunksecond to last chunk', 'last chunk' ]; +const expect = [ 'first chunk', 'second to last chunk', 'last chunk' ]; process.on('exit', function() { assert.strictEqual(ms._chunks, -1); assert.deepStrictEqual(results, expect); diff --git a/test/js/node/test/parallel/test-stream-readable-emittedReadable.js b/test/js/node/test/parallel/test-stream-readable-emittedReadable.js index ba613f9e9ff1..ffaf1d5b9433 100644 --- a/test/js/node/test/parallel/test-stream-readable-emittedReadable.js +++ b/test/js/node/test/parallel/test-stream-readable-emittedReadable.js @@ -10,7 +10,7 @@ const readable = new Readable({ // Initialized to false. assert.strictEqual(readable._readableState.emittedReadable, false); -const expected = [Buffer.from('foobar'), Buffer.from('quo'), null]; +const expected = [Buffer.from('foo'), Buffer.from('bar'), Buffer.from('quo'), null]; readable.on('readable', common.mustCall(() => { // emittedReadable should be true when the readable event is emitted assert.strictEqual(readable._readableState.emittedReadable, true); diff --git a/test/js/node/test/parallel/test-stream-readable-infinite-read.js b/test/js/node/test/parallel/test-stream-readable-infinite-read.js index df88d78b74c3..9d39b1fc6cdb 100644 --- a/test/js/node/test/parallel/test-stream-readable-infinite-read.js +++ b/test/js/node/test/parallel/test-stream-readable-infinite-read.js @@ -10,7 +10,7 @@ const readable = new Readable({ highWaterMark: 16 * 1024, read: common.mustCall(function() { this.push(buf); - }, 31) + }, 12) }); let i = 0; @@ -18,16 +18,11 @@ let i = 0; readable.on('readable', common.mustCall(function() { if (i++ === 10) { // We will just terminate now. - process.removeAllListeners('readable'); + readable.removeAllListeners('readable'); return; } const data = readable.read(); - // TODO(mcollina): there is something odd in the highWaterMark logic - // investigate. - if (i === 1) { - assert.strictEqual(data.length, 8192 * 2); - } else { - assert.strictEqual(data.length, 8192 * 3); - } + // read() with no size returns a single buffered chunk at a time. + assert.strictEqual(data.length, 8192); }, 11)); diff --git a/test/js/node/test/parallel/test-stream-readable-needReadable.js b/test/js/node/test/parallel/test-stream-readable-needReadable.js index c4bc90bb19d3..3f26db791c7b 100644 --- a/test/js/node/test/parallel/test-stream-readable-needReadable.js +++ b/test/js/node/test/parallel/test-stream-readable-needReadable.js @@ -32,7 +32,7 @@ const asyncReadable = new Readable({ }); asyncReadable.on('readable', common.mustCall(() => { - if (asyncReadable.read() !== null) { + if (asyncReadable.read(asyncReadable.readableLength) !== null) { // After each read(), the buffer is empty. // If the stream doesn't end now, // then we need to notify the reader on future changes. diff --git a/test/js/node/test/parallel/test-stream-readable-to-web-byob.js b/test/js/node/test/parallel/test-stream-readable-to-web-byob.js new file mode 100644 index 000000000000..8e5f10efee1b --- /dev/null +++ b/test/js/node/test/parallel/test-stream-readable-to-web-byob.js @@ -0,0 +1,49 @@ +'use strict'; +require('../common'); +const { Readable } = require('stream'); +const assert = require('assert'); +const common = require('../common'); + +let count = 0; + +const nodeStream = new Readable({ + read(size) { + if (this.destroyed) { + return; + } + // Simulate a stream that pushes sequences of 16 bytes + const buffer = Buffer.alloc(size); + for (let i = 0; i < size; i++) { + buffer[i] = count++ % 16; + } + this.push(buffer); + } +}); + +// Test validation of 'type' option +assert.throws( + () => { + Readable.toWeb(nodeStream, { type: 'wrong type' }); + }, + { + code: 'ERR_INVALID_ARG_VALUE' + } +); + +// Test normal operation with ReadableByteStream +const webStream = Readable.toWeb(nodeStream, { type: 'bytes' }); +const reader = webStream.getReader({ mode: 'byob' }); +const expected = new Uint8Array(16); +for (let i = 0; i < 16; i++) { + expected[i] = count++; +} + +for (let i = 0; i < 1000; i++) { + // Read 16 bytes of data from the stream + const receive = new Uint8Array(16); + reader.read(receive).then(common.mustCall((result) => { + // Verify the data received + assert.ok(!result.done); + assert.deepStrictEqual(result.value, expected); + })); +} diff --git a/test/js/node/test/parallel/test-stream-readable-to-web-termination-byob.js b/test/js/node/test/parallel/test-stream-readable-to-web-termination-byob.js new file mode 100644 index 000000000000..8b1f8d1817c0 --- /dev/null +++ b/test/js/node/test/parallel/test-stream-readable-to-web-termination-byob.js @@ -0,0 +1,15 @@ +'use strict'; +require('../common'); +const { Readable } = require('stream'); +const assert = require('assert'); +const common = require('../common'); +{ + const r = Readable.from([]); + // Cancelling reader while closing should not cause uncaught exceptions + r.on('close', common.mustCall(() => reader.cancel())); + + const reader = Readable.toWeb(r, { type: 'bytes' }).getReader({ mode: 'byob' }); + reader.read(new Uint8Array(16)).then(common.mustCall((result) => { + assert.ok(result.done); + })); +} diff --git a/test/js/node/test/parallel/test-stream-readable-to-web-termination.js b/test/js/node/test/parallel/test-stream-readable-to-web-termination.js index 13fce9bc715e..f30cf721e14c 100644 --- a/test/js/node/test/parallel/test-stream-readable-to-web-termination.js +++ b/test/js/node/test/parallel/test-stream-readable-to-web-termination.js @@ -1,6 +1,8 @@ 'use strict'; -require('../common'); -const { Readable } = require('stream'); +const common = require('../common'); +const assert = require('assert'); +const { Duplex, Readable } = require('stream'); +const { setTimeout: delay } = require('timers/promises'); { const r = Readable.from([]); @@ -10,3 +12,33 @@ const { Readable } = require('stream'); const reader = Readable.toWeb(r).getReader(); reader.read(); } + +{ + const duplex = new Duplex({ + read() { + this.push(Buffer.from('x')); + this.push(null); + }, + write(_chunk, _encoding, callback) { + callback(); + }, + }); + + const reader = Readable.toWeb(duplex).getReader(); + + (async () => { + const result = await reader.read(); + assert.deepStrictEqual(result, { + value: new Uint8Array(Buffer.from('x')), + done: false, + }); + + const closeResult = await Promise.race([ + reader.read(), + delay(common.platformTimeout(100)).then(() => 'timeout'), + ]); + + assert.notStrictEqual(closeResult, 'timeout'); + assert.deepStrictEqual(closeResult, { value: undefined, done: true }); + })().then(common.mustCall()); +} diff --git a/test/js/node/test/parallel/test-stream-typedarray.js b/test/js/node/test/parallel/test-stream-typedarray.js index ae5846da09db..55d92aa31eda 100644 --- a/test/js/node/test/parallel/test-stream-typedarray.js +++ b/test/js/node/test/parallel/test-stream-typedarray.js @@ -83,9 +83,12 @@ const views = common.getArrayBufferViews(buffer); readable.push(views[2]); readable.unshift(views[0]); + // read() with no size returns one buffered chunk at a time. const buf = readable.read(); assert(buf instanceof Buffer); - assert.deepStrictEqual([...buf], [...views[0], ...views[1], ...views[2]]); + assert.deepStrictEqual([...buf], [...views[0]]); + assert.deepStrictEqual([...readable.read()], [...views[1]]); + assert.deepStrictEqual([...readable.read()], [...views[2]]); } { diff --git a/test/js/node/test/parallel/test-stream-uint8array.js b/test/js/node/test/parallel/test-stream-uint8array.js index f1de4c873fd3..5ffdbbbc54b4 100644 --- a/test/js/node/test/parallel/test-stream-uint8array.js +++ b/test/js/node/test/parallel/test-stream-uint8array.js @@ -80,9 +80,11 @@ const GHI = new Uint8Array([0x47, 0x48, 0x49]); readable.push(DEF); readable.unshift(ABC); + // read() with no size returns one buffered chunk at a time. const buf = readable.read(); assert(buf instanceof Buffer); - assert.deepStrictEqual([...buf], [...ABC, ...DEF]); + assert.deepStrictEqual([...buf], [...ABC]); + assert.deepStrictEqual([...readable.read()], [...DEF]); } { diff --git a/test/js/node/test/parallel/test-stream2-transform.js b/test/js/node/test/parallel/test-stream2-transform.js index f222f1c03b48..a7d0f236d787 100644 --- a/test/js/node/test/parallel/test-stream2-transform.js +++ b/test/js/node/test/parallel/test-stream2-transform.js @@ -282,7 +282,10 @@ const { PassThrough, Transform } = require('stream'); pt.write(Buffer.from('ef'), common.mustCall(function() { pt.end(); })); - assert.strictEqual(pt.read().toString(), 'abcdef'); + // read() with no size returns one buffered chunk at a time. + assert.strictEqual(pt.read().toString(), 'abc'); + assert.strictEqual(pt.read().toString(), 'd'); + assert.strictEqual(pt.read().toString(), 'ef'); assert.strictEqual(pt.read(), null); }); }); diff --git a/test/js/node/test/parallel/test-webstreams-adapters-writable-buffer-sources.js b/test/js/node/test/parallel/test-webstreams-adapters-writable-buffer-sources.js new file mode 100644 index 000000000000..995db97e7473 --- /dev/null +++ b/test/js/node/test/parallel/test-webstreams-adapters-writable-buffer-sources.js @@ -0,0 +1,95 @@ +'use strict'; +const common = require('../common'); + +const assert = require('assert'); +const { Buffer } = require('buffer'); +const { Duplex, Writable } = require('stream'); +const { suite, test } = require('node:test'); + +const ctors = [ArrayBuffer, SharedArrayBuffer]; + +suite('underlying Writable', () => { + suite('in non-object mode', () => { + for (const ctor of ctors) { + test(`converts ${ctor.name} chunks`, async () => { + const buffer = new ctor(4); + const writable = new Writable({ + objectMode: false, + write: common.mustCall((chunk, encoding, callback) => { + assert(Buffer.isBuffer(chunk)); + assert.strictEqual(chunk.buffer, buffer); + callback(); + }), + }); + writable.on('error', common.mustNotCall()); + const writer = Writable.toWeb(writable).getWriter(); + await writer.write(buffer); + }); + } + }); + + suite('in object mode', () => { + for (const ctor of ctors) { + test(`passes through ${ctor.name} chunks`, async () => { + const buffer = new ctor(4); + const writable = new Writable({ + objectMode: true, + write: common.mustCall((chunk, encoding, callback) => { + assert(chunk instanceof ctor); + assert.strictEqual(chunk, buffer); + callback(); + }), + }); + writable.on('error', common.mustNotCall()); + const writer = Writable.toWeb(writable).getWriter(); + await writer.write(buffer); + }); + } + }); +}); + +suite('underlying Duplex', () => { + suite('in non-object mode', () => { + for (const ctor of ctors) { + test(`converts ${ctor.name} chunks`, async () => { + const buffer = new ctor(4); + const duplex = new Duplex({ + writableObjectMode: false, + write: common.mustCall((chunk, encoding, callback) => { + assert(Buffer.isBuffer(chunk)); + assert.strictEqual(chunk.buffer, buffer); + callback(); + }), + read() { + this.push(null); + }, + }); + duplex.on('error', common.mustNotCall()); + const writer = Duplex.toWeb(duplex).writable.getWriter(); + await writer.write(buffer); + }); + } + }); + + suite('in object mode', () => { + for (const ctor of ctors) { + test(`passes through ${ctor.name} chunks`, async () => { + const buffer = new ctor(4); + const duplex = new Duplex({ + writableObjectMode: true, + write: common.mustCall((chunk, encoding, callback) => { + assert(chunk instanceof ctor); + assert.strictEqual(chunk, buffer); + callback(); + }), + read() { + this.push(null); + }, + }); + duplex.on('error', common.mustNotCall()); + const writer = Duplex.toWeb(duplex).writable.getWriter(); + await writer.write(buffer); + }); + } + }); +}); diff --git a/test/js/node/test/parallel/test-webstreams-compression-bad-chunks.js b/test/js/node/test/parallel/test-webstreams-compression-bad-chunks.js new file mode 100644 index 000000000000..4a8ca3cff8a2 --- /dev/null +++ b/test/js/node/test/parallel/test-webstreams-compression-bad-chunks.js @@ -0,0 +1,75 @@ +'use strict'; +require('../common'); +const assert = require('assert'); +const test = require('node:test'); +const { CompressionStream, DecompressionStream } = require('stream/web'); + +// Verify that writing invalid (non-BufferSource) chunks to +// CompressionStream and DecompressionStream properly rejects +// on both the write and the read side, instead of hanging. + +const badChunks = [ + { name: 'undefined', value: undefined, code: 'ERR_INVALID_ARG_TYPE' }, + { name: 'null', value: null, code: 'ERR_STREAM_NULL_VALUES' }, + { name: 'number', value: 3.14, code: 'ERR_INVALID_ARG_TYPE' }, + { name: 'object', value: {}, code: 'ERR_INVALID_ARG_TYPE' }, + { name: 'array', value: [65], code: 'ERR_INVALID_ARG_TYPE' }, + { + name: 'SharedArrayBuffer', + value: new SharedArrayBuffer(1), + code: 'ERR_INVALID_ARG_TYPE', + }, + { + name: 'Uint8Array backed by SharedArrayBuffer', + value: new Uint8Array(new SharedArrayBuffer(1)), + code: 'ERR_INVALID_ARG_TYPE', + }, +]; + +for (const format of ['deflate', 'deflate-raw', 'gzip', 'brotli']) { + for (const { name, value, code } of badChunks) { + const expected = { name: 'TypeError', code }; + + test(`CompressionStream rejects bad chunk (${name}) for ${format}`, async () => { + const cs = new CompressionStream(format); + const writer = cs.writable.getWriter(); + const reader = cs.readable.getReader(); + + const writePromise = writer.write(value); + const readPromise = reader.read(); + + await assert.rejects(writePromise, expected); + await assert.rejects(readPromise, expected); + }); + + test(`DecompressionStream rejects bad chunk (${name}) for ${format}`, async () => { + const ds = new DecompressionStream(format); + const writer = ds.writable.getWriter(); + const reader = ds.readable.getReader(); + + const writePromise = writer.write(value); + const readPromise = reader.read(); + + await assert.rejects(writePromise, expected); + await assert.rejects(readPromise, expected); + }); + } +} + +// Verify that decompression errors (e.g. corrupt data) are surfaced as +// TypeError, not plain Error, per the Compression Streams spec. +for (const format of ['deflate', 'deflate-raw', 'gzip', 'brotli']) { + test(`DecompressionStream surfaces corrupt data as TypeError for ${format}`, async () => { + const ds = new DecompressionStream(format); + const writer = ds.writable.getWriter(); + const reader = ds.readable.getReader(); + + const corruptData = new Uint8Array([0, 1, 2, 3, 4, 5]); + + writer.write(corruptData).catch(() => {}); + reader.read().catch(() => {}); + + await assert.rejects(writer.close(), { name: 'TypeError' }); + await assert.rejects(reader.closed, { name: 'TypeError' }); + }); +} diff --git a/test/js/node/test/parallel/test-webstreams-compression-buffer-source.js b/test/js/node/test/parallel/test-webstreams-compression-buffer-source.js new file mode 100644 index 000000000000..3304a8e64f31 --- /dev/null +++ b/test/js/node/test/parallel/test-webstreams-compression-buffer-source.js @@ -0,0 +1,42 @@ +'use strict'; +require('../common'); +const assert = require('assert'); +const test = require('node:test'); +const { DecompressionStream, CompressionStream } = require('stream/web'); + +// Minimal gzip-compressed bytes for "hello" +const compressedGzip = new Uint8Array([ + 31, 139, 8, 0, 0, 0, 0, 0, 0, 3, + 203, 72, 205, 201, 201, 7, 0, 134, 166, 16, 54, 5, 0, 0, 0, +]); + +test('DecompressionStream accepts ArrayBuffer chunks', async () => { + const ds = new DecompressionStream('gzip'); + const writer = ds.writable.getWriter(); + + const writePromise = writer.write(compressedGzip.buffer); + writer.close(); + + const chunks = await Array.fromAsync(ds.readable); + await writePromise; + const out = Buffer.concat(chunks.map((c) => Buffer.from(c))); + assert.strictEqual(out.toString(), 'hello'); +}); + +test('CompressionStream round-trip with ArrayBuffer input', async () => { + const cs = new CompressionStream('gzip'); + const ds = new DecompressionStream('gzip'); + + const csWriter = cs.writable.getWriter(); + + const input = new TextEncoder().encode('hello').buffer; + + await csWriter.write(input); + csWriter.close(); + + await cs.readable.pipeTo(ds.writable); + + const out = await Array.fromAsync(ds.readable); + const result = Buffer.concat(out.map((c) => Buffer.from(c))); + assert.strictEqual(result.toString(), 'hello'); +}); diff --git a/test/js/node/test/parallel/test-webstreams-duplex-fromweb-writev-unhandled-rejection.js b/test/js/node/test/parallel/test-webstreams-duplex-fromweb-writev-unhandled-rejection.js new file mode 100644 index 000000000000..5367b2a09e1f --- /dev/null +++ b/test/js/node/test/parallel/test-webstreams-duplex-fromweb-writev-unhandled-rejection.js @@ -0,0 +1,55 @@ +'use strict'; + +// Regression test for https://github.com/nodejs/node/issues/62199 +// +// When Duplex.fromWeb is corked, writes are batched into _writev. If destroy() +// is called in the same microtask (after uncork()), writer.ready rejects with a +// non-array value. The done() callback inside _writev unconditionally called +// error.filter(), which throws TypeError on non-arrays. This TypeError became +// an unhandled rejection that crashed the process. +// +// The same bug exists in newStreamWritableFromWritableStream (Writable.fromWeb). + +const common = require('../common'); +const { Duplex, Writable } = require('stream'); +const { TransformStream, WritableStream } = require('stream/web'); + +// Exact reproduction from the issue report (davidje13). +// Before the fix: process crashes with unhandled TypeError. +// After the fix: stream closes cleanly with no unhandled rejection. +{ + const output = Duplex.fromWeb(new TransformStream()); + + output.on('close', common.mustCall()); + + output.cork(); + output.write('test'); + output.write('test'); + output.uncork(); + output.destroy(); +} + +// Same bug in Writable.fromWeb (newStreamWritableFromWritableStream). +{ + const writable = Writable.fromWeb(new WritableStream()); + + writable.on('close', common.mustCall()); + + writable.cork(); + writable.write('test'); + writable.write('test'); + writable.uncork(); + writable.destroy(); +} + +// Regression: normal cork/uncork/_writev success path must still work. +// Verifies that () => done() correctly signals success via callback(). +{ + const writable = Writable.fromWeb(new WritableStream({ write() {} })); + + writable.cork(); + writable.write('foo'); + writable.write('bar'); + writable.uncork(); + writable.end(common.mustCall()); +} diff --git a/test/js/node/test/parallel/test-whatwg-webstreams-compression.js b/test/js/node/test/parallel/test-whatwg-webstreams-compression.js index a6f2e1b425dc..f168a0ca846f 100644 --- a/test/js/node/test/parallel/test-whatwg-webstreams-compression.js +++ b/test/js/node/test/parallel/test-whatwg-webstreams-compression.js @@ -24,13 +24,13 @@ async function test(format) { const writer = gzip.writable.getWriter(); const compressed_data = []; - const reader_function = ({ value, done }) => { + const reader_function = common.mustCallAtLeast(({ value, done }) => { if (value) compressed_data.push(value); if (!done) return reader.read().then(reader_function); assert.strictEqual(dec.decode(Buffer.concat(compressed_data)), 'hello'); - }; + }); const reader_promise = reader.read().then(reader_function); await Promise.all([ diff --git a/test/js/node/test/parallel/test-zlib-flush-write-sync-interleaved.js b/test/js/node/test/parallel/test-zlib-flush-write-sync-interleaved.js index f8387f40069b..87ca9fe1e9a2 100644 --- a/test/js/node/test/parallel/test-zlib-flush-write-sync-interleaved.js +++ b/test/js/node/test/parallel/test-zlib-flush-write-sync-interleaved.js @@ -19,7 +19,7 @@ for (const chunk of ['abc', 'def', 'ghi']) { compress.write(chunk, common.mustCall(() => events.push({ written: chunk }))); compress.flush(Z_PARTIAL_FLUSH, common.mustCall(() => { events.push('flushed'); - const chunk = compress.read(); + const chunk = compress.read(compress.readableLength); if (chunk !== null) compressedChunks.push(chunk); })); @@ -36,7 +36,7 @@ function writeToDecompress() { const chunk = compressedChunks.shift(); if (chunk === undefined) return decompress.end(); decompress.write(chunk, common.mustCall(() => { - events.push({ read: decompress.read() }); + events.push({ read: decompress.read(decompress.readableLength) }); writeToDecompress(); })); } diff --git a/test/js/third_party/duckdb/duckdb-basic-usage.test.ts b/test/js/third_party/duckdb/duckdb-basic-usage.test.ts index 2861ada7155d..8ee3bae518f5 100644 --- a/test/js/third_party/duckdb/duckdb-basic-usage.test.ts +++ b/test/js/third_party/duckdb/duckdb-basic-usage.test.ts @@ -7,6 +7,14 @@ if (process.platform === "win32" && process.arch === "arm64") { // duckdb does not distribute win32-arm64 binaries process.exit(0); } +if (Number(process.versions.modules) > 137) { + // The deprecated `duckdb` package only publishes prebuilts up to + // NODE_MODULE_VERSION 137 (checked npm.duckdb.org for 1.3.1-1.4.1: no + // node-v141/-v147 binaries), and node-pre-gyp's --fallback-to-build source + // compile is not viable in CI. Drop this gate if the test migrates to + // @duckdb/node-api, which is N-API based and ABI-independent. + process.exit(0); +} import { describe, expect, test } from "bun:test"; // Must be CJS require so that the above code can exit before we attempt to import DuckDB diff --git a/test/js/third_party/grpc-js/test-server.test.ts b/test/js/third_party/grpc-js/test-server.test.ts index e1dd4c2933b2..607afbda7636 100644 --- a/test/js/third_party/grpc-js/test-server.test.ts +++ b/test/js/third_party/grpc-js/test-server.test.ts @@ -194,9 +194,16 @@ describe("Server", () => { { deadline: deadline }, (callError2, result) => { assert(callError2); - // DEADLINE_EXCEEDED means that the server is unreachable + // DEADLINE_EXCEEDED means the server was unreachable; + // UNAVAILABLE means the connection dropped before the call. + // CANCELLED happens when the call wins the race onto the + // draining session before the GOAWAY/socket-close is + // processed and is then torn down with NGHTTP2_CANCEL — the + // same in-flight teardown path Node takes at socket close. assert( - callError2.code === grpc.status.DEADLINE_EXCEEDED || callError2.code === grpc.status.UNAVAILABLE, + callError2.code === grpc.status.DEADLINE_EXCEEDED || + callError2.code === grpc.status.UNAVAILABLE || + callError2.code === grpc.status.CANCELLED, ); done(); }, diff --git a/test/js/web/streams/compression.test.ts b/test/js/web/streams/compression.test.ts index 63fae21bf4a6..f182e968b665 100644 --- a/test/js/web/streams/compression.test.ts +++ b/test/js/web/streams/compression.test.ts @@ -249,3 +249,89 @@ describe("CompressionStream and DecompressionStream", () => { }); }); }); + +// Ported behaviors from Node v26's webstreams adapters +// (upstream: test-whatwg-webstreams-compression.js and +// lib/internal/webstreams/compression.js validateBufferSourceChunk). +describe("CompressionStream chunk handling (Node v26 semantics)", () => { + test("accepts ArrayBuffer chunks", async () => { + const input = "hello arraybuffer world"; + const data = new TextEncoder().encode(input); + + const cs = new CompressionStream("gzip"); + const writer = cs.writable.getWriter(); + writer.write(data.buffer); + writer.close(); + + const compressedChunks: Uint8Array[] = []; + const reader = cs.readable.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + compressedChunks.push(value); + } + expect(compressedChunks.length).toBeGreaterThan(0); + + const ds = new DecompressionStream("gzip"); + const dWriter = ds.writable.getWriter(); + for (const chunk of compressedChunks) dWriter.write(chunk); + dWriter.close(); + + const out: Uint8Array[] = []; + const dReader = ds.readable.getReader(); + while (true) { + const { done, value } = await dReader.read(); + if (done) break; + out.push(value); + } + expect(new TextDecoder().decode(Buffer.concat(out))).toBe(input); + }); + + test("rejects SharedArrayBuffer chunks with ERR_INVALID_ARG_TYPE", async () => { + const cs = new CompressionStream("gzip"); + const writer = cs.writable.getWriter(); + expect.assertions(1); + try { + await writer.write(new SharedArrayBuffer(8)); + } catch (e: any) { + expect(e.code).toBe("ERR_INVALID_ARG_TYPE"); + } + }); + + test("a synchronously-invalid chunk errors both sides instead of hanging the readable", async () => { + const cs = new CompressionStream("gzip"); + const writer = cs.writable.getWriter(); + const reader = cs.readable.getReader(); + + const writeError = writer.write(42).catch(e => e); + // Without the kDestroyOnSyncError handling the readable side hangs + // forever here. + const readError = reader.read().catch(e => e); + + const [we, re] = await Promise.all([writeError, readError]); + expect(we.code).toBe("ERR_INVALID_ARG_TYPE"); + expect(re.code).toBe("ERR_INVALID_ARG_TYPE"); + }); + + test("brotli decoder errors surface as TypeError with the original code as own property", async () => { + const ds = new DecompressionStream("brotli"); + const writer = ds.writable.getWriter(); + const reader = ds.readable.getReader(); + + writer.write(new Uint8Array([0xff, 0xff, 0xff, 0xff, 0xff, 0xff])).catch(() => {}); + writer.close().catch(() => {}); + + expect.assertions(4); + try { + while (true) { + const { done } = await reader.read(); + if (done) break; + } + } catch (e: any) { + expect(e).toBeInstanceOf(TypeError); + expect(Object.hasOwn(e, "code")).toBe(true); + expect(e.code).toStartWith("ERR_BROTLI_DECODER_ERROR_"); + expect(e.cause.code).toBe(e.code); + } + }); +}); diff --git a/test/js/web/streams/streams.test.js b/test/js/web/streams/streams.test.js index 5256b7502f17..8299c4bd504d 100644 --- a/test/js/web/streams/streams.test.js +++ b/test/js/web/streams/streams.test.js @@ -1276,3 +1276,17 @@ it("auto-allocated byte stream chunks are zero-filled before being exposed to th expect(value.subarray(1).every(b => b === 0)).toBe(true); reader.cancel(); }); + +it("reader.cancel() settles a pending BYOB read with done: true (whatwg ReadableStreamCancel step 5)", async () => { + const stream = new ReadableStream({ + type: "bytes", + pull() {}, + cancel() {}, + }); + const reader = stream.getReader({ mode: "byob" }); + const pendingRead = reader.read(new Uint8Array(16)); + await reader.cancel("test reason"); + const result = await pendingRead; + expect(result.done).toBe(true); + expect(result.value).toBeUndefined(); +}); diff --git a/test/napi/napi-app/bun.lock b/test/napi/napi-app/bun.lock index 605f6d477770..099875ae95ce 100644 --- a/test/napi/napi-app/bun.lock +++ b/test/napi/napi-app/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "napi-buffer-bug", diff --git a/test/napi/napi-app/package.json b/test/napi/napi-app/package.json index c3a3ff5a6db0..b820a510fee2 100644 --- a/test/napi/napi-app/package.json +++ b/test/napi/napi-app/package.json @@ -3,8 +3,8 @@ "version": "1.0.0", "gypfile": true, "scripts": { - "install": "node-gyp rebuild --debug -j max", - "build": "node-gyp rebuild --debug -j max", + "install": "bun --bun node-gyp rebuild --debug -j max", + "build": "bun --bun node-gyp rebuild --debug -j max", "clean": "node-gyp clean" }, "devDependencies": { diff --git a/test/napi/napi-app/standalone_tests.cpp b/test/napi/napi-app/standalone_tests.cpp index 23050e9d2fc5..af93cd1064b1 100644 --- a/test/napi/napi-app/standalone_tests.cpp +++ b/test/napi/napi-app/standalone_tests.cpp @@ -1169,6 +1169,7 @@ static napi_value test_deferred_exceptions(const Napi::CallbackInfo &info) { clear(); + napi_ref object_ref; status = napi_wrap( env, object, nullptr, +[](napi_env env, void *data, void *finalize_hint) { @@ -1176,13 +1177,26 @@ static napi_value test_deferred_exceptions(const Napi::CallbackInfo &info) { printf("napi_throw status: %d\n", napi_throw(env, ok(env))); puts("finalizer end"); }, - nullptr, nullptr); + nullptr, &object_ref); if (status != napi_ok) { printf("napi_wrap failed: %d\n", status); return nullptr; } + // Pin the wrapped object for the rest of the process. Under Node >= 26 a + // finalizer that calls napi_throw aborts if it runs from GC (it would need + // node_api_post_finalizer), but running it at env teardown is allowed and + // prints napi_cannot_run_js. Keeping the object strongly referenced makes + // the finalizer timing deterministic on both runtimes. + uint32_t refcount; + status = napi_reference_ref(env, object_ref, &refcount); + + if (status != napi_ok) { + printf("napi_reference_ref failed: %d\n", status); + return nullptr; + } + clear(); puts("ok"); diff --git a/test/napi/napi-finalizer-delete-ref.test.ts b/test/napi/napi-finalizer-delete-ref.test.ts index 555ec1baf431..876aaeca0039 100644 --- a/test/napi/napi-finalizer-delete-ref.test.ts +++ b/test/napi/napi-finalizer-delete-ref.test.ts @@ -1,12 +1,13 @@ import { spawn, spawnSync } from "bun"; import { beforeAll, expect, it } from "bun:test"; import { existsSync } from "fs"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, canBuildNodeAddons } from "harness"; import { join } from "path"; const addonPath = join(__dirname, "napi-app/build/Debug/test_delete_ref_in_finalizer_experimental.node"); beforeAll(() => { + if (!canBuildNodeAddons()) return; // Build the native addons in napi-app, but only if the one this test needs // is missing (napi.test.ts or a previous run usually has built it already). // The addon doesn't link against bun, so an existing binary stays valid @@ -33,17 +34,19 @@ beforeAll(() => { } }, 300_000); -it("napi_delete_reference can be called from finalizers during GC in experimental modules", async () => { - // Finalizers in NAPI_VERSION_EXPERIMENTAL modules run synchronously while - // the garbage collector is sweeping. Unlike napi_reference_unref (which - // really is forbidden there, see "napi_reference_unref is blocked from - // finalizers in experimental modules" in napi.test.ts), Node.js still - // allows napi_delete_reference during GC: it takes node_api_basic_env, - // and deleting the reference returned by napi_wrap is documented to be - // done from the finalize callback (node-addon-api's ObjectWrap destructor - // does exactly this). Bun used to abort with a "napi_reference_unref" - // panic. - const code = ` +it.skipIf(!canBuildNodeAddons())( + "napi_delete_reference can be called from finalizers during GC in experimental modules", + async () => { + // Finalizers in NAPI_VERSION_EXPERIMENTAL modules run synchronously while + // the garbage collector is sweeping. Unlike napi_reference_unref (which + // really is forbidden there, see "napi_reference_unref is blocked from + // finalizers in experimental modules" in napi.test.ts), Node.js still + // allows napi_delete_reference during GC: it takes node_api_basic_env, + // and deleting the reference returned by napi_wrap is documented to be + // done from the finalize callback (node-addon-api's ObjectWrap destructor + // does exactly this). Bun used to abort with a "napi_reference_unref" + // panic. + const code = ` const addon = require(${JSON.stringify( join(__dirname, "napi-app/build/Debug/test_delete_ref_in_finalizer_experimental.node"), )}); @@ -65,21 +68,23 @@ it("napi_delete_reference can be called from finalizers during GC in experimenta } console.log("SUCCESS"); `; - const { BUN_INSPECT_CONNECT_TO: _, ASAN_OPTIONS, ...rest } = bunEnv; - await using proc = spawn({ - cmd: [bunExe(), "-e", code], - env: { - ...rest, - // If the GC check wrongly fires, die with a plain abort instead of - // hanging in the crash reporter / ASAN symbolizer. - BUN_INTERNAL_SUPPRESS_CRASH_ON_NAPI_ABORT: "1", - ASAN_OPTIONS: "allow_user_segv_handler=1:disable_coredump=1:symbolize=0", - }, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).not.toContain("FATAL ERROR"); - expect(stdout).toContain("SUCCESS"); - expect(exitCode).toBe(0); -}, 30_000); + const { BUN_INSPECT_CONNECT_TO: _, ASAN_OPTIONS, ...rest } = bunEnv; + await using proc = spawn({ + cmd: [bunExe(), "-e", code], + env: { + ...rest, + // If the GC check wrongly fires, die with a plain abort instead of + // hanging in the crash reporter / ASAN symbolizer. + BUN_INTERNAL_SUPPRESS_CRASH_ON_NAPI_ABORT: "1", + ASAN_OPTIONS: "allow_user_segv_handler=1:disable_coredump=1:symbolize=0", + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("FATAL ERROR"); + expect(stdout).toContain("SUCCESS"); + expect(exitCode).toBe(0); + }, + 30_000, +); diff --git a/test/napi/napi-value-ffi.test.ts b/test/napi/napi-value-ffi.test.ts index 8aa13c27092c..808d92f9aee8 100644 --- a/test/napi/napi-value-ffi.test.ts +++ b/test/napi/napi-value-ffi.test.ts @@ -1,13 +1,14 @@ import { spawnSync } from "bun"; import { cc, dlopen } from "bun:ffi"; import { beforeAll, describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, isArm64, isASAN, isWindows } from "harness"; +import { bunEnv, bunExe, canBuildNodeAddons, isArm64, isASAN, isWindows } from "harness"; import { join } from "path"; import source from "./napi-app/ffi_addon_1.c" with { type: "file" }; -// TinyCC (and all of bun:ffi) is disabled on Windows ARM64 -const isFFIUnavailable = isWindows && isArm64; +// TinyCC (and all of bun:ffi) is disabled on Windows ARM64; the napi-app +// fixture needs a toolchain that can compile the reported Node headers. +const isFFIUnavailable = (isWindows && isArm64) || !canBuildNodeAddons(); const symbols = { set_instance_data: { diff --git a/test/napi/napi.test.ts b/test/napi/napi.test.ts index cdf548e5a72f..8312c944788c 100644 --- a/test/napi/napi.test.ts +++ b/test/napi/napi.test.ts @@ -1,11 +1,24 @@ import { spawn, spawnSync } from "bun"; import { beforeAll, describe, expect, it } from "bun:test"; import { readdirSync } from "fs"; -import { bunEnv, bunExe, isCI, isMacOS, isMusl, isWindows, tempDirWithFiles } from "harness"; +import { + bunEnv, + bunExe, + canBuildNodeAddons, + isCI, + isMacOS, + isMusl, + isWindows, + nodeExeMatchingAbi, + tempDirWithFiles, +} from "harness"; import { join } from "path"; -describe.concurrent("napi", () => { - beforeAll(() => { +describe.concurrent.skipIf(!canBuildNodeAddons())("napi", () => { + beforeAll(async () => { + // Resolve (and possibly download) the ABI-matching node here, under the + // generous hook timeout, instead of inside the first test that needs it. + await nodeExeMatchingAbi(); // build gyp console.time("Building node-gyp"); const install = spawnSync({ @@ -21,9 +34,10 @@ describe.concurrent("napi", () => { process.exit(1); } console.timeEnd("Building node-gyp"); - // node-gyp rebuild can take a while under a debug/ASAN binary; default - // 5s hook timeout kills the install subprocess mid-build. - }, 120_000); + // node-gyp rebuild can take a while under a debug/ASAN binary (and the + // hook may first download an ABI-matching node); default 5s hook timeout + // kills the install subprocess mid-build. + }, 300_000); describe.each(["esm", "cjs"])("bundle .node files to %s via", format => { describe.each(["node", "bun"])("target %s", target => { @@ -53,7 +67,7 @@ describe.concurrent("napi", () => { }); expect(build.success).toBeTrue(); - for (let exec of target === "bun" ? [bunExe()] : [bunExe(), "node"]) { + for (let exec of target === "bun" ? [bunExe()] : [bunExe(), await nodeExeMatchingAbi()]) { const result = spawnSync({ cmd: [exec, join(dir, "main.js"), "self"], env: bunEnv, @@ -134,7 +148,7 @@ describe.concurrent("napi", () => { expect(build.logs).toBeEmpty(); - for (let exec of target === "bun" ? [bunExe()] : [bunExe(), "node"]) { + for (let exec of target === "bun" ? [bunExe()] : [bunExe(), await nodeExeMatchingAbi()]) { const result = spawnSync({ cmd: [exec, join(dir, "main.js"), "self"], env: bunEnv, @@ -751,8 +765,11 @@ describe.concurrent("napi", () => { expect(bunStderr).toContain("FATAL ERROR"); expect(bunStdout + bunStderr).toContain("TEST PASSED: Process crashed as expected"); - // The error message should NOT contain "Did not crash" - expect(bunStdout + bunStderr).not.toContain("ERROR: Did not crash"); + // The marker must NOT have actually been printed. Only check stdout: the + // fixture prints the marker via console.log (stdout), while stderr contains + // the debug-build panic report whose "Args:" line echoes the full -e script + // source, including the literal "ERROR: Did not crash! Test failed!". + expect(bunStdout).not.toContain("ERROR: Did not crash"); }, 25_000, ); @@ -776,6 +793,9 @@ async function checkSameOutput(test: string, args: any[] | string, envArgs: Reco async function runOn(executable: string, test: string, args: any[] | string, envArgs: Record = {}) { const env = { ...bunEnv, ...envArgs }; + // "node" means a Node whose addon ABI matches the headers the fixture was + // compiled against (the system node may lag the version Bun reports). + if (executable === "node") executable = await nodeExeMatchingAbi(); const exec = spawn({ cmd: [ executable, @@ -805,6 +825,7 @@ async function runOn(executable: string, test: string, args: any[] | string, env async function checkBothFail(test: string, args: any[] | string, envArgs: Record = {}) { const [node, bun] = await Promise.all( ["node", bunExe()].map(async executable => { + if (executable === "node") executable = await nodeExeMatchingAbi(); const { BUN_INSPECT_CONNECT_TO: _, ...rest } = bunEnv; const env = { ...rest, BUN_INTERNAL_SUPPRESS_CRASH_ON_NAPI_ABORT: "1", ...envArgs }; const exec = spawn({ @@ -829,7 +850,7 @@ async function checkBothFail(test: string, args: any[] | string, envArgs: Record expect(!!node.signalCode).toEqual(!!bun.signalCode); } -describe("cleanup hooks", () => { +describe.skipIf(!canBuildNodeAddons())("cleanup hooks", () => { describe("execution order", () => { it("executes in reverse insertion order like Node.js", async () => { // Test that cleanup hooks execute in reverse insertion order (LIFO) diff --git a/test/napi/node-napi-tests/harness.ts b/test/napi/node-napi-tests/harness.ts index 2befe7ed970b..98c99d992f5f 100644 --- a/test/napi/node-napi-tests/harness.ts +++ b/test/napi/node-napi-tests/harness.ts @@ -7,14 +7,14 @@ const abortingJsNativeApiTests = ["test_finalizer/test_fatal_finalize.js"]; export async function build(dir: string) { const child = spawn({ - cmd: [bunExe(), "x", "node-gyp@11", "rebuild", "--debug", "-j", "max", "--verbose"], + cmd: [bunExe(), "--bun", "x", "node-gyp@11", "rebuild", "--debug", "-j", "max", "--verbose"], cwd: dir, stderr: "pipe", stdout: "ignore", stdin: "inherit", env: { ...bunEnv, - npm_config_target: "v24.3.0", + npm_config_target: "v26.3.0", CXXFLAGS: (bunEnv.CXXFLAGS ?? "") + (process.platform == "win32" ? " -std=c++20" : " -std=gnu++20"), // on linux CI, node-gyp will default to g++ and the version installed there is very old, // so we make it use clang instead diff --git a/test/regression/issue/30205.test.ts b/test/regression/issue/30205.test.ts index 9bd23e922fb7..d1fa2fc7d352 100644 --- a/test/regression/issue/30205.test.ts +++ b/test/regression/issue/30205.test.ts @@ -19,14 +19,14 @@ import { spawnSync } from "bun"; import { beforeAll, describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { bunEnv, bunExe, canBuildNodeAddons, isWindows, tempDir } from "harness"; import { existsSync } from "node:fs"; import { join } from "node:path"; const napiAppDir = join(import.meta.dir, "..", "..", "napi", "napi-app"); const addon = join(napiAppDir, "build", "Debug", "isolate_finalizer_addon.node"); -describe("#30205", () => { +describe.skipIf(!canBuildNodeAddons())("#30205", () => { beforeAll(() => { if (existsSync(addon)) return; // Same one-shot build pattern as test/napi/napi.test.ts; the addon is diff --git a/test/v8/bad-modules/mismatched_abi_version.cpp b/test/v8/bad-modules/mismatched_abi_version.cpp index 73b2e78c03fe..96fc590ea01e 100644 --- a/test/v8/bad-modules/mismatched_abi_version.cpp +++ b/test/v8/bad-modules/mismatched_abi_version.cpp @@ -8,7 +8,7 @@ void init(v8::Local exports, v8::Local module, extern "C" { static node::node_module _module = { - // bun expects 137 (Node.js 24.3.0) + // bun expects 147 (Node.js 26.3.0) 42, // nm_version 0, // nm_flags nullptr, // nm_dso_handle diff --git a/test/v8/bad-modules/no_entrypoint.cpp b/test/v8/bad-modules/no_entrypoint.cpp index 2ebb9ae7c453..cb2784884b3e 100644 --- a/test/v8/bad-modules/no_entrypoint.cpp +++ b/test/v8/bad-modules/no_entrypoint.cpp @@ -2,7 +2,7 @@ extern "C" { static node::node_module _module = { - 137, // nm_version (Node.js 24.3.0) + NODE_MODULE_VERSION, // nm_version 0, // nm_flags nullptr, // nm_dso_handle "no_entrypoint.cpp", // nm_filename diff --git a/test/v8/v8-module/main.cpp b/test/v8/v8-module/main.cpp index 3cc8e33f0f7d..9eaa53295342 100644 --- a/test/v8/v8-module/main.cpp +++ b/test/v8/v8-module/main.cpp @@ -45,15 +45,15 @@ static std::string describe(Isolate *isolate, Local value) { return "false"; } else if (value->IsString()) { char buf[1024] = {0}; - value.As()->WriteUtf8(isolate, buf, sizeof(buf) - 1); + value.As()->WriteUtf8V2(isolate, buf, sizeof(buf) - 1); std::string result = "\""; result += buf; result += "\""; return result; } else if (value->IsFunction()) { char buf[1024] = {0}; - value.As()->GetName().As()->WriteUtf8(isolate, buf, - sizeof(buf) - 1); + value.As()->GetName().As()->WriteUtf8V2( + isolate, buf, sizeof(buf) - 1); std::string result = "function "; result += buf; result += "()"; @@ -131,36 +131,45 @@ static void perform_string_test(const FunctionCallbackInfo &info, Local v8_string) { Isolate *isolate = info.GetIsolate(); char buf[256] = {0x7f}; - int retval; - int nchars; + size_t retval; + size_t nchars; LOG_VALUE_KIND(v8_string); LOG_EXPR(v8_string->Length()); - LOG_EXPR(v8_string->Utf8Length(isolate)); + LOG_EXPR(v8_string->Utf8LengthV2(isolate)); LOG_EXPR(v8_string->IsOneByte()); LOG_EXPR(v8_string->ContainsOnlyOneByte()); LOG_EXPR(v8_string->IsExternal()); LOG_EXPR(v8_string->IsExternalTwoByte()); LOG_EXPR(v8_string->IsExternalOneByte()); - // check string has the right contents - LOG_EXPR(retval = v8_string->WriteUtf8(isolate, buf, sizeof buf, &nchars)); + // check string has the right contents. The legacy WriteUtf8 null-terminated + // by default; with WriteUtf8V2 that behavior is requested explicitly via + // kNullTerminate so the buffer contents stay the same. + LOG_EXPR(retval = v8_string->WriteUtf8V2(isolate, buf, sizeof buf, + String::WriteFlags::kNullTerminate, + &nchars)); LOG_EXPR(nchars); - log_buffer(buf, retval + 1); + log_buffer(buf, static_cast(retval) + 1); memset(buf, 0x7f, sizeof buf); - // try with assuming the buffer is large enough - LOG_EXPR(retval = v8_string->WriteUtf8(isolate, buf, -1, &nchars)); + // legacy WriteUtf8 accepted length = -1 to assume the buffer is large + // enough; WriteUtf8V2 always takes an explicit capacity + LOG_EXPR(retval = v8_string->WriteUtf8V2(isolate, buf, sizeof buf, + String::WriteFlags::kNullTerminate, + &nchars)); LOG_EXPR(nchars); - log_buffer(buf, retval + 1); + log_buffer(buf, static_cast(retval) + 1); memset(buf, 0x7f, sizeof buf); // try with ignoring nchars (it should not try to store anything in a // nullptr) - LOG_EXPR(retval = v8_string->WriteUtf8(isolate, buf, sizeof buf, nullptr)); - log_buffer(buf, retval + 1); + LOG_EXPR(retval = v8_string->WriteUtf8V2(isolate, buf, sizeof buf, + String::WriteFlags::kNullTerminate, + nullptr)); + log_buffer(buf, static_cast(retval) + 1); memset(buf, 0x7f, sizeof buf); @@ -232,10 +241,16 @@ void test_v8_string_write_utf8(const FunctionCallbackInfo &info) { Local s = String::NewFromUtf8(isolate, utf8_data).ToLocalChecked(); for (int i = buf_size; i >= 0; i--) { memset(buf, 0xaa, buf_size); - int nchars; - int retval = s->WriteUtf8(isolate, buf, i, &nchars); - printf("buffer size = %2d, nchars = %2d, returned = %2d, data =", i, nchars, - retval); + size_t nchars; + // WriteUtf8V2 requires capacity >= 1 when null termination is requested, + // so only ask for it when the buffer is non-empty (legacy WriteUtf8 also + // wrote nothing for a zero-sized buffer). + size_t retval = s->WriteUtf8V2(isolate, buf, static_cast(i), + i > 0 ? String::WriteFlags::kNullTerminate + : String::WriteFlags::kNone, + &nchars); + printf("buffer size = %2d, nchars = %2zu, returned = %2zu, data =", i, + nchars, retval); for (int j = 0; j < buf_size; j++) { printf("%c%02x", j == i ? '|' : ' ', reinterpret_cast(buf)[j]); @@ -245,10 +260,11 @@ void test_v8_string_write_utf8(const FunctionCallbackInfo &info) { return ok(info); } -// Regression test for WriteUtf8 when a valid surrogate pair (astral character) -// does not fit in the remaining buffer. V8's legacy WriteUtf8 encodes the -// unpaired lead surrogate as WTF-8 (3 bytes, 0xED 0xA0-0xAF ...) in that case -// rather than leaving the buffer untouched. The encoder that backs this on Bun +// Regression test for writing UTF-8 when a valid surrogate pair (astral +// character) does not fit in the remaining buffer. V8's legacy WriteUtf8 +// encoded the unpaired lead surrogate as WTF-8 (3 bytes, 0xED 0xA0-0xAF ...) +// in that case; WriteUtf8V2 instead refuses to write partial sequences and +// stops before the astral character. The encoder that backs this on Bun // previously wrote U+FFFD (0xEF 0xBF 0xBD) here, diverging from V8. void test_v8_string_write_utf8_surrogate(const FunctionCallbackInfo &info) { Isolate *isolate = info.GetIsolate(); @@ -269,10 +285,13 @@ void test_v8_string_write_utf8_surrogate(const FunctionCallbackInfo &info Local s = String::NewFromUtf8(isolate, in.utf8).ToLocalChecked(); for (int i = total; i >= 0; i--) { memset(buf, 0xaa, total); - int nchars; - int retval = s->WriteUtf8(isolate, buf, i, &nchars); - printf("%-7s size = %d, nchars = %d, returned = %d, data =", in.label, i, - nchars, retval); + size_t nchars; + size_t retval = s->WriteUtf8V2(isolate, buf, static_cast(i), + i > 0 ? String::WriteFlags::kNullTerminate + : String::WriteFlags::kNone, + &nchars); + printf("%-7s size = %d, nchars = %zu, returned = %zu, data =", in.label, + i, nchars, retval); for (int j = 0; j < total; j++) { printf("%c%02x", j == i ? '|' : ' ', reinterpret_cast(buf)[j]); @@ -479,12 +498,14 @@ static void examine_object_fields(Isolate *isolate, Local o, int expected_field0, int expected_field1) { char buf[16]; HandleScope hs(isolate); - o->GetInternalField(0).As()->WriteUtf8(isolate, buf); + o->GetInternalField(0).As()->WriteUtf8V2( + isolate, buf, sizeof buf, String::WriteFlags::kNullTerminate); assert(atoi(buf) == expected_field0); Local field1 = o->GetInternalField(1).As(); if (field1->IsString()) { - field1.As()->WriteUtf8(isolate, buf); + field1.As()->WriteUtf8V2(isolate, buf, sizeof buf, + String::WriteFlags::kNullTerminate); assert(atoi(buf) == expected_field1); } else { assert(field1->IsUndefined()); @@ -542,7 +563,8 @@ void test_handle_scope_gc(const FunctionCallbackInfo &info) { // try to use all mini strings for (size_t j = 0; j < num_small_allocs; j++) { char buf[16]; - mini_strings[j]->WriteUtf8(isolate, buf); + mini_strings[j]->WriteUtf8V2(isolate, buf, sizeof buf, + String::WriteFlags::kNullTerminate); assert(atoi(buf) == (int)j); } @@ -569,7 +591,8 @@ void test_handle_scope_gc(const FunctionCallbackInfo &info) { memset(string_data, 0, string_size); for (size_t i = 0; i < num_strings; i++) { - huge_strings[i]->WriteUtf8(isolate, string_data); + huge_strings[i]->WriteUtf8V2(isolate, string_data, string_size, + String::WriteFlags::kNullTerminate); for (size_t j = 0; j < string_size - 1; j++) { assert(string_data[j] == (char)(i + 1)); } @@ -611,11 +634,83 @@ void test_v8_escapable_handle_scope(const FunctionCallbackInfo &info) { LOG_VALUE_KIND(t); char buf[16]; - s->WriteUtf8(isolate, buf); + s->WriteUtf8V2(isolate, buf, sizeof buf, String::WriteFlags::kNullTerminate); LOG_EXPR(buf); LOG_EXPR(n->Value()); } +// Regression test: the escape slot must be reserved when the escapable scope +// opens, not when Escape() is called. With Node 26 headers the inline +// ~HandleScope calls DeleteExtensions, which frees every handle created +// inside the scope — including, before the fix, an escape handle allocated at +// Escape() time after in-scope Local copies. +Local escape_after_inline_handles(Isolate *isolate) { + EscapableHandleScope ehs(isolate); + Local value = + String::NewFromUtf8(isolate, "escaped-after-inline").ToLocalChecked(); + // These go through the headers' inline CreateHandle (HandleScope::Extend + // grants) and are swept by DeleteExtensions when the scope closes. + Local copy1 = Local::New(isolate, Local::Cast(value)); + Local copy2 = Local::New(isolate, copy1); + (void)copy2; + Local escaped = ehs.Escape(value); + return escaped; +} + +void test_v8_escapable_handle_scope_inline_grants( + const FunctionCallbackInfo &info) { + Isolate *isolate = info.GetIsolate(); + Local s = escape_after_inline_handles(isolate); + // Create more handles so a freed escape slot would be overwritten before we + // read it back. + for (int i = 0; i < 16; i++) { + (void)Number::New(isolate, i * 1.5); + } + LOG_VALUE_KIND(s); + char buf[32]; + s->WriteUtf8V2(isolate, buf, sizeof buf, String::WriteFlags::kNullTerminate); + LOG_EXPR(buf); +} + +// Regression test: handles created through the headers' inline CreateHandle +// must survive a Bun-internal HandleScope push/pop (Array::Iterate pushes one +// around the iteration callback). If the pop leaves the isolate's +// HandleScopeData pointing into the popped scope's buffer, a later inline +// v8::HandleScope snapshots that stale limit and its DeleteExtensions sweeps +// the enclosing buffer's grants — including `kept`. +void test_v8_locals_survive_nested_call( + const FunctionCallbackInfo &info) { + Isolate *isolate = info.GetIsolate(); + Local context = isolate->GetCurrentContext(); + Local value = + String::NewFromUtf8(isolate, "kept-across-call").ToLocalChecked(); + // Inline grant before the nested scope push. + Local kept = Local::New(isolate, Local::Cast(value)); + Local array = Array::New(isolate, 3); + // Array::Iterate pushes (and pops) a Bun-internal handle scope around the + // callback; the inline Local::New inside makes Extend run while that scope + // is current. + (void)array->Iterate( + context, + [](uint32_t index, Local element, void *data) { + Isolate *iso = static_cast(data); + Local copy = Local::New(iso, element); + (void)copy; + return Array::CallbackResult::kContinue; + }, + isolate); + // Inline scope after the pop: snapshots whatever HandleScopeData now holds. + { + HandleScope inner(isolate); + Local tmp = Local::New(isolate, kept); + (void)tmp; + } // ~HandleScope → DeleteExtensions + char buf[32]; + Local::Cast(kept)->WriteUtf8V2(isolate, buf, sizeof buf, + String::WriteFlags::kNullTerminate); + LOG_EXPR(buf); +} + void test_uv_os_getpid(const FunctionCallbackInfo &info) { #ifndef _WIN32 assert(getpid() == uv_os_getpid()); @@ -1207,6 +1302,10 @@ void initialize(Local exports, Local module, NODE_SET_METHOD(exports, "test_handle_scope_gc", test_handle_scope_gc); NODE_SET_METHOD(exports, "test_v8_escapable_handle_scope", test_v8_escapable_handle_scope); + NODE_SET_METHOD(exports, "test_v8_escapable_handle_scope_inline_grants", + test_v8_escapable_handle_scope_inline_grants); + NODE_SET_METHOD(exports, "test_v8_locals_survive_nested_call", + test_v8_locals_survive_nested_call); NODE_SET_METHOD(exports, "test_uv_os_getpid", test_uv_os_getpid); NODE_SET_METHOD(exports, "test_uv_os_getppid", test_uv_os_getppid); NODE_SET_METHOD(exports, "test_v8_object_get_by_key", @@ -1233,7 +1332,9 @@ void initialize(Local exports, Local module, test_v8_value_type_checks); // without this, node hits a UAF deleting the Global - node::AddEnvironmentCleanupHook(context->GetIsolate(), + // (Context::GetIsolate was removed in V8 14.6; the module initializer runs + // with the isolate entered, so take the current one) + node::AddEnvironmentCleanupHook(Isolate::GetCurrent(), GlobalTestWrapper::cleanup, nullptr); } diff --git a/test/v8/v8.test.ts b/test/v8/v8.test.ts index 8363e2745e6f..77a4575fc1ea 100644 --- a/test/v8/v8.test.ts +++ b/test/v8/v8.test.ts @@ -1,7 +1,18 @@ import { spawn } from "bun"; import { jscDescribe } from "bun:jsc"; import { beforeAll, describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, isASAN, isBroken, isMusl, isWindows, nodeExe, tempDir, tmpdirSync } from "harness"; +import { + bunEnv, + bunExe, + canBuildNodeAddons, + isASAN, + isBroken, + isMusl, + isWindows, + nodeExeMatchingAbi, + tempDir, + tmpdirSync, +} from "harness"; import assert from "node:assert"; import fs from "node:fs/promises"; import { basename, join } from "path"; @@ -21,7 +32,7 @@ enum BuildMode { delete bunEnv.CC; delete bunEnv.CXX; -// Node.js 24.3.0 requires C++20 +// Node.js 26.3.0 requires C++20 bunEnv.CXXFLAGS ??= ""; if (process.platform == "darwin") { bunEnv.CXXFLAGS += " -std=gnu++20"; @@ -77,7 +88,14 @@ async function build( "-j", "max", ] - : [bunExe(), "run", "node-gyp", "rebuild", "--release", "-j", "max"], // for node.js we don't bother with debug mode + : // for node.js we don't bother with debug mode. Run node-gyp under bun + // (--bun) here too: a clang-cl-built Node carries thin-LTO flags in + // process.config.target_defaults that node-gyp copies into + // config.gypi and MSVC's link.exe chokes on (/opt:lldltojobs) — gyp + // -D defines can't override target_defaults. Bun reports the same + // ABI (147) with clean target_defaults, so the module loads in + // node 26 all the same. + [bunExe(), "--bun", "run", "node-gyp", "rebuild", "--release", "-j", "max"], cwd: tmpDir, env: bunEnv, stdin: "inherit", @@ -104,7 +122,7 @@ async function build( console.log(err); } -describe.todoIf(isBroken && isMusl)("node:v8", () => { +describe.skipIf(!canBuildNodeAddons()).todoIf(isBroken && isMusl)("node:v8", () => { beforeAll(async () => { // set up clean directories for our 4 builds directories.bunRelease = tmpdirSync(); @@ -121,7 +139,11 @@ describe.todoIf(isBroken && isMusl)("node:v8", () => { await build(srcDir, directories.bunDebug, Runtime.bun, BuildMode.debug); await build(srcDir, directories.node, Runtime.node, BuildMode.release); await build(join(__dirname, "bad-modules"), directories.badModules, Runtime.node, BuildMode.release); - }); + + // Resolve (and possibly download) the ABI-matching node here, under the + // generous hook timeout, instead of inside the first test that needs it. + await nodeExeMatchingAbi(); + }, 600_000); describe("module lifecycle", () => { it("can call a basic native function", async () => { @@ -305,6 +327,14 @@ describe.todoIf(isBroken && isMusl)("node:v8", () => { it("keeps handles alive in the outer scope", async () => { await checkSameOutput("test_v8_escapable_handle_scope"); }); + + it("escaped handles survive in-scope inline handle creation", async () => { + await checkSameOutput("test_v8_escapable_handle_scope_inline_grants"); + }); + + it("inline handles survive a nested call's scope push/pop", async () => { + await checkSameOutput("test_v8_locals_survive_nested_call"); + }); }); describe("MaybeLocal", () => { @@ -368,7 +398,7 @@ async function runOn(runtime: Runtime, buildMode: BuildMode, testName: string, j : buildMode == BuildMode.debug ? directories.bunDebug : directories.bunRelease; - const exe = runtime == Runtime.node ? (nodeExe() ?? "node") : bunExe(); + const exe = runtime == Runtime.node ? await nodeExeMatchingAbi() : bunExe(); const cmd = [ exe, @@ -389,7 +419,7 @@ async function runOn(runtime: Runtime, buildMode: BuildMode, testName: string, j stdio: ["inherit", "pipe", "pipe"], }); const [exitCode, out, err] = await Promise.all([proc.exited, proc.stdout.text(), proc.stderr.text()]); - const crashMsg = `test ${testName} crashed under ${Runtime[runtime]} in ${BuildMode[buildMode]} mode`; + const crashMsg = `test ${testName} crashed under ${Runtime[runtime]} in ${BuildMode[buildMode]} mode (exit code ${exitCode}${exitCode && exitCode > 256 ? ` / 0x${exitCode.toString(16)}` : ""})`; if (exitCode !== 0) { throw new Error(`${crashMsg}: ${err}\n${out}`.trim()); } @@ -397,13 +427,15 @@ async function runOn(runtime: Runtime, buildMode: BuildMode, testName: string, j return out.trim(); } -describe.todoIf(isBroken && isMusl)("String::Utf8Length bounds", () => { +describe.skipIf(!canBuildNodeAddons()).todoIf(isBroken && isMusl)("String::Utf8Length bounds", () => { it( - "saturates at INT32_MAX for strings whose UTF-8 size exceeds it", + "reports sizes beyond INT32_MAX without wrapping", async () => { - // Build a tiny standalone V8-API addon that just reports String::Utf8Length of its + // Build a tiny standalone V8-API addon that just reports String::Utf8LengthV2 of its // argument, then feed it a Latin-1 string whose UTF-8 expansion is larger than INT32_MAX. - // The reported length must stay positive and saturate at INT32_MAX instead of wrapping. + // Utf8LengthV2 returns size_t, so the reported length must be the exact byte count + // instead of wrapping to a negative or small value (the legacy int-returning Utf8Length + // saturated at INT32_MAX here). using dir = tempDir("v8-utf8-length", { "package.json": JSON.stringify({ name: "v8-utf8-length-test", @@ -434,7 +466,7 @@ namespace utf8len_test { void string_utf8_length(const FunctionCallbackInfo &info) { Isolate *isolate = info.GetIsolate(); Local s = info[0].As(); - printf("Utf8Length = %d\\n", s->Utf8Length(isolate)); + printf("Utf8Length = %zu\\n", s->Utf8LengthV2(isolate)); fflush(stdout); } @@ -471,7 +503,20 @@ addon.string_utf8_length("\\u00ff".repeat(2 ** 30 + 1)); { const build = spawn({ - cmd: [bunExe(), "--bun", "run", "node-gyp", "rebuild", "--release", "-j", "max"], + cmd: [ + bunExe(), + "--bun", + "run", + "node-gyp", + "rebuild", + "--release", + "-j", + "max", + "--", + "-Denable_lto=false", + "-Denable_thin_lto=false", + "-Dlto_jobs=", + ], cwd, env: bunEnv, stdin: "inherit", @@ -507,9 +552,10 @@ addon.string_utf8_length("\\u00ff".repeat(2 ** 30 + 1)); .trim() .split(/\r?\n/) .filter(Boolean); - // The small string reports its exact UTF-8 size; the oversized string saturates at - // INT32_MAX (2147483647) instead of wrapping to a negative or small value. - expect(lines, `stderr:\n${err}`).toEqual(["Utf8Length = 6", "Utf8Length = 2147483647"]); + // Both strings report their exact UTF-8 size: Utf8LengthV2 returns size_t, so the + // oversized string's 2**31 + 2 bytes are reported exactly instead of wrapping or + // saturating at INT32_MAX like the legacy Utf8Length did. + expect(lines, `stderr:\n${err}`).toEqual(["Utf8Length = 6", "Utf8Length = 2147483650"]); expect(exitCode).toBe(0); }, 10 * 60 * 1000, From 5c30adcbe191583d28ee8e578c5b91bfcb3601ef Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Mon, 15 Jun 2026 13:06:05 -0700 Subject: [PATCH 2/5] http2: reject array headers in sendTrailers() and additionalHeaders() [build images] Node 26 throws ERR_INVALID_ARG_TYPE for an array passed to either method (verified on v26.3.0); without the guard an array spreads to {'0':..} and emits invalid trailer/info-header frames. Completes the $isArray guard added to respond()/respondWithFD()/respondWithFile(). --- src/js/node/http2.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 2173b9301df7..796bc07163df 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -2037,7 +2037,7 @@ class Http2Stream extends Duplex { if (headers == undefined) { headers = {}; - } else if (!$isObject(headers)) { + } else if (!$isObject(headers) || $isArray(headers)) { throw $ERR_INVALID_ARG_TYPE("headers", "object", headers); } else { headers = { ...headers }; @@ -2571,7 +2571,7 @@ class ServerHttp2Stream extends Http2Stream { if (headers == undefined) { headers = {}; - } else if (!$isObject(headers)) { + } else if (!$isObject(headers) || $isArray(headers)) { throw $ERR_INVALID_ARG_TYPE("headers", "object", headers); } else { headers = { ...headers }; From 5c81608e6b9eaf6f79a578d6a6ac9cd12d03d398 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Tue, 16 Jun 2026 10:43:05 -0700 Subject: [PATCH 3/5] http2: send the peer's last-processed stream id in auto-filled GOAWAY [build images] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When session.goaway() is called without a lastStreamID (or with the JS-default 0), the auto-filled value was the highest stream id seen in either direction — for a client that is its own (odd) request id. RFC 9113 §6.8 says GOAWAY's last-stream-id refers to streams the RECEIVER initiated; nghttp2 servers reject a wrong-parity id with NGHTTP2_ERR_PROTO (-505) and tear the connection down. Track the highest peer-initiated id separately (odd for a server, even for a client) and use it for the auto value — node's last_proc_stream_id semantics. Verified: with the fix, the spawned-node fixture in node-http2.test.js no longer hits the -505 (10/10 clean; was 5/10). --- src/runtime/api/bun/h2_frame_parser.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index 127829b1073e..df18b3be902d 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -1262,6 +1262,12 @@ pub struct H2FrameParser { out_standing_pings: Cell, max_send_header_block_length: Cell, last_stream_id: Cell, + /// Highest PEER-initiated stream id processed (odd ids for a server, even for a + /// client). This — not `last_stream_id` — is what an auto-filled GOAWAY must carry: + /// RFC 9113 §6.8 last_stream_id refers to streams the RECEIVER initiated, and + /// nghttp2 servers reject a GOAWAY naming a client-initiated id with a connection + /// PROTOCOL_ERROR (node's last_proc_stream_id semantics). + last_peer_stream_id: Cell, // Stream id whose header block is awaiting CONTINUATION frames // (RFC 9113 §4.3); 0 when none. expecting_continuation: Cell, @@ -4496,6 +4502,12 @@ impl H2FrameParser { if stream_identifier > self.last_stream_id.get() { self.last_stream_id.set(stream_identifier); } + let peer_parity: u32 = if self.is_server.get() { 1 } else { 0 }; + if stream_identifier % 2 == peer_parity + && stream_identifier > self.last_peer_stream_id.get() + { + self.last_peer_stream_id.set(stream_identifier); + } // new stream open let local_window_size = if self.outstanding_settings.get() > 0 { @@ -5134,7 +5146,7 @@ impl H2FrameParser { } let error_code = error_code_arg.to_int32(); - let mut last_stream_id = this.last_stream_id.get(); + let mut last_stream_id = this.last_peer_stream_id.get(); if args_list.len >= 2 { let last_stream_arg = args_list.ptr[1]; if !last_stream_arg.is_empty_or_undefined_or_null() { @@ -7521,6 +7533,7 @@ impl H2FrameParser { out_standing_pings: Cell::new(0), max_send_header_block_length: Cell::new(0), last_stream_id: Cell::new(0), + last_peer_stream_id: Cell::new(0), expecting_continuation: Cell::new(0), is_server: Cell::new(false), preface_received_len: Cell::new(0), From 731609d2ac74184f6f15ffcad802d3a44517f6dc Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Wed, 10 Jun 2026 17:12:24 -0700 Subject: [PATCH 4/5] net,tls: port Node.js net/tls compatibility tests and fix the gaps they surface [build images] Half-open/reset/write semantics, server TLSSocket wrap, session/keylog, SNICallback/ALPNCallback, pfx, OpenSSL error shapes, addCACert, local binding (+305 tests). --- packages/bun-usockets/src/bsd.c | 85 +- packages/bun-usockets/src/context.c | 21 +- packages/bun-usockets/src/crypto/openssl.c | 1008 +++++++++++++- .../bun-usockets/src/eventing/epoll_kqueue.c | 16 +- packages/bun-usockets/src/internal/internal.h | 22 +- .../src/internal/networking/bsd.h | 6 +- packages/bun-usockets/src/libusockets.h | 49 +- packages/bun-usockets/src/loop.c | 28 +- packages/bun-usockets/src/socket.c | 62 + packages/bun-uws/src/App.h | 14 +- ...02-quiet-build-script-linker-warning.patch | 12 + scripts/build/cargo-config.ts | 12 +- scripts/build/deps/lolhtml.ts | 2 +- scripts/build/rust-lto-fix-cli.ts | 9 +- scripts/build/rust.ts | 13 + src/http/HTTPContext.rs | 22 +- src/http/ProxyTunnel.rs | 4 + src/http/lib.rs | 16 + src/http/ssl_config.rs | 17 + .../websocket_client/WebSocketProxyTunnel.rs | 4 + .../WebSocketUpgradeClient.rs | 11 +- src/js/internal/shared.ts | 121 ++ src/js/node/http2.ts | 20 + src/js/node/net.ts | 1234 +++++++++++++++-- src/js/node/perf_hooks.ts | 91 +- src/js/node/tls.ts | 868 +++++++++++- src/jsc/ErrorCode.rs | 151 +- src/jsc/bindings/ErrorCode.cpp | 4 + src/jsc/bindings/ErrorCode.ts | 2 + src/jsc/bindings/NodeValidator.cpp | 13 +- src/jsc/generated.rs | 18 + src/runtime/api/SecureContext.classes.ts | 16 +- src/runtime/api/bun/SecureContext.rs | 194 ++- src/runtime/api/bun/h2_frame_parser.rs | 55 +- src/runtime/crypto/boringssl_jsc.rs | 95 +- src/runtime/node/node_net_binding.rs | 6 +- src/runtime/socket/Handlers.rs | 36 + src/runtime/socket/Listener.rs | 218 ++- src/runtime/socket/SSLConfig.bindv2.ts | 10 + src/runtime/socket/SSLConfig.rs | 6 +- src/runtime/socket/SocketConfig.bindv2.ts | 4 + src/runtime/socket/UpgradedDuplex.rs | 23 + src/runtime/socket/WindowsNamedPipe.rs | 37 + src/runtime/socket/WindowsNamedPipeContext.rs | 20 + src/runtime/socket/socket_body.rs | 676 ++++++++- src/runtime/socket/sockets.classes.ts | 16 + src/runtime/socket/tls_socket_functions.rs | 225 ++- src/runtime/socket/uws_dispatch.rs | 71 +- src/sql_jsc/mysql/MySQLConnection.rs | 1 + src/sql_jsc/postgres/PostgresSQLConnection.rs | 1 + src/uws/lib.rs | 112 ++ src/uws_sys/ListenSocket.rs | 7 +- src/uws_sys/SocketContext.rs | 27 + src/uws_sys/SocketGroup.rs | 5 + src/uws_sys/SocketKind.rs | 4 + src/uws_sys/socket.rs | 47 +- src/uws_sys/us_socket_t.rs | 80 ++ test/cli/init/init.test.ts | 27 +- test/expectations.txt | 42 + .../bun-types/fixture/serve-types.test.ts | 2 +- test/js/bun/net/socket.test.ts | 6 +- test/js/node/http2/node-http2.test.js | 9 +- test/js/node/net/node-net.test.ts | 3 + test/js/node/test/common/boringssl.js | 346 +++++ test/js/node/test/common/index.js | 2 +- test/js/node/test/common/tls.js | 42 + test/js/node/test/fixtures/list-certs.js | 19 + .../test/fixtures/tls-extra-ca-override.js | 50 + .../tls-get-ca-certificates-worker.js | 10 + ...rver-reject-chunked-with-content-length.js | 30 - ...st-http2-server-shutdown-options-errors.js | 3 +- .../test/parallel/test-net-allow-half-open.js | 47 + ...selectfamily-attempt-timeout-cli-option.js | 10 + ...net-autoselectfamily-commandline-option.js | 48 + .../parallel/test-net-autoselectfamily.js | 223 +++ test/js/node/test/parallel/test-net-binary.js | 88 ++ .../node/test/parallel/test-net-bytes-read.js | 47 + .../test/parallel/test-net-bytes-stats.js | 78 ++ .../parallel/test-net-client-bind-twice.js | 26 + .../test/parallel/test-net-connect-memleak.js | 58 + .../test-net-connect-options-allowhalfopen.js | 118 ++ .../test-net-connect-paused-connection.js | 33 + .../test-net-connect-reset-after-destroy.js | 29 + .../test-net-connect-reset-until-connected.js | 29 + .../test/parallel/test-net-end-destroyed.js | 26 + .../test/parallel/test-net-error-twice.js | 63 + .../test/parallel/test-net-large-string.js | 51 + .../test-net-pause-resume-connecting.js | 95 ++ .../node/test/parallel/test-net-perf_hooks.js | 60 + .../node/test/parallel/test-net-pingpong.js | 133 ++ .../parallel/test-net-pipe-connect-errors.js | 97 ++ .../parallel/test-net-pipe-with-long-path.js | 36 + .../parallel/test-net-server-keepalive.js | 35 + .../test-net-server-listen-options.js | 94 ++ .../parallel/test-net-server-listen-path.js | 91 ++ .../test/parallel/test-net-server-nodelay.js | 26 + .../test/parallel/test-net-server-reset.js | 30 + .../parallel/test-net-socket-reset-send.js | 30 + .../parallel/test-net-socket-setnodelay.js | 56 + .../node/test/parallel/test-net-socket-tos.js | 100 ++ .../test-net-socket-write-after-close.js | 42 + test/js/node/test/parallel/test-net-stream.js | 51 + .../parallel/test-net-write-after-close.js | 52 + .../parallel/test-net-write-after-end-nt.js | 32 + .../parallel/test-tls-basic-validations.js | 137 ++ .../node/test/parallel/test-tls-buffersize.js | 43 + .../parallel/test-tls-cert-chains-concat.js | 48 + .../parallel/test-tls-cli-max-version-1.2.js | 15 + .../parallel/test-tls-cli-max-version-1.3.js | 15 + .../parallel/test-tls-cli-min-version-1.0.js | 15 + .../parallel/test-tls-cli-min-version-1.1.js | 15 + .../parallel/test-tls-cli-min-version-1.2.js | 15 + .../parallel/test-tls-cli-min-version-1.3.js | 15 + .../test-tls-client-getephemeralkeyinfo.js | 88 ++ .../parallel/test-tls-client-reject-12.js | 13 + .../test/parallel/test-tls-client-reject.js | 112 ++ .../test-tls-client-renegotiation-13.js | 55 + .../parallel/test-tls-client-resume-12.js | 13 + .../test/parallel/test-tls-client-resume.js | 115 ++ ...t-tls-clientcertengine-invalid-arg-type.js | 15 + .../test/parallel/test-tls-cnnic-whitelist.js | 56 + .../parallel/test-tls-connect-given-socket.js | 85 ++ .../test/parallel/test-tls-connect-memleak.js | 66 + .../test-tls-connect-timeout-option.js | 20 + .../test-tls-dhparam-auto-boringssl.js | 19 + .../test-tls-disable-renegotiation.js | 99 ++ .../node/test/parallel/test-tls-econnreset.js | 4 +- .../parallel/test-tls-empty-sni-context.js | 35 + .../parallel/test-tls-enable-keylog-cli.js | 61 + .../parallel/test-tls-env-bad-extra-ca.js | 44 + .../test-tls-env-extra-ca-with-options.js | 82 ++ .../test/parallel/test-tls-env-extra-ca.js | 46 + .../parallel/test-tls-error-servername.js | 48 + .../test/parallel/test-tls-error-stack.js | 21 + .../parallel/test-tls-exportkeyingmaterial.js | 102 ++ .../test/parallel/test-tls-fast-writing.js | 4 +- .../node/test/parallel/test-tls-finished.js | 68 + ...get-ca-certificates-system-without-flag.js | 36 + .../parallel/test-tls-getcertificate-x509.js | 38 + .../test/parallel/test-tls-getprotocol.js | 68 + .../test/parallel/test-tls-invalid-pfx.js | 23 + .../test-tls-ip-servername-forbidden.js | 18 + .../node/test/parallel/test-tls-js-stream.js | 66 + .../test/parallel/test-tls-key-mismatch.js | 47 + .../test/parallel/test-tls-keylog-tlsv13.js | 36 + .../test/parallel/test-tls-min-max-version.js | 287 ++++ .../node/test/parallel/test-tls-multi-key.js | 196 +++ .../test-tls-net-socket-keepalive-12.js | 13 + .../parallel/test-tls-net-socket-keepalive.js | 57 + .../parallel/test-tls-no-cert-required.js | 62 + .../node/test/parallel/test-tls-no-sslv23.js | 58 + ...st-tls-off-thread-cert-loading-disabled.js | 40 + ...ls-psk-alpn-callback-exception-handling.js | 430 ++++++ .../test/parallel/test-tls-psk-circuit.js | 81 ++ .../node/test/parallel/test-tls-psk-server.js | 24 +- .../test-tls-reduced-SECLEVEL-in-cipher.js | 31 + .../test/parallel/test-tls-secure-session.js | 46 + .../test-tls-server-capture-rejection.js | 34 + ...rver-failed-handshake-emits-clienterror.js | 29 + .../test-tls-session-timeout-errors.js | 36 + .../test/parallel/test-tls-set-ciphers.js | 4 +- ...et-default-ca-certificates-array-buffer.js | 39 + ...t-tls-set-default-ca-certificates-basic.js | 58 + ...t-tls-set-default-ca-certificates-error.js | 41 + ...-default-ca-certificates-extra-override.js | 19 + ...set-default-ca-certificates-mixed-types.js | 46 + ...ault-ca-certificates-precedence-bundled.js | 53 + ...efault-ca-certificates-precedence-empty.js | 51 + .../node/test/parallel/test-tls-sni-option.js | 174 +++ .../parallel/test-tls-snicallback-error.js | 24 + .../test/parallel/test-tls-ticket-cluster.js | 140 ++ .../parallel/test-tls-ticket-invalid-arg.js | 24 + test/js/node/test/parallel/test-tls-ticket.js | 163 +++ .../test/parallel/test-tls-timeout-server.js | 47 + .../parallel/test-tls-wrap-econnreset-pipe.js | 48 + .../parallel/test-tls-wrap-event-emmiter.js | 17 + .../test/parallel/test-vm-module-errors.js | 4 +- .../test-net-listen-shared-ports.js | 67 + .../test/sequential/test-net-localport.js | 20 + test/js/node/tls/node-tls-cert.test.ts | 141 +- test/js/node/tls/node-tls-connect.test.ts | 142 +- test/js/node/tls/node-tls-server.test.ts | 457 ++++++ test/js/node/tls/ssl-ctx-cache.test.ts | 140 +- .../node/tls/tls-connect-socket-churn.test.ts | 10 +- test/js/web/fetch/chunked-trailing.test.js | 23 + test/js/web/fetch/fetch-leak.test.ts | 6 +- .../fetch-tls-abortsignal-timeout.test.ts | 7 +- test/js/web/fetch/fetch.test.ts | 10 +- ...cket-permessage-deflate-edge-cases.test.ts | 4 + .../websocket-subprotocol-strict.test.ts | 4 + test/no-validate-leaksan.txt | 9 +- 191 files changed, 13326 insertions(+), 583 deletions(-) create mode 100644 patches/lolhtml/0002-quiet-build-script-linker-warning.patch create mode 100644 test/js/node/test/common/boringssl.js create mode 100644 test/js/node/test/fixtures/list-certs.js create mode 100644 test/js/node/test/fixtures/tls-extra-ca-override.js create mode 100644 test/js/node/test/fixtures/tls-get-ca-certificates-worker.js delete mode 100644 test/js/node/test/parallel/test-http-server-reject-chunked-with-content-length.js create mode 100644 test/js/node/test/parallel/test-net-allow-half-open.js create mode 100644 test/js/node/test/parallel/test-net-autoselectfamily-attempt-timeout-cli-option.js create mode 100644 test/js/node/test/parallel/test-net-autoselectfamily-commandline-option.js create mode 100644 test/js/node/test/parallel/test-net-autoselectfamily.js create mode 100644 test/js/node/test/parallel/test-net-binary.js create mode 100644 test/js/node/test/parallel/test-net-bytes-read.js create mode 100644 test/js/node/test/parallel/test-net-bytes-stats.js create mode 100644 test/js/node/test/parallel/test-net-client-bind-twice.js create mode 100644 test/js/node/test/parallel/test-net-connect-memleak.js create mode 100644 test/js/node/test/parallel/test-net-connect-options-allowhalfopen.js create mode 100644 test/js/node/test/parallel/test-net-connect-paused-connection.js create mode 100644 test/js/node/test/parallel/test-net-connect-reset-after-destroy.js create mode 100644 test/js/node/test/parallel/test-net-connect-reset-until-connected.js create mode 100644 test/js/node/test/parallel/test-net-end-destroyed.js create mode 100644 test/js/node/test/parallel/test-net-error-twice.js create mode 100644 test/js/node/test/parallel/test-net-large-string.js create mode 100644 test/js/node/test/parallel/test-net-pause-resume-connecting.js create mode 100644 test/js/node/test/parallel/test-net-perf_hooks.js create mode 100644 test/js/node/test/parallel/test-net-pingpong.js create mode 100644 test/js/node/test/parallel/test-net-pipe-connect-errors.js create mode 100644 test/js/node/test/parallel/test-net-pipe-with-long-path.js create mode 100644 test/js/node/test/parallel/test-net-server-keepalive.js create mode 100644 test/js/node/test/parallel/test-net-server-listen-options.js create mode 100644 test/js/node/test/parallel/test-net-server-listen-path.js create mode 100644 test/js/node/test/parallel/test-net-server-nodelay.js create mode 100644 test/js/node/test/parallel/test-net-server-reset.js create mode 100644 test/js/node/test/parallel/test-net-socket-reset-send.js create mode 100644 test/js/node/test/parallel/test-net-socket-setnodelay.js create mode 100644 test/js/node/test/parallel/test-net-socket-tos.js create mode 100644 test/js/node/test/parallel/test-net-socket-write-after-close.js create mode 100644 test/js/node/test/parallel/test-net-stream.js create mode 100644 test/js/node/test/parallel/test-net-write-after-close.js create mode 100644 test/js/node/test/parallel/test-net-write-after-end-nt.js create mode 100644 test/js/node/test/parallel/test-tls-basic-validations.js create mode 100644 test/js/node/test/parallel/test-tls-buffersize.js create mode 100644 test/js/node/test/parallel/test-tls-cert-chains-concat.js create mode 100644 test/js/node/test/parallel/test-tls-cli-max-version-1.2.js create mode 100644 test/js/node/test/parallel/test-tls-cli-max-version-1.3.js create mode 100644 test/js/node/test/parallel/test-tls-cli-min-version-1.0.js create mode 100644 test/js/node/test/parallel/test-tls-cli-min-version-1.1.js create mode 100644 test/js/node/test/parallel/test-tls-cli-min-version-1.2.js create mode 100644 test/js/node/test/parallel/test-tls-cli-min-version-1.3.js create mode 100644 test/js/node/test/parallel/test-tls-client-getephemeralkeyinfo.js create mode 100644 test/js/node/test/parallel/test-tls-client-reject-12.js create mode 100644 test/js/node/test/parallel/test-tls-client-reject.js create mode 100644 test/js/node/test/parallel/test-tls-client-renegotiation-13.js create mode 100644 test/js/node/test/parallel/test-tls-client-resume-12.js create mode 100644 test/js/node/test/parallel/test-tls-client-resume.js create mode 100644 test/js/node/test/parallel/test-tls-clientcertengine-invalid-arg-type.js create mode 100644 test/js/node/test/parallel/test-tls-cnnic-whitelist.js create mode 100644 test/js/node/test/parallel/test-tls-connect-given-socket.js create mode 100644 test/js/node/test/parallel/test-tls-connect-memleak.js create mode 100644 test/js/node/test/parallel/test-tls-connect-timeout-option.js create mode 100644 test/js/node/test/parallel/test-tls-dhparam-auto-boringssl.js create mode 100644 test/js/node/test/parallel/test-tls-disable-renegotiation.js create mode 100644 test/js/node/test/parallel/test-tls-empty-sni-context.js create mode 100644 test/js/node/test/parallel/test-tls-enable-keylog-cli.js create mode 100644 test/js/node/test/parallel/test-tls-env-bad-extra-ca.js create mode 100644 test/js/node/test/parallel/test-tls-env-extra-ca-with-options.js create mode 100644 test/js/node/test/parallel/test-tls-env-extra-ca.js create mode 100644 test/js/node/test/parallel/test-tls-error-servername.js create mode 100644 test/js/node/test/parallel/test-tls-error-stack.js create mode 100644 test/js/node/test/parallel/test-tls-exportkeyingmaterial.js create mode 100644 test/js/node/test/parallel/test-tls-finished.js create mode 100644 test/js/node/test/parallel/test-tls-get-ca-certificates-system-without-flag.js create mode 100644 test/js/node/test/parallel/test-tls-getcertificate-x509.js create mode 100644 test/js/node/test/parallel/test-tls-getprotocol.js create mode 100644 test/js/node/test/parallel/test-tls-invalid-pfx.js create mode 100644 test/js/node/test/parallel/test-tls-ip-servername-forbidden.js create mode 100644 test/js/node/test/parallel/test-tls-js-stream.js create mode 100644 test/js/node/test/parallel/test-tls-key-mismatch.js create mode 100644 test/js/node/test/parallel/test-tls-keylog-tlsv13.js create mode 100644 test/js/node/test/parallel/test-tls-min-max-version.js create mode 100644 test/js/node/test/parallel/test-tls-multi-key.js create mode 100644 test/js/node/test/parallel/test-tls-net-socket-keepalive-12.js create mode 100644 test/js/node/test/parallel/test-tls-net-socket-keepalive.js create mode 100644 test/js/node/test/parallel/test-tls-no-cert-required.js create mode 100644 test/js/node/test/parallel/test-tls-no-sslv23.js create mode 100644 test/js/node/test/parallel/test-tls-off-thread-cert-loading-disabled.js create mode 100644 test/js/node/test/parallel/test-tls-psk-alpn-callback-exception-handling.js create mode 100644 test/js/node/test/parallel/test-tls-psk-circuit.js create mode 100644 test/js/node/test/parallel/test-tls-reduced-SECLEVEL-in-cipher.js create mode 100644 test/js/node/test/parallel/test-tls-secure-session.js create mode 100644 test/js/node/test/parallel/test-tls-server-capture-rejection.js create mode 100644 test/js/node/test/parallel/test-tls-server-failed-handshake-emits-clienterror.js create mode 100644 test/js/node/test/parallel/test-tls-session-timeout-errors.js create mode 100644 test/js/node/test/parallel/test-tls-set-default-ca-certificates-array-buffer.js create mode 100644 test/js/node/test/parallel/test-tls-set-default-ca-certificates-basic.js create mode 100644 test/js/node/test/parallel/test-tls-set-default-ca-certificates-error.js create mode 100644 test/js/node/test/parallel/test-tls-set-default-ca-certificates-extra-override.js create mode 100644 test/js/node/test/parallel/test-tls-set-default-ca-certificates-mixed-types.js create mode 100644 test/js/node/test/parallel/test-tls-set-default-ca-certificates-precedence-bundled.js create mode 100644 test/js/node/test/parallel/test-tls-set-default-ca-certificates-precedence-empty.js create mode 100644 test/js/node/test/parallel/test-tls-sni-option.js create mode 100644 test/js/node/test/parallel/test-tls-snicallback-error.js create mode 100644 test/js/node/test/parallel/test-tls-ticket-cluster.js create mode 100644 test/js/node/test/parallel/test-tls-ticket-invalid-arg.js create mode 100644 test/js/node/test/parallel/test-tls-ticket.js create mode 100644 test/js/node/test/parallel/test-tls-timeout-server.js create mode 100644 test/js/node/test/parallel/test-tls-wrap-econnreset-pipe.js create mode 100644 test/js/node/test/parallel/test-tls-wrap-event-emmiter.js create mode 100644 test/js/node/test/sequential/test-net-listen-shared-ports.js create mode 100644 test/js/node/test/sequential/test-net-localport.js diff --git a/packages/bun-usockets/src/bsd.c b/packages/bun-usockets/src/bsd.c index 69ef75395885..74f1875c9a18 100644 --- a/packages/bun-usockets/src/bsd.c +++ b/packages/bun-usockets/src/bsd.c @@ -611,6 +611,66 @@ int bsd_socket_keepalive(LIBUS_SOCKET_DESCRIPTOR fd, int on, unsigned int delay) #endif } +/* IP type-of-service / traffic-class. The option level depends on the socket + * family (IP_TOS for IPv4, IPV6_TCLASS for IPv6), detected via getsockname. + * Returns 0 on success or a negative platform errno on failure (the negative + * convention matches what node:net's ErrnoException expects). */ +static int bsd_socket_tos_level(LIBUS_SOCKET_DESCRIPTOR fd, int *level, int *option) { + struct sockaddr_storage storage; + socklen_t addrlen = sizeof(storage); + if (getsockname(fd, (struct sockaddr *) &storage, &addrlen)) { +#ifdef _WIN32 + return -WSAGetLastError(); +#else + return -errno; +#endif + } + if (storage.ss_family == AF_INET) { + *level = IPPROTO_IP; + *option = IP_TOS; + } else if (storage.ss_family == AF_INET6) { + *level = IPPROTO_IPV6; + *option = IPV6_TCLASS; + } else { + return -EINVAL; + } + return 0; +} + +int bsd_socket_set_tos(LIBUS_SOCKET_DESCRIPTOR fd, int tos) { + int level, option; + int err = bsd_socket_tos_level(fd, &level, &option); + if (err) return err; +#ifdef _WIN32 + if (setsockopt(fd, level, option, (const char *) &tos, sizeof(tos))) { + return -WSAGetLastError(); + } +#else + if (setsockopt(fd, level, option, &tos, sizeof(tos))) { + return -errno; + } +#endif + return 0; +} + +int bsd_socket_get_tos(LIBUS_SOCKET_DESCRIPTOR fd) { + int level, option; + int err = bsd_socket_tos_level(fd, &level, &option); + if (err) return err; + int tos = 0; + socklen_t len = sizeof(tos); +#ifdef _WIN32 + if (getsockopt(fd, level, option, (char *) &tos, (int *) &len)) { + return -WSAGetLastError(); + } +#else + if (getsockopt(fd, level, option, &tos, &len)) { + return -errno; + } +#endif + return tos; +} + void bsd_socket_flush(LIBUS_SOCKET_DESCRIPTOR fd) { // Linux TCP_CORK has the same underlying corking mechanism as with MSG_MORE #ifdef TCP_CORK @@ -1261,6 +1321,10 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket_unix(const char *path, size_t l struct sockaddr_un server_address; size_t addrlen = 0; if (bsd_create_unix_socket_address(path, len, &dirfd_workaround_for_unix_path_len, &server_address, &addrlen)) { + /* The path could not be expressed as a sockaddr_un (the basename + * exceeds sun_path even with the dirfd workaround); surface the errno + * so the caller can report something better than a codeless failure. */ + if (error && errno) *error = errno; return LIBUS_SOCKET_ERROR; } @@ -1599,12 +1663,31 @@ static int is_loopback(struct sockaddr_storage *sockaddr) { } #endif -LIBUS_SOCKET_DESCRIPTOR bsd_create_connect_socket(struct sockaddr_storage *addr, int options) { +LIBUS_SOCKET_DESCRIPTOR bsd_create_connect_socket(struct sockaddr_storage *addr, struct sockaddr_storage *local_addr, int options) { LIBUS_SOCKET_DESCRIPTOR fd = bsd_create_socket(addr->ss_family, SOCK_STREAM, 0, NULL); if (fd == LIBUS_SOCKET_ERROR) { return LIBUS_SOCKET_ERROR; } + /* Bind to the requested local address/port before connecting (the + * `localAddress`/`localPort` connect options). A failure here - typically + * EADDRINUSE or EADDRNOTAVAIL - fails the connect with that errno. */ + if (local_addr) { + socklen_t local_len = local_addr->ss_family == AF_INET ? sizeof(struct sockaddr_in) : sizeof(struct sockaddr_in6); + if (bind(fd, (struct sockaddr *) local_addr, local_len)) { +#ifdef _WIN32 + int bind_err = WSAGetLastError(); + bsd_close_socket(fd); + WSASetLastError(bind_err); +#else + int bind_err = errno; + bsd_close_socket(fd); + errno = bind_err; +#endif + return LIBUS_SOCKET_ERROR; + } + } + #ifdef _WIN32 win32_set_nonblocking(fd); diff --git a/packages/bun-usockets/src/context.c b/packages/bun-usockets/src/context.c index ba72180749cf..de1fa728ce9b 100644 --- a/packages/bun-usockets/src/context.c +++ b/packages/bun-usockets/src/context.c @@ -475,8 +475,8 @@ static inline void us_internal_init_connect_socket(struct us_socket_t *s, struct us_socket_t *us_socket_group_connect_resolved_dns(struct us_socket_group_t *group, unsigned char kind, struct ssl_ctx_st *ssl_ctx, - struct sockaddr_storage *addr, int options, int socket_ext_size) { - LIBUS_SOCKET_DESCRIPTOR connect_socket_fd = bsd_create_connect_socket(addr, options); + struct sockaddr_storage *addr, struct sockaddr_storage *local_addr, int options, int socket_ext_size) { + LIBUS_SOCKET_DESCRIPTOR connect_socket_fd = bsd_create_connect_socket(addr, local_addr, options); if (connect_socket_fd == LIBUS_SOCKET_ERROR) { return NULL; } @@ -539,14 +539,22 @@ static bool try_parse_ip(const char *ip_str, int port, struct sockaddr_storage * } void *us_socket_group_connect(struct us_socket_group_t *group, unsigned char kind, - struct ssl_ctx_st *ssl_ctx, const char *host, int port, int options, + struct ssl_ctx_st *ssl_ctx, const char *host, int port, + const char *local_host, int local_port, int options, int socket_ext_size, int *has_dns_resolved) { struct us_loop_t *loop = group->loop; + /* The local address is always a literal IP (Node validates it as one). */ + struct sockaddr_storage local_addr_storage; + struct sockaddr_storage *local_addr = NULL; + if (local_host && try_parse_ip(local_host, local_port, &local_addr_storage)) { + local_addr = &local_addr_storage; + } + struct sockaddr_storage addr; if (try_parse_ip(host, port, &addr)) { *has_dns_resolved = 1; - return us_socket_group_connect_resolved_dns(group, kind, ssl_ctx, &addr, options, socket_ext_size); + return us_socket_group_connect_resolved_dns(group, kind, ssl_ctx, &addr, local_addr, options, socket_ext_size); } struct addrinfo_request *ai_req; @@ -563,7 +571,7 @@ void *us_socket_group_connect(struct us_socket_group_t *group, unsigned char kin struct sockaddr_storage a; init_addr_with_port(&entries->info, port, &a); *has_dns_resolved = 1; - struct us_socket_t *s = us_socket_group_connect_resolved_dns(group, kind, ssl_ctx, &a, options, socket_ext_size); + struct us_socket_t *s = us_socket_group_connect_resolved_dns(group, kind, ssl_ctx, &a, local_addr, options, socket_ext_size); Bun__addrinfo_freeRequest(ai_req, s == NULL); return s; } @@ -628,7 +636,8 @@ int start_connections(struct us_connecting_socket_t *c, int count) { for (; c->addrinfo_head != NULL && opened < count; c->addrinfo_head = c->addrinfo_head->ai_next) { struct sockaddr_storage addr; init_addr_with_port(c->addrinfo_head, c->port, &addr); - LIBUS_SOCKET_DESCRIPTOR connect_socket_fd = bsd_create_connect_socket(&addr, c->options); + /* The deferred-DNS path does not carry a local binding. */ + LIBUS_SOCKET_DESCRIPTOR connect_socket_fd = bsd_create_connect_socket(&addr, NULL, c->options); if (connect_socket_fd == LIBUS_SOCKET_ERROR) { continue; } diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index d2d7c8d613e7..51daa4e33e63 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -20,6 +20,7 @@ #include "internal/internal.h" #include "libusockets.h" #include +#include #include #include @@ -35,6 +36,7 @@ void *sni_find(void *sni, const char *hostname); #include #include #include +#include #elif LIBUS_USE_WOLFSSL #include #include @@ -60,6 +62,11 @@ void *sni_find(void *sni, const char *hostname); * plaintext. Same shape for open/writable/close/end. * ────────────────────────────────────────────────────────────────────────── */ +/* Capacity of the parked fatal-error reason (ERR_error_string_n output). + * OpenSSL formats "error:...:reason" strings well under this; anything + * longer is truncated by ERR_error_string_n itself (always NUL-terminated). */ +#define US_SSL_FATAL_ERROR_REASON_MAX 256 + struct loop_ssl_data { char *ssl_read_input, *ssl_read_output; unsigned int ssl_read_input_length; @@ -69,6 +76,19 @@ struct loop_ssl_data { BIO *shared_rbio; BIO *shared_wbio; BIO_METHOD *shared_biom; + /* The OpenSSL error string of the fatal SSL error that is about to close + * the current socket (set in the SSL_ERROR_SSL branch immediately before + * ssl_close, consumed by the handshake-failure dispatch inside that + * ssl_close, cleared after use). Lets 'wrong version number' and friends + * reach the JS 'tlsClientError' / client error the way Node reports them. + * A longer reason is truncated: every writer goes through + * ERR_error_string_n, which NUL-terminates and truncates to the buffer + * size (OpenSSL's own error strings stay well under it). */ + char ssl_last_fatal_error[US_SSL_FATAL_ERROR_REASON_MAX]; + /* The socket that parked ssl_last_fatal_error. The scratch is per-loop, so + * a reason parked by one socket must never be reported for another (a + * server and a client in the same process share this loop). */ + void *ssl_last_fatal_error_owner; }; enum { @@ -117,8 +137,25 @@ long us_ssl_ctx_live_count(void) { static int us_ctx_ex_idx = -1; static int us_sni_ex_idx = -1; static int us_ctx_cache_ex_idx = -1; +/* Marks an SSL_CTX whose verification store holds user-provided CAs (the + * ca/caFile options or a later addCACert): the per-socket client attach must + * not replace such a store with the process-shared default roots. */ +static int us_ctx_user_ca_ex_idx = -1; static int us_ssl_reneg_state_idx = -1; +/* Per-connection async-SNI suspension state (select_certificate_cb retry). */ +static int us_ssl_sni_pending_idx = -1; static int us_ssl_listener_ex_idx = -1; +/* Set (to a non-NULL marker) only on SSLs attached to a real us_socket_t via + * us_internal_ssl_attach. The new-session callback uses it to ignore SSLs + * owned by other engines (the JS-stream SSL wrapper used for TLS-over-duplex) + * whose BIOs do not point at the loop's shared BIO data. */ +static int us_ssl_is_socket_ex_idx = -1; +/* Defined in Rust (src/uws_sys/SocketKind.rs) so the ordinal tracks the enum. */ +extern const unsigned char BUN_SOCKET_KIND_BUN_SOCKET_TLS; +/* Serialized resumable session parked by the new-session callback until the + * SSL stack unwinds; freed with the SSL if never delivered. */ +static int us_ssl_pending_session_idx = -1; +static int us_ssl_pending_keylog_idx = -1; #ifdef _WIN32 static INIT_ONCE us_ex_idx_once = INIT_ONCE_STATIC_INIT; #else @@ -130,6 +167,25 @@ static pthread_once_t us_ex_idx_once = PTHREAD_ONCE_INIT; #define US_RENEG_LIMIT(p) ((uint32_t)((uint64_t)(uintptr_t)(p) >> 32)) #define US_RENEG_WINDOW(p) ((uint32_t)((uint64_t)(uintptr_t)(p))) +/* Async SNICallback suspension state, hung off the SSL via ex_data. + * Allocated the first time a dynamic resolver answers "pending"; freed with + * the SSL. The resolved ctx carries one reference owned by this struct until + * select_cert_cb consumes it (SSL_set_SSL_CTX takes its own). */ +struct us_ssl_sni_pending_t { + /* 0 = none, 1 = waiting for the JS resolution, 2 = resolved, 3 = error */ + int state; + struct ssl_ctx_st *resolved_ctx; +}; + +static void us_ssl_sni_pending_free(void *parent, void *ptr, CRYPTO_EX_DATA *ad, + int index, long argl, void *argp) { + (void)parent; (void)ad; (void)index; (void)argl; (void)argp; + struct us_ssl_sni_pending_t *st = ptr; + if (!st) return; + if (st->resolved_ctx) SSL_CTX_free(st->resolved_ctx); + us_free(st); +} + struct us_ssl_reneg_state_t { uint64_t window_start_ms; uint32_t count; @@ -146,6 +202,142 @@ static void us_ssl_reneg_state_free(void *parent, void *ptr, CRYPTO_EX_DATA *ad, us_free(ptr); } +/* A new resumable session is ready (for TLS 1.3, the peer's NewSessionTicket + * was just processed; SSL_get_session() right after the handshake only returns + * an unresumable placeholder). This callback fires from inside + * SSL_read/SSL_do_handshake, where running JS could free the SSL out from + * under the caller - so it only serializes the session and parks it on the + * connection. ssl_flush_pending_session() hands it to the socket's session + * callback once the SSL stack has unwound. */ +/* Upper bounds for parked payloads: a serialized SSL_SESSION (i2d) and a + * single keylog line. Anything larger is dropped at the parking site. */ +#define US_SSL_PENDING_SESSION_MAX 65536 +#define US_SSL_PENDING_KEYLOG_LINE_MAX 4096 + +struct us_ssl_pending_session_t { + struct us_ssl_pending_session_t *next; + uint32_t length; + unsigned char data[]; +}; +static void us_ssl_pending_session_free(void *parent, void *ptr, CRYPTO_EX_DATA *ad, + int index, long argl, void *argp) { + (void)parent; (void)ad; (void)index; (void)argl; (void)argp; + struct us_ssl_pending_session_t *pending = ptr; + while (pending) { + struct us_ssl_pending_session_t *next = pending->next; + free(pending); + pending = next; + } +} +/* NSS key-log lines are produced from inside SSL_do_handshake/SSL_read, so + * they are parked on the SSL the same way new sessions are and delivered once + * the read unwinds. The stored bytes already carry the trailing newline Node + * appends before emitting 'keylog'. */ +static void us_ssl_keylog_cb(const SSL *cssl, const char *line) { + SSL *ssl = (SSL *)cssl; + if (!SSL_get_ex_data(ssl, us_ssl_is_socket_ex_idx)) { + return; + } + size_t line_len = strlen(line); + if (line_len == 0 || line_len > US_SSL_PENDING_KEYLOG_LINE_MAX) { + return; + } + struct us_ssl_pending_session_t *pending = + malloc(sizeof(struct us_ssl_pending_session_t) + line_len + 1); + if (!pending) { + return; + } + memcpy(pending->data, line, line_len); + pending->data[line_len] = '\n'; + pending->length = (uint32_t)(line_len + 1); + pending->next = NULL; + struct us_ssl_pending_session_t *head = SSL_get_ex_data(ssl, us_ssl_pending_keylog_idx); + if (!head) { + SSL_set_ex_data(ssl, us_ssl_pending_keylog_idx, pending); + } else { + while (head->next) head = head->next; + head->next = pending; + } +} + +static void ssl_flush_pending_keylog(struct us_socket_t *s) { + if (!s->ssl || us_socket_is_closed(s)) { + return; + } + struct us_ssl_pending_session_t *pending = + SSL_get_ex_data(s->ssl, us_ssl_pending_keylog_idx); + if (!pending) { + return; + } + SSL_set_ex_data(s->ssl, us_ssl_pending_keylog_idx, NULL); + while (pending) { + struct us_ssl_pending_session_t *next = pending->next; + if (!us_socket_is_closed(s) && s->ssl) { + us_dispatch_keylog(s, pending->data, (int)pending->length); + } + free(pending); + pending = next; + } +} + +static int us_ssl_new_session_cb(SSL *ssl, SSL_SESSION *session) { + /* Park only for consumers that will drain the queue: SSLs attached to a + * real us_socket_t (flushed into us_dispatch_session once the read unwinds) + * and SSLs whose owner opted in via us_ssl_enable_pending_events (the + * Rust SSLWrapper behind TLS-over-duplex / named pipes, which polls + * us_ssl_pop_pending_session after its reads). Everything else (fetch, + * WebSocket tunnels) has no consumer - don't queue. */ + if (!SSL_get_ex_data(ssl, us_ssl_is_socket_ex_idx)) { + return 0; + } + int length = i2d_SSL_SESSION(session, NULL); + if (length <= 0 || length > US_SSL_PENDING_SESSION_MAX) { + return 0; + } + struct us_ssl_pending_session_t *pending = + malloc(sizeof(struct us_ssl_pending_session_t) + (size_t)length); + if (!pending) { + return 0; + } + unsigned char *out = pending->data; + pending->length = (uint32_t)i2d_SSL_SESSION(session, &out); + pending->next = NULL; + /* Append: each NewSessionTicket is a distinct resumable session and gets + * its own 'session' event, in arrival order. */ + struct us_ssl_pending_session_t *head = SSL_get_ex_data(ssl, us_ssl_pending_session_idx); + if (!head) { + SSL_set_ex_data(ssl, us_ssl_pending_session_idx, pending); + } else { + while (head->next) head = head->next; + head->next = pending; + } + /* 0: we serialized a copy; the caller keeps ownership of `session`. */ + return 0; +} + +/* Deliver a session parked by the new-session callback. Must only be called + * once the SSL_read/SSL_do_handshake that parked it has returned; the JS it + * runs may close the socket, so callers must check ssl_gone(s) afterwards. */ +static void ssl_flush_pending_session(struct us_socket_t *s) { + if (!s->ssl || us_socket_is_closed(s)) { + return; + } + struct us_ssl_pending_session_t *pending = + SSL_get_ex_data(s->ssl, us_ssl_pending_session_idx); + if (!pending) { + return; + } + SSL_set_ex_data(s->ssl, us_ssl_pending_session_idx, NULL); + while (pending) { + struct us_ssl_pending_session_t *next = pending->next; + if (!us_socket_is_closed(s) && s->ssl) { + us_dispatch_session(s, pending->data, (int)pending->length); + } + free(pending); + pending = next; + } +} + /* Defined in Zig (`SSLContextCache.zig`): tombstones the cache entry on * SSL_CTX refcount→0 so the per-VM weak SSL_CTX cache learns the pointer is * dead without holding a ref of its own. */ @@ -156,8 +348,13 @@ static void us_ex_idx_init(void) { us_ctx_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, us_ctx_ex_free); us_sni_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, NULL); us_ctx_cache_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, bun_ssl_ctx_cache_on_free); + us_ctx_user_ca_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, NULL); us_ssl_reneg_state_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, us_ssl_reneg_state_free); + us_ssl_sni_pending_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, us_ssl_sni_pending_free); us_ssl_listener_ex_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, NULL); + us_ssl_is_socket_ex_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, NULL); + us_ssl_pending_session_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, us_ssl_pending_session_free); + us_ssl_pending_keylog_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, us_ssl_pending_session_free); } #ifdef _WIN32 @@ -181,6 +378,45 @@ static inline int us_ssl_ctx_ex_idx(void) { return us_ctx_ex_idx; } +/* TLS-over-duplex / named-pipe owners (the Rust SSLWrapper): opt this SSL + * into the parked session/keylog queues so us_ssl_new_session_cb / + * us_ssl_keylog_cb collect them. There is no us_socket_t to flush into + * us_dispatch_*, so the wrapper drains the queues with + * us_ssl_pop_pending_* once its SSL_read/SSL_do_handshake stack unwinds. */ +void us_ssl_enable_pending_events(SSL *ssl) { + us_ex_idx_ensure(); + SSL_set_ex_data(ssl, us_ssl_is_socket_ex_idx, (void *)1); +} + +static int us_ssl_pop_pending(SSL *ssl, int idx, unsigned char *out, int out_cap) { + if (idx < 0) return 0; + struct us_ssl_pending_session_t *pending = SSL_get_ex_data(ssl, idx); + if (!pending) return 0; + SSL_set_ex_data(ssl, idx, pending->next); + int len = (int)pending->length; + if (len > out_cap) { + /* The parking sites cap entries (64 KB sessions, 4 KB+1 keylog lines) and + * callers pass buffers at least that large, so this is unreachable; drop + * the entry rather than overflow. */ + len = 0; + } else { + memcpy(out, pending->data, (size_t)len); + } + free(pending); + return len; +} + +/* Pop the oldest parked session/keylog entry into `out` (cap `out_cap`). + * Returns the byte length, or 0 when the queue is empty. Entries arrive in + * parking order; each pop hands over exactly one entry. */ +int us_ssl_pop_pending_session(SSL *ssl, unsigned char *out, int out_cap) { + return us_ssl_pop_pending(ssl, us_ssl_pending_session_idx, out, out_cap); +} + +int us_ssl_pop_pending_keylog(SSL *ssl, unsigned char *out, int out_cap) { + return us_ssl_pop_pending(ssl, us_ssl_pending_keylog_idx, out, out_cap); +} + int us_ssl_ctx_cache_ex_idx(void) { us_ex_idx_ensure(); return us_ctx_cache_ex_idx; @@ -235,9 +471,44 @@ static long BIO_s_custom_ctrl(BIO *bio, int cmd, long num, void *user) { } } +/* Save/restore the per-loop BIO routing state around a JS callback that runs + * from inside SSL_do_handshake/SSL_read: user JS that writes to or destroys a + * different TLS socket on the same loop re-points loop_ssl_data->ssl_socket + * (and may consume the read-input window), and the interrupted handshake's + * next BIO_write would otherwise land on that other socket's fd. */ +void us_internal_ssl_loop_state_save(void *ssl_ptr, void **out) { + SSL *ssl = (SSL *)ssl_ptr; + struct loop_ssl_data *d = (struct loop_ssl_data *)BIO_get_data(SSL_get_wbio(ssl)); + out[0] = d; + out[1] = d ? (void *)d->ssl_socket : NULL; + out[2] = d ? (void *)d->ssl_read_input : NULL; + out[3] = d ? (void *)(uintptr_t)d->ssl_read_input_length : NULL; + out[4] = d ? (void *)(uintptr_t)d->ssl_read_input_offset : NULL; +} + +void us_internal_ssl_loop_state_restore(void **saved) { + struct loop_ssl_data *d = (struct loop_ssl_data *)saved[0]; + if (!d) return; + d->ssl_socket = (struct us_socket_t *)saved[1]; + d->ssl_read_input = (char *)saved[2]; + d->ssl_read_input_length = (unsigned int)(uintptr_t)saved[3]; + d->ssl_read_input_offset = (unsigned int)(uintptr_t)saved[4]; +} + static int BIO_s_custom_write(BIO *bio, const char *data, int length) { struct loop_ssl_data *loop_ssl_data = (struct loop_ssl_data *)BIO_get_data(bio); + /* A callback run from inside SSL_do_handshake/SSL_read marked this socket + * for deferred destruction (an SNI abort, or JS destroying the socket): the + * connection is being dropped without a TLS-level goodbye, so swallow + * whatever BoringSSL tries to flush (typically the fatal alert). The bytes + * are reported as written so the SSL state machine completes its error + * path instead of retrying. */ + if (loop_ssl_data->ssl_socket && loop_ssl_data->ssl_socket->ssl_pending_detach) { + BIO_clear_retry_flags(bio); + return length; + } + int written = us_socket_raw_write(loop_ssl_data->ssl_socket, data, length); BIO_clear_retry_flags(bio); @@ -372,7 +643,24 @@ static int add_ca_cert_to_ctx_store(SSL_CTX *ctx, const char *content, X509_STOR } end: BIO_free(in); - return count > 0; + if (count == 0) { + /* The PEM loop terminates with PEM_R_NO_START_LINE once there are no + * (more) CERTIFICATE blocks. A PEM document that contains no + * certificates at all - Node's test suite passes a private key here - is + * ignored the way Node ignores it rather than failing the whole context. + * Content that is not PEM at all, or a malformed certificate block, is + * still an error. */ + unsigned long pem_err = ERR_peek_last_error(); + if ((pem_err == 0 || (ERR_GET_LIB(pem_err) == ERR_LIB_PEM && + ERR_GET_REASON(pem_err) == PEM_R_NO_START_LINE)) && + strstr(content, "-----BEGIN ") != NULL) { + ERR_clear_error(); + return 1; + } + return 0; + } + ERR_clear_error(); + return 1; } static int us_ssl_ctx_use_certificate_chain(SSL_CTX *ctx, const char *content) { @@ -469,7 +757,12 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, /* Default options we rely on — changing these breaks the BIO logic. */ SSL_CTX_set_read_ahead(ssl_context, 1); SSL_CTX_set_mode(ssl_context, SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER); - SSL_CTX_set_min_proto_version(ssl_context, TLS1_2_VERSION); + /* Honor explicit minVersion/maxVersion (Node's secureProtocol/min/maxVersion); + * default to a TLS1.2 floor when no minimum is requested. */ + SSL_CTX_set_min_proto_version(ssl_context, options.ssl_min_version ? options.ssl_min_version : TLS1_2_VERSION); + if (options.ssl_max_version) { + SSL_CTX_set_max_proto_version(ssl_context, options.ssl_max_version); + } if (options.ssl_prefer_low_memory_usage) { SSL_CTX_set_mode(ssl_context, SSL_MODE_RELEASE_BUFFERS); @@ -484,31 +777,52 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, SSL_CTX_set_default_passwd_cb(ssl_context, passphrase_cb); } - if (options.cert_file_name) { - if (SSL_CTX_use_certificate_chain_file(ssl_context, options.cert_file_name) != 1) { - ssl_ctx_build_fail(ssl_context); - return NULL; - } - } else if (options.cert && options.cert_count > 0) { + /* Multiple identities (e.g. an RSA and an EC pair, the way Node accepts + * arrays of key/cert or several pfx entries) must be loaded pair-wise: + * loading every certificate first and then every key makes BoringSSL check + * each key against the last certificate loaded and fail with + * KEY_TYPE_MISMATCH on a mixed configuration. With pair-wise loading the + * later identity replaces the earlier one in the legacy slot, which is the + * documented BoringSSL behaviour the adapted tests expect. */ + int interleave_identities = !options.cert_file_name && !options.key_file_name && + options.cert && options.key && + options.cert_count == options.key_count && + options.cert_count > 1; + if (interleave_identities) { for (unsigned int i = 0; i < options.cert_count; i++) { - if (us_ssl_ctx_use_certificate_chain(ssl_context, options.cert[i]) != 1) { + if (us_ssl_ctx_use_certificate_chain(ssl_context, options.cert[i]) != 1 || + us_ssl_ctx_use_privatekey_content(ssl_context, options.key[i], SSL_FILETYPE_PEM) != 1) { ssl_ctx_build_fail(ssl_context); return NULL; } } - } - - if (options.key_file_name) { - if (SSL_CTX_use_PrivateKey_file(ssl_context, options.key_file_name, SSL_FILETYPE_PEM) != 1) { - ssl_ctx_build_fail(ssl_context); - return NULL; + } else { + if (options.cert_file_name) { + if (SSL_CTX_use_certificate_chain_file(ssl_context, options.cert_file_name) != 1) { + ssl_ctx_build_fail(ssl_context); + return NULL; + } + } else if (options.cert && options.cert_count > 0) { + for (unsigned int i = 0; i < options.cert_count; i++) { + if (us_ssl_ctx_use_certificate_chain(ssl_context, options.cert[i]) != 1) { + ssl_ctx_build_fail(ssl_context); + return NULL; + } + } } - } else if (options.key && options.key_count > 0) { - for (unsigned int i = 0; i < options.key_count; i++) { - if (us_ssl_ctx_use_privatekey_content(ssl_context, options.key[i], SSL_FILETYPE_PEM) != 1) { + + if (options.key_file_name) { + if (SSL_CTX_use_PrivateKey_file(ssl_context, options.key_file_name, SSL_FILETYPE_PEM) != 1) { ssl_ctx_build_fail(ssl_context); return NULL; } + } else if (options.key && options.key_count > 0) { + for (unsigned int i = 0; i < options.key_count; i++) { + if (us_ssl_ctx_use_privatekey_content(ssl_context, options.key[i], SSL_FILETYPE_PEM) != 1) { + ssl_ctx_build_fail(ssl_context); + return NULL; + } + } } } /* passwd_cb is only consulted by SSL_CTX_use_PrivateKey* above; the secret @@ -528,6 +842,8 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, return NULL; } SSL_CTX_set_client_CA_list(ssl_context, ca_list); + us_ex_idx_ensure(); + SSL_CTX_set_ex_data(ssl_context, us_ctx_user_ca_ex_idx, (void *)1); if (SSL_CTX_load_verify_locations(ssl_context, options.ca_file_name, NULL) != 1) { *err = CREATE_BUN_SOCKET_ERROR_INVALID_CA_FILE; ssl_ctx_build_fail(ssl_context); @@ -539,6 +855,8 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, us_verify_callback); } else if (options.ca && options.ca_count > 0) { + us_ex_idx_ensure(); + SSL_CTX_set_ex_data(ssl_context, us_ctx_user_ca_ex_idx, (void *)1); /* As above: user CAs only, into the SSL_CTX's own initially-empty store — * otherwise a server doing mTLS with `ca: [internalCA]` would also accept * any client certificate that chains to a public root. */ @@ -556,7 +874,10 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, us_verify_callback); } } else if (options.request_cert) { - SSL_CTX_set_cert_store(ssl_context, us_get_default_ca_store()); + /* No per-config CAs are added to this store, so the process-wide shared + * copy (built once) can be used instead of re-parsing the ~150 bundled + * roots for every context - the same approach as Node's root_cert_store. */ + SSL_CTX_set_cert_store(ssl_context, us_get_shared_default_ca_store()); SSL_CTX_set_verify(ssl_context, options.reject_unauthorized ? (SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT) : SSL_VERIFY_PEER, @@ -591,7 +912,9 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, if (options.ssl_ciphers) { if (!SSL_CTX_set_cipher_list(ssl_context, options.ssl_ciphers)) { - unsigned long ssl_err = ERR_get_error(); + /* Peek, don't consume: the caller decomposes the queued reason + * (NO_CIPHER_MATCH, INVALID_COMMAND) into the JS error. */ + unsigned long ssl_err = ERR_peek_error(); if (!(strlen(options.ssl_ciphers) == 0 && ERR_GET_REASON(ssl_err) == SSL_R_NO_CIPHER_MATCH)) { *err = CREATE_BUN_SOCKET_ERROR_INVALID_CIPHERS; ssl_ctx_build_fail(ssl_context); @@ -605,9 +928,163 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, SSL_CTX_set_options(ssl_context, options.secure_options); } + /* Surface resumable sessions through the new-session callback the way Node + * does: for TLS 1.3 the resumable session only exists once the peer's + * NewSessionTicket arrives, and BoringSSL only exposes it here. NO_INTERNAL + * keeps BoringSSL from also caching it. */ + SSL_CTX_set_session_cache_mode(ssl_context, SSL_SESS_CACHE_CLIENT | + SSL_SESS_CACHE_SERVER | + SSL_SESS_CACHE_NO_INTERNAL | + SSL_SESS_CACHE_NO_AUTO_CLEAR); + SSL_CTX_sess_set_new_cb(ssl_context, us_ssl_new_session_cb); + SSL_CTX_set_keylog_callback(ssl_context, us_ssl_keylog_cb); return ssl_context; } +/* node:tls `secureContext.context.addCACert(pem)`: append the certificates in + * `content` to this context's trust store. Returns 0 when the content is not + * a PEM document or contains a malformed certificate. */ +int us_ssl_ctx_add_ca_cert(SSL_CTX *ctx, const char *content) { + if (!ctx || !content) { + return 0; + } + X509_STORE *store = SSL_CTX_get_cert_store(ctx); + /* Clone-on-write: a context that shares the process-wide default root + * store must get its own copy before a CA is appended, or the addition + * would be visible to every other context in the process - the same + * root_cert_store check Node's SecureContext::AddCACert performs. + * us_get_shared_default_ca_store() up-refs before returning, so release + * the reference taken just for this comparison. */ + X509_STORE *shared = us_get_shared_default_ca_store(); + int store_is_shared = store && store == shared; + X509_STORE_free(shared); + /* A default context built without ca/requestCert keeps the empty store from + * SSL_CTX_new() (verification for it normally comes from the per-socket + * shared-root override). addCACert must EXTEND the default trust set the + * way Node does, so when the store is the shared one - or still empty - + * replace it with a fresh full default store (bundled roots, NODE_EXTRA_CA + * certificates, system CAs when enabled) before appending the user's CA. */ + int store_is_empty = 0; + if (store && !store_is_shared) { + const STACK_OF(X509_OBJECT) *objs = X509_STORE_get0_objects(store); + store_is_empty = objs == NULL || sk_X509_OBJECT_num(objs) == 0; + } + if (store_is_shared || store_is_empty) { + X509_STORE *own = us_get_default_ca_store(); + if (!own) { + return 0; + } + SSL_CTX_set_cert_store(ctx, own); + store = own; + } + if (!store) { + return 0; + } + us_ex_idx_ensure(); + SSL_CTX_set_ex_data(ctx, us_ctx_user_ca_ex_idx, (void *)1); + return add_ca_cert_to_ctx_store(ctx, content, store); +} + +/* node:tls `pfx` support: parse a PKCS#12 blob and hand back PEM-encoded + * key / certificate / extra-chain strings the regular key/cert/ca options can + * consume. Returns 1 on success; the three out-strings are malloc'd and the + * caller frees them with free(). On failure returns 0 and sets *err_reason to + * a static tag: "parse" (not PKCS#12), "mac" (bad passphrase / corrupt), + * "key" (no private key), "cert" (no certificate). */ +static int pem_from_bio(BIO *bio, char **out, size_t *out_len) { + char *mem = NULL; + long n = BIO_get_mem_data(bio, &mem); + if (n <= 0 || !mem) return 0; + char *copy = (char *)malloc((size_t)n + 1); + if (!copy) return 0; + memcpy(copy, mem, (size_t)n); + copy[n] = 0; + *out = copy; + *out_len = (size_t)n; + return 1; +} + +int us_ssl_parse_pkcs12(const char *data, size_t len, const char *pass, + char **out_key, size_t *out_key_len, + char **out_cert, size_t *out_cert_len, + char **out_ca, size_t *out_ca_len, + const char **err_reason) { + *out_key = *out_cert = *out_ca = NULL; + *out_key_len = *out_cert_len = *out_ca_len = 0; + *err_reason = NULL; + int ok = 0; + EVP_PKEY *pkey = NULL; + X509 *cert = NULL; + STACK_OF(X509) *extra = NULL; + PKCS12 *p12 = NULL; + BIO *kb = NULL, *cb = NULL, *ab = NULL; + if (len > INT_MAX) { + /* BIO_new_mem_buf takes an int; a negative value would mean + * "treat as a NUL-terminated string", silently misparsing the blob. */ + *err_reason = "parse"; + return 0; + } + BIO *in = BIO_new_mem_buf(data, (int)len); + if (!in) { + *err_reason = "parse"; + return 0; + } + p12 = d2i_PKCS12_bio(in, NULL); + BIO_free(in); + if (!p12) { + *err_reason = "parse"; + ERR_clear_error(); + return 0; + } + if (!PKCS12_parse(p12, pass ? pass : "", &pkey, &cert, &extra)) { + *err_reason = "mac"; + ERR_clear_error(); + goto done; + } + if (!pkey) { + *err_reason = "key"; + goto done; + } + if (!cert) { + *err_reason = "cert"; + goto done; + } + kb = BIO_new(BIO_s_mem()); + cb = BIO_new(BIO_s_mem()); + if (!kb || !cb || !PEM_write_bio_PrivateKey(kb, pkey, NULL, NULL, 0, NULL, NULL) || + !PEM_write_bio_X509(cb, cert) || !pem_from_bio(kb, out_key, out_key_len) || + !pem_from_bio(cb, out_cert, out_cert_len)) { + *err_reason = "parse"; + goto done; + } + if (extra && sk_X509_num(extra) > 0) { + ab = BIO_new(BIO_s_mem()); + if (ab) { + for (size_t i = 0; i < sk_X509_num(extra); i++) { + PEM_write_bio_X509(ab, sk_X509_value(extra, i)); + } + pem_from_bio(ab, out_ca, out_ca_len); + } + } + ok = 1; +done: + if (!ok) { + free(*out_key); + free(*out_cert); + free(*out_ca); + *out_key = *out_cert = *out_ca = NULL; + } + if (kb) BIO_free(kb); + if (cb) BIO_free(cb); + if (ab) BIO_free(ab); + if (pkey) EVP_PKEY_free(pkey); + if (cert) X509_free(cert); + if (extra) sk_X509_pop_free(extra, X509_free); + if (p12) PKCS12_free(p12); + ERR_clear_error(); + return ok; +} + SSL_CTX *us_ssl_ctx_from_options(struct us_bun_socket_context_options_t options, enum create_bun_socket_error_t *err) { SSL_CTX *ctx = us_ssl_ctx_build_raw(options, err); @@ -652,6 +1129,22 @@ void us_internal_ssl_attach(struct us_socket_t *s, SSL_CTX *ctx, struct loop_ssl_data *loop_ssl_data = (struct loop_ssl_data *)s->group->loop->data.ssl_data; SSL *ssl = SSL_new(ctx); + /* Only Bun.connect / node:tls sockets surface the 'session' event; tagging + * just those keeps the new-session callback a no-op for every other TLS + * consumer (fetch, Bun.serve, postgres, websockets) instead of serializing + * a session per handshake that the dispatch then discards. */ + /* The listener's own kind is always 0; the kind it assigns to accepted + * sockets lives in accept_kind and may not have been copied onto `s` yet + * when its SSL is initialized. */ + if (ssl && (us_socket_kind(s) == BUN_SOCKET_KIND_BUN_SOCKET_TLS || + (listener && listener->accept_kind == BUN_SOCKET_KIND_BUN_SOCKET_TLS))) { + /* The very first TLS attach in a process can be a client connection, and + * nothing on that path has registered the ex_data indices yet - using the + * still--1 index would make CRYPTO_set_ex_data grow its slot array toward + * (size_t)-1. */ + us_ex_idx_ensure(); + SSL_set_ex_data(ssl, us_ssl_is_socket_ex_idx, (void *)1); + } SSL_set_bio(ssl, loop_ssl_data->shared_rbio, loop_ssl_data->shared_wbio); BIO_up_ref(loop_ssl_data->shared_rbio); BIO_up_ref(loop_ssl_data->shared_wbio); @@ -673,8 +1166,15 @@ void us_internal_ssl_attach(struct us_socket_t *s, SSL_CTX *ctx, * never aborts here — JS reads verify_error and decides. */ if (SSL_CTX_get_verify_mode(ctx) == SSL_VERIFY_NONE) { SSL_set_verify(ssl, SSL_VERIFY_PEER, us_verify_callback); - X509_STORE *roots = us_get_shared_default_ca_store(); - if (roots) SSL_set0_verify_cert_store(ssl, roots); + us_ex_idx_ensure(); + if (!SSL_CTX_get_ex_data(ctx, us_ctx_user_ca_ex_idx)) { + /* Default context: give this socket the process-shared root bundle. + * A context whose store holds user-provided CAs (ca/caFile options or + * addCACert) keeps using its own store - overriding it here would + * hide those CAs from chain verification. */ + X509_STORE *roots = us_get_shared_default_ca_store(); + if (roots) SSL_set0_verify_cert_store(ssl, roots); + } } } else { SSL_set_accept_state(ssl); @@ -690,11 +1190,22 @@ void us_internal_ssl_attach(struct us_socket_t *s, SSL_CTX *ctx, s->ssl_read_wants_write = 0; s->ssl_fatal_error = 0; s->ssl_raw_tap = 0; + s->ssl_in_use = 0; + s->ssl_pending_detach = 0; + s->ssl_pending_close_code = 0; s->ssl_is_server = is_client ? 0 : 1; } void us_internal_ssl_detach(struct us_socket_t *s) { if (s->ssl) { + if (s->ssl_in_use) { + /* SSL_do_handshake/SSL_read is on the stack (a JS callback run from + * inside it destroyed the socket); freeing now would leave BoringSSL + * working on freed memory when control returns. The driver frees it + * when the call unwinds. */ + s->ssl_pending_detach = 1; + return; + } SSL_free(s_ssl(s)); s->ssl = NULL; } @@ -780,14 +1291,49 @@ struct us_bun_verify_error_t us_internal_ssl_verify_error(struct us_socket_t *s) /* The on_handshake callback runs JS which may us_socket_close(s) — that frees * s->ssl. Every caller MUST check ssl_gone(s) immediately after this returns * and bail before touching s->ssl again. */ +/* If a fatal handshake reason was parked by `s`, dispatch it as the EPROTO + * failure for `s` and return 1; the per-loop scratch is copied to the stack + * and cleared before the dispatch runs JS. Returns 0 when nothing was parked + * for this socket. */ +static int ssl_dispatch_parked_reason(struct us_socket_t *s) { + struct loop_ssl_data *loop_ssl_data = + (struct loop_ssl_data *) s->group->loop->data.ssl_data; + if (!loop_ssl_data || !loop_ssl_data->ssl_last_fatal_error[0] || + loop_ssl_data->ssl_last_fatal_error_owner != (void *)s) { + return 0; + } + char reason[sizeof(loop_ssl_data->ssl_last_fatal_error)]; + memcpy(reason, loop_ssl_data->ssl_last_fatal_error, sizeof(reason)); + loop_ssl_data->ssl_last_fatal_error[0] = 0; + loop_ssl_data->ssl_last_fatal_error_owner = NULL; + struct us_bun_verify_error_t verify_error = { + .error = -71, .code = "EPROTO", .reason = reason}; + us_dispatch_handshake(s, 0, verify_error); + return 1; +} + static void ssl_trigger_handshake(struct us_socket_t *s, int success) { s->ssl_handshake_state = HANDSHAKE_COMPLETED; + /* A fatal SSL protocol error (wrong version number, bad record, ...) was + * recorded just before this failure: report it instead of the X509 verify + * result so Node's tlsClientError / client error carries the OpenSSL + * reason string. */ + if (!success && ssl_dispatch_parked_reason(s)) { + return; + } struct us_bun_verify_error_t verify_error = us_internal_ssl_verify_error(s); us_dispatch_handshake(s, success, verify_error); } static void ssl_trigger_handshake_econnreset(struct us_socket_t *s) { s->ssl_handshake_state = HANDSHAKE_COMPLETED; + /* A fatal SSL protocol error (wrong version number, bad record, ...) was + * recorded just before this close: report it instead of the generic + * disconnected-before-established message so Node's tlsClientError / + * client error carries the OpenSSL reason. */ + if (ssl_dispatch_parked_reason(s)) { + return; + } struct us_bun_verify_error_t verify_error = { .error = -46, .code = "ECONNRESET", .reason = "Client network socket disconnected before secure TLS connection was established"}; @@ -882,6 +1428,16 @@ static int ssl_handle_shutdown(struct us_socket_t *s, int force_fast_shutdown) { } struct us_socket_t *us_internal_ssl_close(struct us_socket_t *s, int code, void *reason) { + if (s->ssl && s->ssl_in_use) { + /* A JS callback running from inside SSL_do_handshake/SSL_read (ALPN, SNI, + * keylog, ...) destroyed this socket. Reaching ssl_set_loop_data / + * SSL_do_handshake here would re-enter BoringSSL on the same SSL* while + * the outer ssl_run_handshake is still on the stack; defer to the SSL + * driver's epilogue (the same protocol close_raw and ssl_detach honor). */ + s->ssl_pending_detach = 1; + s->ssl_pending_close_code = (unsigned char) code; + return s; + } /* SEMI_SOCKET never connected — SSL was attached eagerly on the fast-path * connect, but no bytes were ever exchanged. Firing on_handshake(0) here * lands in JS after onConnectError already tore down `this`/its handlers. */ @@ -919,6 +1475,11 @@ struct us_socket_t *us_internal_ssl_close(struct us_socket_t *s, int code, void #define ssl_close us_internal_ssl_close static void ssl_update_handshake(struct us_socket_t *s) { + /* The OpenSSL error queue is per-thread and another socket's failure (a + * server and a client commonly share this thread) may have left entries on + * it; clear it before this socket's handshake step so any reason captured + * below genuinely belongs to this socket's own failure. */ + ERR_clear_error(); if (!s->ssl || s->ssl_handshake_state != HANDSHAKE_PENDING) return; /* SSL_read may have driven the handshake to completion before we got here @@ -938,7 +1499,17 @@ static void ssl_update_handshake(struct us_socket_t *s) { return; } + unsigned char ssl_was_in_use = s->ssl_in_use; + s->ssl_in_use = 1; int result = SSL_do_handshake(s_ssl(s)); + s->ssl_in_use = ssl_was_in_use; + if (!ssl_was_in_use && s->ssl_pending_detach) { + /* A callback run from inside the handshake destroyed this socket; perform + * the deferred close now and do not touch the SSL again. */ + s->ssl_pending_detach = 0; + us_socket_close(s, s->ssl_pending_close_code, NULL); + return; + } if (SSL_get_shutdown(s_ssl(s)) & SSL_RECEIVED_SHUTDOWN) { ssl_close(s, 0, NULL); @@ -947,8 +1518,23 @@ static void ssl_update_handshake(struct us_socket_t *s) { if (result <= 0) { int err = SSL_get_error(s_ssl(s), result); + if (err == SSL_ERROR_PENDING_CERTIFICATE) { + /* Suspended by an async SNICallback: stay in HANDSHAKE_PENDING with no + * poll re-arm; us_socket_sni_resolve() re-drives the handshake when the + * JS resolution arrives. */ + s->ssl_handshake_state = HANDSHAKE_PENDING; + return; + } if (err != SSL_ERROR_WANT_READ && err != SSL_ERROR_WANT_WRITE) { if (err == SSL_ERROR_SSL || err == SSL_ERROR_SYSCALL) { + struct loop_ssl_data *loop_ssl_data = + (struct loop_ssl_data *) s->group->loop->data.ssl_data; + unsigned long ssl_queue_err = ERR_peek_last_error(); + if (loop_ssl_data && ssl_queue_err != 0) { + ERR_error_string_n(ssl_queue_err, loop_ssl_data->ssl_last_fatal_error, + sizeof(loop_ssl_data->ssl_last_fatal_error)); + loop_ssl_data->ssl_last_fatal_error_owner = s; + } ERR_clear_error(); s->ssl_fatal_error = 1; } @@ -989,8 +1575,15 @@ struct us_socket_t *us_internal_ssl_on_close(struct us_socket_t *s, int code, vo struct us_socket_t *us_internal_ssl_on_end(struct us_socket_t *s) { ssl_set_loop_data(s); - /* TCP FIN under TLS — send our close_notify (if not already) and raw-close. */ - return ssl_close(s, 0, NULL); + /* TCP FIN under TLS: the peer's write side is gone, so no close_notify reply + * is coming. Send ours best-effort and raw-close now — deferring (the + * code==0 path in ssl_close) would wait forever, and with native + * allowHalfOpen=true the loop.c caller no longer raw-closes for us. */ + s = ssl_close(s, 0, NULL); + if (s && !us_socket_is_closed(s)) { + s = us_internal_socket_close_raw(s, LIBUS_SOCKET_CLOSE_CODE_CLEAN_SHUTDOWN, NULL); + } + return s; } struct us_socket_t *us_internal_ssl_on_writable(struct us_socket_t *s) { @@ -1015,6 +1608,19 @@ struct us_socket_t *us_internal_ssl_on_writable(struct us_socket_t *s) { } struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, int length) { + /* See ssl_update_handshake: start this socket's SSL processing with a clean + * per-thread error queue so a captured reason cannot belong to another + * socket on the same thread. */ + ERR_clear_error(); + /* An accepted node:tls socket's kind is only assigned after its SSL was + * attached, so the is-a-bun-socket marker the session/keylog callbacks key + * on may still be missing. Set it lazily before the SSL_read that will + * fire those callbacks. */ + if (s->ssl && us_socket_kind(s) == BUN_SOCKET_KIND_BUN_SOCKET_TLS && + !SSL_get_ex_data(s->ssl, us_ssl_is_socket_ex_idx)) { + us_ex_idx_ensure(); + SSL_set_ex_data(s->ssl, us_ssl_is_socket_ex_idx, (void *)1); + } /* upgradeTLS [raw, _] half observes ciphertext before SSL_read consumes it. * Skip the empty-flush call from on_writable (length==0 → no real wire bytes). */ if (s->ssl_raw_tap && length > 0) { @@ -1028,7 +1634,15 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, i loop_ssl_data->ssl_read_input_length = length; if (us_socket_is_closed(s)) return NULL; - if (us_internal_ssl_is_shut_down(s)) { + /* Neither SENT_SHUTDOWN (TLS half-close from `socket.shutdown()` / node:tls + * `_final`) nor a sent FIN (POLL_TYPE_SOCKET_SHUT_DOWN) may skip the read + * loop: a half-closed socket still reads. The peer may have application + * data in flight that has to be delivered before its close_notify (handled + * as ZERO_RETURN below) or FIN closes us - under TLS 1.2 this is the + * NORMAL case for a write()+end() server, because the server finishes its + * handshake (and ends) one flight before the client can reply. Only bail + * when reading is genuinely impossible. */ + if (!s->ssl || !s_ssl(s) || s->ssl_fatal_error) { ssl_close(s, 0, NULL); return NULL; } @@ -1043,19 +1657,41 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, i int read = 0; restart: while (1) { + unsigned char ssl_was_in_use = s->ssl_in_use; + s->ssl_in_use = 1; int just_read = SSL_read(s_ssl(s), loop_ssl_data->ssl_read_output + LIBUS_RECV_BUFFER_PADDING + read, LIBUS_RECV_BUFFER_LENGTH - read); + s->ssl_in_use = ssl_was_in_use; + if (!ssl_was_in_use && s->ssl_pending_detach) { + /* A callback run from inside this read destroyed the socket; perform + * the deferred close now and stop processing. */ + s->ssl_pending_detach = 0; + return us_socket_close(s, s->ssl_pending_close_code, NULL); + } if (just_read <= 0) { int err = SSL_get_error(s_ssl(s), just_read); - if (err != SSL_ERROR_WANT_READ && err != SSL_ERROR_WANT_WRITE) { + /* SSL_ERROR_PENDING_CERTIFICATE: the handshake is suspended waiting for + * an async SNICallback (us_select_cert_cb returned retry). Treat it + * like WANT_READ - stop the read loop, deliver whatever was decrypted, + * and park the socket; us_socket_sni_resolve() re-drives the handshake + * when the JS resolution arrives. */ + if (err != SSL_ERROR_WANT_READ && err != SSL_ERROR_WANT_WRITE && + err != SSL_ERROR_PENDING_CERTIFICATE) { if (err == SSL_ERROR_WANT_RENEGOTIATE) { if (ssl_renegotiate(s)) continue; if (ssl_gone(s)) return NULL; err = SSL_ERROR_SSL; } else if (err == SSL_ERROR_ZERO_RETURN) { - /* Remote close_notify. Flush what we decrypted, then close. */ + /* Remote close_notify. A NewSessionTicket that rode in ahead of the + * close_notify was parked by the new-session callback; deliver it + * first (wire order - the ticket preceded these bytes, and Node's + * NewSessionCallback runs before the data reaches JS), then the + * decrypted data, then close. */ + ssl_flush_pending_session(s); + ssl_flush_pending_keylog(s); + if (ssl_gone(s)) return NULL; if (read) { s = us_dispatch_data(s, loop_ssl_data->ssl_read_output + LIBUS_RECV_BUFFER_PADDING, read); if (!s || ssl_gone(s)) return NULL; @@ -1065,10 +1701,25 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, i } if (err == SSL_ERROR_SSL || err == SSL_ERROR_SYSCALL) { + /* Only park the reason while the handshake is still pending - that + * is the only consumer (the close path's EPROTO dispatch). For a + * completed handshake nothing reads it for this socket, and the + * ssl_close below runs JS that could tear down a different + * mid-handshake socket on this loop, which would then pick up this + * socket's reason as its own. */ + if (s->ssl_handshake_state != HANDSHAKE_COMPLETED) { + unsigned long ssl_queue_err = ERR_peek_last_error(); + if (ssl_queue_err != 0) { + ERR_error_string_n(ssl_queue_err, loop_ssl_data->ssl_last_fatal_error, + sizeof(loop_ssl_data->ssl_last_fatal_error)); + loop_ssl_data->ssl_last_fatal_error_owner = s; + } + } ERR_clear_error(); s->ssl_fatal_error = 1; } ssl_close(s, 0, NULL); + loop_ssl_data->ssl_last_fatal_error[0] = 0; return NULL; } else { if (err == SSL_ERROR_WANT_WRITE) s->ssl_read_wants_write = 1; @@ -1092,6 +1743,18 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, i } if (!read) break; + /* Deliver any parked session/keylog payloads BEFORE the data: the + * SSL_read that parked them has returned, the ticket preceded these + * bytes on the wire (Node's NewSessionCallback also runs before the + * data reaches JS), and the data dispatch may run JS that closes the + * socket (an agent with keepAlive off destroys it as soon as the + * response completes) - the tail flush below never runs then and the + * parked session would be dropped. ssl_read_input_length is 0 here + * (checked above), so JS writing from the session handler cannot + * clobber pending ciphertext. */ + ssl_flush_pending_session(s); + ssl_flush_pending_keylog(s); + if (ssl_gone(s)) return NULL; s = us_dispatch_data(s, loop_ssl_data->ssl_read_output + LIBUS_RECV_BUFFER_PADDING, read); if (!s || ssl_gone(s)) return NULL; break; @@ -1122,6 +1785,12 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, i char *saved_input = loop_ssl_data->ssl_read_input; unsigned int saved_length = loop_ssl_data->ssl_read_input_length; unsigned int saved_offset = loop_ssl_data->ssl_read_input_offset; + /* Same flush-before-dispatch as the loop exit below; the save/restore + * around this block protects the ciphertext still in the BIO from any + * JS the session handler runs. */ + ssl_flush_pending_session(s); + ssl_flush_pending_keylog(s); + if (ssl_gone(s)) return NULL; s = us_dispatch_data(s, loop_ssl_data->ssl_read_output + LIBUS_RECV_BUFFER_PADDING, read); if (!s || ssl_gone(s)) return NULL; loop_ssl_data->ssl_read_input = saved_input; @@ -1144,6 +1813,13 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, i if (!s || ssl_gone(s)) return NULL; } + /* The SSL_read loop above is fully unwound; deliver any session the + * new-session callback parked while it ran. The JS this dispatches may + * close the socket. */ + ssl_flush_pending_session(s); + ssl_flush_pending_keylog(s); + if (ssl_gone(s)) return NULL; + return s; } @@ -1210,6 +1886,36 @@ int us_internal_ssl_write(struct us_socket_t *s, const char *data, int length) { void us_internal_ssl_shutdown(struct us_socket_t *s) { if (us_socket_is_closed(s) || us_internal_ssl_is_shut_down(s)) return; + /* BoringSSL has no TLS half-close: once SSL_shutdown sends our + * close_notify, SSL_read refuses to return any further application data + * (SSL_R_PROTOCOL_IS_SHUTDOWN). Node (OpenSSL) keeps reading after sending + * close_notify, and node:net/tls semantics depend on that: a write()+end() + * server must still receive the reply the peer sends after processing our + * data - under TLS 1.2 the server's handshake completes one flight before + * the client's, so that ordering is the norm rather than the exception. + * + * Send the TLS-level close_notify only when the peer's close_notify has + * already arrived (we will never need to read again). Otherwise do a TCP + * half-close (FIN, keep reading): the peer sees EOF after our last record + * and the connection tears down through the normal read-side path when its + * close_notify / FIN arrives. */ + if (!SSL_in_init(s_ssl(s)) && !(SSL_get_shutdown(s_ssl(s)) & SSL_RECEIVED_SHUTDOWN)) { + /* BoringSSL defers post-handshake writes (the TLS 1.3 NewSessionTicket + * messages) until the first SSL_write or SSL_shutdown. We are not sending + * close_notify here, so flush them explicitly before the FIN: a + * zero-length write seals no application record but pushes the pending + * handshake data through the BIO. Without this, a server that ends + * without writing (the tls.Server((s) => s.end()) pattern) never delivers + * its session tickets and clients cannot resume. */ + struct loop_ssl_data *flush_loop_data = (struct loop_ssl_data *)s->group->loop->data.ssl_data; + flush_loop_data->ssl_read_input_length = 0; + flush_loop_data->ssl_socket = s; + char zero_buf = 0; + SSL_write(s_ssl(s), &zero_buf, 0); + us_internal_socket_raw_shutdown(s); + return; + } + struct loop_ssl_data *loop_ssl_data = (struct loop_ssl_data *)s->group->loop->data.ssl_data; loop_ssl_data->ssl_read_input_length = 0; loop_ssl_data->ssl_socket = s; @@ -1230,6 +1936,45 @@ void us_internal_ssl_shutdown(struct us_socket_t *s) { } } +/* Resume a handshake suspended by an async SNICallback. `ctx` (may be NULL = + * fall through to the default context) carries a reference that this call + * consumes. `error` != 0 aborts the handshake instead. No-op when the socket + * already closed/detached (the pending JS resolution outlived it). */ +void us_socket_sni_resolve(struct us_socket_t *s, struct ssl_ctx_st *ctx, int error) { + if (!s || us_socket_is_closed(s) || !s->ssl || !s_ssl(s)) { + if (ctx) SSL_CTX_free(ctx); + return; + } + if (us_ssl_sni_pending_idx < 0) { + if (ctx) SSL_CTX_free(ctx); + return; + } + struct us_ssl_sni_pending_t *pending = SSL_get_ex_data(s_ssl(s), us_ssl_sni_pending_idx); + if (!pending || pending->state != 1) { + /* Not actually suspended (late/duplicate resolution). */ + if (ctx) SSL_CTX_free(ctx); + return; + } + if (error) { + pending->state = 3; + if (ctx) SSL_CTX_free(ctx); + /* Match the synchronous abort path: the connection is dropped WITHOUT a + * TLS alert (Node's behavior for SNICallback errors). Mark the socket for + * the deferred close before re-driving the handshake, so the BIO swallows + * the handshake_failure alert BoringSSL queues for select_cert_error and + * the epilogue closes the socket; the client just sees the connection go + * away ("disconnected before secure TLS connection was established"). */ + s->ssl_pending_detach = 1; + s->ssl_pending_close_code = 0; + } else { + pending->state = 2; + pending->resolved_ctx = ctx; /* may be NULL = default ctx */ + } + /* Re-drive the handshake; select_cert_cb re-fires and consumes the state. */ + ssl_set_loop_data(s); + ssl_update_handshake(s); +} + void us_internal_ssl_handshake_abort(struct us_socket_t *s) { s->ssl_fatal_error = 1; ssl_close(s, 0, NULL); @@ -1237,17 +1982,28 @@ void us_internal_ssl_handshake_abort(struct us_socket_t *s) { /* ── Adopt-TLS (STARTTLS / Bun.connect upgrade) ──────────────────────────── */ +/* Feed bytes that were already read off the wire (e.g. a ClientHello consumed + * by the plain-TCP layer before the socket was adopted into TLS) through the + * same decrypt path as bytes arriving from the kernel. */ +struct us_socket_t *us_socket_tls_feed(struct us_socket_t *s, const char *data, int length) { + if (us_socket_is_closed(s) || !s->ssl || length <= 0) return s; + return us_internal_ssl_on_data(s, (char *)data, length); +} + struct us_socket_t *us_socket_adopt_tls(struct us_socket_t *s, struct us_socket_group_t *group, unsigned char kind, struct ssl_ctx_st *ssl_ctx, - const char *sni, int old_ext_size, + const char *sni, int is_client, int old_ext_size, int ext_size) { if (us_socket_is_closed(s)) return NULL; struct us_socket_t *new_s = us_socket_adopt(s, group, kind, old_ext_size, ext_size); if (!new_s) return NULL; - us_internal_ssl_attach(new_s, ssl_ctx, /*is_client*/1, sni, NULL); + /* is_client=0 puts the SSL in accept state (server-side upgrade, e.g. + * `new tls.TLSSocket(acceptedSocket, { isServer: true })`); there is no + * listener for an adopted socket, so SNI resolves from the single ssl_ctx. */ + us_internal_ssl_attach(new_s, ssl_ctx, is_client, sni, NULL); us_socket_resume(new_s); /* Do NOT kick the handshake or dispatch on_open here — the caller hasn't * repointed the ext slot yet, so any dispatch (open/handshake/close) would @@ -1273,13 +2029,156 @@ static void sni_node_destructor(void *user) { static struct sni_node_t *resolve_listener_ctx(struct us_listen_socket_t *ls, const char *hostname) { if (!ls->sni) return NULL; - struct sni_node_t *node = (struct sni_node_t *)sni_find(ls->sni, hostname); - if (!node) { - if (!ls->on_server_name) return NULL; - ls->on_server_name(ls, hostname); - node = (struct sni_node_t *)sni_find(ls->sni, hostname); + return (struct sni_node_t *)sni_find(ls->sni, hostname); +} + +/* Extracts the host_name from the ClientHello's server_name extension. + * Returns the length written to `out` (NUL-terminated), or 0 if absent / + * malformed. BoringSSL does document SSL_get_servername as usable inside + * select_certificate_cb (extract_sni runs before the callback), but every + * caller here reads the raw ClientHello instead so the lookup depends only + * on the early-callback contract, not on SSL* handshake state. */ +static size_t us_client_hello_servername(const SSL_CLIENT_HELLO *hello, char *out, size_t out_len) { + const uint8_t *ext; + size_t ext_len; + if (!SSL_early_callback_ctx_extension_get(hello, TLSEXT_TYPE_server_name, &ext, &ext_len)) { + return 0; + } + /* server_name extension: u16 list_len, then entries of (u8 type, u16 len, bytes). */ + if (ext_len < 5) return 0; + size_t list_len = ((size_t)ext[0] << 8) | ext[1]; + if (list_len + 2 != ext_len) return 0; + const uint8_t *p = ext + 2; + size_t remaining = list_len; + while (remaining >= 3) { + uint8_t type = p[0]; + size_t name_len = ((size_t)p[1] << 8) | p[2]; + if (name_len + 3 > remaining) return 0; + if (type == TLSEXT_NAMETYPE_host_name) { + if (name_len == 0 || name_len >= out_len) return 0; + memcpy(out, p + 3, name_len); + out[name_len] = 0; + return name_len; + } + p += 3 + name_len; + remaining -= 3 + name_len; } - return node; + return 0; +} + +/* The async-capable certificate selector. Registered (instead of relying on + * sni_cb alone) on listener contexts that have a dynamic JS resolver, so an + * SNICallback that cannot answer synchronously suspends the handshake + * (ssl_select_cert_retry -> SSL_ERROR_PENDING_CERTIFICATE) instead of falling + * through to the default context. us_socket_sni_resolve() resumes it. */ +static enum ssl_select_cert_result_t us_select_cert_cb(const SSL_CLIENT_HELLO *hello) { + SSL *ssl = hello->ssl; + if (!ssl || us_ssl_listener_ex_idx < 0) return ssl_select_cert_success; + + /* A previous suspension being resumed: consume the stored result. */ + struct us_ssl_sni_pending_t *pending = + us_ssl_sni_pending_idx >= 0 ? SSL_get_ex_data(ssl, us_ssl_sni_pending_idx) : NULL; + if (pending && pending->state == 2) { + pending->state = 0; + if (pending->resolved_ctx) { + SSL_set_SSL_CTX(ssl, pending->resolved_ctx); + SSL_CTX_free(pending->resolved_ctx); + pending->resolved_ctx = NULL; + return ssl_select_cert_success; + } + /* The asynchronous resolution selected nothing (cb(null, null)): fall + * through to the static SNI tree below, exactly like a synchronous + * resolver returning null - the resume must not skip the tree fallback + * the sync path gets. */ + struct us_listen_socket_t *resumed_ls = + (struct us_listen_socket_t *)SSL_get_ex_data(ssl, us_ssl_listener_ex_idx); + if (resumed_ls) { + /* Read the servername from the raw ClientHello, same as the first-call + * path below: that is the read the early-callback contract guarantees + * (SSL_get_servername happens to be populated by the resume re-drive + * today, but the raw parse does not depend on that). */ + char resumed_host[256]; + if (us_client_hello_servername(hello, resumed_host, sizeof(resumed_host))) { + struct sni_node_t *resumed_node = resolve_listener_ctx(resumed_ls, resumed_host); + if (resumed_node) { + SSL_set_SSL_CTX(ssl, resumed_node->ctx); + } + } + } + return ssl_select_cert_success; + } + if (pending && pending->state == 3) { + pending->state = 0; + return ssl_select_cert_error; + } + if (pending && pending->state == 1) { + /* Still waiting (a spurious re-drive); keep suspending. */ + return ssl_select_cert_retry; + } + + struct us_listen_socket_t *ls = + (struct us_listen_socket_t *)SSL_get_ex_data(ssl, us_ssl_listener_ex_idx); + if (!ls || !ls->on_server_name) return ssl_select_cert_success; + + char hostname[256]; + if (!us_client_hello_servername(hello, hostname, sizeof(hostname))) { + return ssl_select_cert_success; + } + + /* The dynamic resolver (the user's SNICallback) runs FIRST, matching Node + * where a user-provided SNICallback replaces the default SNI handling + * entirely - including for the bind hostname, which Listener.rs always + * registers in the static tree (so tree-first would shadow the callback + * for the most-requested name and break per-connection cert rotation). + * The static tree (bind hostname + addContext entries) is the fallback + * when the resolver selects nothing, which is also the no-user-callback + * path: the JS dispatch returns undefined immediately in that case. */ + + /* The socket processing this ClientHello - the JS resolver needs it as the + * resume handle for an asynchronous SNICallback. */ + struct loop_ssl_data *cb_lsd = (struct loop_ssl_data *)BIO_get_data(SSL_get_wbio(ssl)); + struct us_socket_t *cb_socket = cb_lsd ? cb_lsd->ssl_socket : NULL; + + void *saved_loop_state[5]; + us_internal_ssl_loop_state_save(ssl, saved_loop_state); + int abort_handshake = 0; + SSL_CTX *dyn = ls->on_server_name(ls, hostname, &abort_handshake, cb_socket); + us_internal_ssl_loop_state_restore(saved_loop_state); + + if (abort_handshake == 1) { + /* Error/invalid context: drop the connection without an alert (the + * deferred-close + BIO-swallow path, same as sni_cb). */ + struct loop_ssl_data *lsd = (struct loop_ssl_data *)BIO_get_data(SSL_get_wbio(ssl)); + if (lsd && lsd->ssl_socket) { + lsd->ssl_socket->ssl_pending_detach = 1; + lsd->ssl_socket->ssl_pending_close_code = 0; + } + return ssl_select_cert_error; + } + if (abort_handshake == 2) { + /* The JS resolver answered "pending": suspend until us_socket_sni_resolve. */ + if (us_ssl_sni_pending_idx >= 0) { + if (!pending) { + pending = us_calloc(1, sizeof(*pending)); + SSL_set_ex_data(ssl, us_ssl_sni_pending_idx, pending); + } + pending->state = 1; + } + return ssl_select_cert_retry; + } + if (dyn) { + SSL_set_SSL_CTX(ssl, dyn); + SSL_CTX_free(dyn); + return ssl_select_cert_success; + } + + /* No dynamic selection: fall back to the static SNI tree (the bind + * hostname and addContext() entries). */ + struct sni_node_t *node = resolve_listener_ctx(ls, hostname); + if (node) { + SSL_set_SSL_CTX(ssl, node->ctx); + } + return ssl_select_cert_success; } static int sni_cb(SSL *ssl, int *al, void *arg) { @@ -1290,10 +2189,23 @@ static int sni_cb(SSL *ssl, int *al, void *arg) { struct us_listen_socket_t *ls = (struct us_listen_socket_t *)SSL_get_ex_data(ssl, us_ssl_listener_ex_idx); if (!ls) return SSL_TLSEXT_ERR_OK; + if (ls->on_server_name) { + /* A dynamic resolver (user SNICallback) exists: us_select_cert_cb already + * ran it - and the static-tree fallback - at the earlier + * select-certificate stage. Consulting the tree again here would + * OVERWRITE the resolver's per-connection selection with the tree entry + * (the bind hostname is always registered there), undoing the + * SNICallback-takes-precedence contract. */ + return SSL_TLSEXT_ERR_OK; + } const char *hostname = SSL_get_servername(ssl, TLSEXT_NAMETYPE_host_name); if (hostname && hostname[0]) { + /* Static SNI tree only (no dynamic resolver registered for this + * listener). */ struct sni_node_t *node = resolve_listener_ctx(ls, hostname); - if (node) SSL_set_SSL_CTX(ssl, node->ctx); + if (node) { + SSL_set_SSL_CTX(ssl, node->ctx); + } } return SSL_TLSEXT_ERR_OK; } @@ -1343,9 +2255,31 @@ void *us_listen_socket_find_server_name_userdata(struct us_listen_socket_t *ls, return node ? node->user : NULL; } +/* Returns the SSL_CTX registered for `hostname_pattern` via + * us_listen_socket_add_server_name, or NULL. Owned - the caller must release + * the reference. The on_server_name resolvers return owned references (the + * SNI dispatcher frees them after SSL_set_SSL_CTX takes its own), so the + * tree's reference must not be handed out as a borrow. */ +struct ssl_ctx_st *us_listen_socket_find_server_name_ctx(struct us_listen_socket_t *ls, + const char *hostname_pattern) { + if (!ls->sni) return NULL; + struct sni_node_t *node = (struct sni_node_t *)sni_find(ls->sni, hostname_pattern); + if (!node || !node->ctx) return NULL; + SSL_CTX_up_ref(node->ctx); + return node->ctx; +} + void us_listen_socket_on_server_name(struct us_listen_socket_t *ls, - void (*cb)(struct us_listen_socket_t *, const char *)) { + struct ssl_ctx_st *(*cb)(struct us_listen_socket_t *, const char *, int *, struct us_socket_t *)) { ls->on_server_name = cb; + /* The dynamic resolver may need to suspend the handshake (async + * SNICallback); only the early select-certificate callback supports retry, + * so register it on the listener's default context. The servername-stage + * sni_cb stays registered for the static SNI tree (it is a no-op when the + * early callback already installed a context). */ + if (ls->ssl_ctx) { + SSL_CTX_set_select_certificate_cb(ls->ssl_ctx, us_select_cert_cb); + } } void *us_socket_server_name_userdata(struct us_socket_t *s) { diff --git a/packages/bun-usockets/src/eventing/epoll_kqueue.c b/packages/bun-usockets/src/eventing/epoll_kqueue.c index 7507f27a3a4c..79371df7fe13 100644 --- a/packages/bun-usockets/src/eventing/epoll_kqueue.c +++ b/packages/bun-usockets/src/eventing/epoll_kqueue.c @@ -503,8 +503,15 @@ int us_poll_start_rc(struct us_poll_t *p, struct us_loop_t *loop, int events) { #ifdef LIBUS_USE_EPOLL struct epoll_event event; if(!(events & LIBUS_SOCKET_READABLE) && !(events & LIBUS_SOCKET_WRITABLE)) { - // if we are disabling readable, we need to add the other events to detect EOF/HUP/ERR - events |= EPOLLRDHUP | EPOLLHUP | EPOLLERR; + /* Polling neither direction (a half-open socket after the peer's FIN): + * EPOLLHUP and EPOLLERR are always reported even when not requested, + * which is exactly what the dispatcher's eof/error handling needs to + * close the socket once both directions are down. Never add + * EPOLLRDHUP here - the peer's FIN has typically ALREADY arrived, so + * a level-triggered EPOLLRDHUP would fire on every epoll_wait while + * the dispatcher (which derives eof from EPOLLHUP only) ignores it, + * spinning the loop at 100% CPU until the JS side closes the fd. */ + events |= EPOLLHUP | EPOLLERR; } event.events = events; event.data.ptr = p; @@ -531,8 +538,9 @@ void us_poll_change(struct us_poll_t *p, struct us_loop_t *loop, int events) { #ifdef LIBUS_USE_EPOLL struct epoll_event event; if(!(events & LIBUS_SOCKET_READABLE) && !(events & LIBUS_SOCKET_WRITABLE)) { - // if we are disabling readable, we need to add the other events to detect EOF/HUP/ERR - events |= EPOLLRDHUP | EPOLLHUP | EPOLLERR; + /* See us_poll_start_rc: EPOLLHUP/EPOLLERR are implicit; never add + * EPOLLRDHUP for an already-half-closed socket or the loop spins. */ + events |= EPOLLHUP | EPOLLERR; } event.events = events; event.data.ptr = p; diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index 014d925fcc55..2cdacc08f22e 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -129,6 +129,8 @@ extern struct us_socket_t *us_dispatch_end(us_socket_r s); extern struct us_socket_t *us_dispatch_connect_error(us_socket_r s, int code); extern struct us_connecting_socket_t *us_dispatch_connecting_error(struct us_connecting_socket_t *c, int code); extern void us_dispatch_handshake(us_socket_r s, int success, struct us_bun_verify_error_t err); +extern void us_dispatch_session(us_socket_r s, const unsigned char *data, int length); +extern void us_dispatch_keylog(us_socket_r s, const unsigned char *data, int length); extern struct us_socket_t *us_dispatch_ssl_raw_tap(us_socket_r s, char *data, int length); extern int Bun__addrinfo_get(struct us_loop_t* loop, const char* host, uint16_t port, struct addrinfo_request** ptr); @@ -265,6 +267,16 @@ struct us_socket_t { * Used by Bun's `socket.upgradeTLS()` so the returned [raw, tls] pair's * `raw` half can observe ciphertext (node:net Duplex.ondata semantics). */ unsigned char ssl_raw_tap : 1; + /* Set while SSL_do_handshake/SSL_read is on the stack: JS run from inside + * those calls (ALPN/SNI/keylog callbacks) may destroy the socket, and the + * SSL must not be freed under BoringSSL's feet - the detach is deferred to + * the driver's epilogue via ssl_pending_detach. */ + unsigned char ssl_in_use : 1; + unsigned char ssl_pending_detach : 1; + /* The close code passed to the deferred close (e.g. a reset requested from + * inside a handshake callback must still RST, not FIN, when it is finally + * performed). */ + unsigned char ssl_pending_close_code; struct us_socket_group_t *group; /* NULL for plain TCP. Direct BoringSSL `SSL*`; set by us_internal_ssl_attach @@ -378,7 +390,10 @@ struct us_listen_socket_t { struct ssl_ctx_st *ssl_ctx; /* SNI hostname → {SSL_CTX*, user*} tree. Owned. */ void *sni; - void (*on_server_name)(struct us_listen_socket_t *, const char *hostname); + /* Dynamic SNI resolver: returns the SSL_CTX to serve for `hostname` on the + * in-flight handshake only (the caller does not cache it), or NULL to fall + * through to the default context. */ + struct ssl_ctx_st *(*on_server_name)(struct us_listen_socket_t *, const char *hostname, int *abort_handshake, struct us_socket_t *socket); unsigned int socket_ext_size; /* kind to stamp on accepted sockets. */ unsigned char accept_kind; @@ -391,4 +406,9 @@ void us_internal_socket_group_unlink_connecting_socket(us_socket_group_r group, int us_raw_root_certs(struct us_cert_string_t **out); +/* Save/restore the per-loop BIO routing state around in-handshake JS + * callbacks (SNI / ALPN). Defined in crypto/openssl.c. */ +void us_internal_ssl_loop_state_save(void *ssl, void **out5); +void us_internal_ssl_loop_state_restore(void **saved5); + #endif // INTERNAL_H diff --git a/packages/bun-usockets/src/internal/networking/bsd.h b/packages/bun-usockets/src/internal/networking/bsd.h index 0b57bf11e045..fd98c143552f 100644 --- a/packages/bun-usockets/src/internal/networking/bsd.h +++ b/packages/bun-usockets/src/internal/networking/bsd.h @@ -188,6 +188,10 @@ int bsd_socket_multicast_interface(LIBUS_SOCKET_DESCRIPTOR fd, const struct sock int bsd_socket_set_membership(LIBUS_SOCKET_DESCRIPTOR fd, const struct sockaddr_storage *addr, const struct sockaddr_storage *iface, int drop); int bsd_socket_set_source_specific_membership(LIBUS_SOCKET_DESCRIPTOR fd, const struct sockaddr_storage *source, const struct sockaddr_storage *group, const struct sockaddr_storage *iface, int drop); int bsd_socket_keepalive(LIBUS_SOCKET_DESCRIPTOR fd, int on, unsigned int delay); +/* IP type-of-service (IPv4 IP_TOS / IPv6 IPV6_TCLASS). set returns 0 or a + * negative platform errno; get returns the value (>= 0) or a negative errno. */ +int bsd_socket_set_tos(LIBUS_SOCKET_DESCRIPTOR fd, int tos); +int bsd_socket_get_tos(LIBUS_SOCKET_DESCRIPTOR fd); void bsd_socket_flush(LIBUS_SOCKET_DESCRIPTOR fd); LIBUS_SOCKET_DESCRIPTOR bsd_create_socket(int domain, int type, int protocol, int *err); @@ -230,7 +234,7 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_udp_socket(const char *host, int port, int op int bsd_connect_udp_socket(LIBUS_SOCKET_DESCRIPTOR fd, const char *host, int port); int bsd_disconnect_udp_socket(LIBUS_SOCKET_DESCRIPTOR fd); -LIBUS_SOCKET_DESCRIPTOR bsd_create_connect_socket(struct sockaddr_storage *addr, int options); +LIBUS_SOCKET_DESCRIPTOR bsd_create_connect_socket(struct sockaddr_storage *addr, struct sockaddr_storage *local_addr, int options); LIBUS_SOCKET_DESCRIPTOR bsd_create_connect_socket_unix(const char *server_path, size_t pathlen, int options); diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index b94041acae7c..f9ad11c952d3 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -322,7 +322,11 @@ struct us_socket_t *us_socket_adopt(us_socket_r s, us_socket_group_r group, * sni may be NULL. */ struct us_socket_t *us_socket_adopt_tls(us_socket_r s, us_socket_group_r group, unsigned char kind, struct ssl_ctx_st *ssl_ctx, const char *sni, - int old_ext_size, int ext_size) __attribute__((nonnull(1, 2, 4))); + int is_client, int old_ext_size, int ext_size) __attribute__((nonnull(1, 2, 4))); +/* Feed bytes that were already read off the wire (e.g. a ClientHello consumed + * by the plain-TCP layer before the socket was adopted into TLS) through the + * same decrypt path as bytes arriving from the kernel. */ +struct us_socket_t *us_socket_tls_feed(us_socket_r s, const char *data, int length) __attribute__((nonnull(1))); /* Send ClientHello after adopt_tls. Separate so the caller can repoint the * ext slot before any dispatch can fire. */ void us_socket_start_tls_handshake(us_socket_r s) nonnull_fn_decl; @@ -350,8 +354,21 @@ void us_listen_socket_remove_server_name(struct us_listen_socket_t *ls, const char *hostname_pattern) nonnull_fn_decl; void *us_listen_socket_find_server_name_userdata(struct us_listen_socket_t *ls, const char *hostname_pattern) nonnull_fn_decl; +/* Returns an owned reference; the caller must release it. */ +struct ssl_ctx_st *us_listen_socket_find_server_name_ctx(struct us_listen_socket_t *ls, + const char *hostname_pattern) nonnull_fn_decl; +/* Parses a PKCS#12 blob into malloc'd PEM key/cert/ca strings (caller frees); + * returns 0 with a static *err_reason tag on failure. */ +int us_ssl_parse_pkcs12(const char *data, size_t len, const char *pass, + char **out_key, size_t *out_key_len, char **out_cert, size_t *out_cert_len, + char **out_ca, size_t *out_ca_len, const char **err_reason); void us_listen_socket_on_server_name(struct us_listen_socket_t *ls, - void (*cb)(struct us_listen_socket_t *, const char *hostname)) nonnull_fn_decl; + struct ssl_ctx_st *(*cb)(struct us_listen_socket_t *, const char *hostname, int *abort_handshake, struct us_socket_t *socket)) nonnull_fn_decl; +/* Resume a handshake suspended by an async SNICallback (the dynamic resolver + * set abort_handshake = 2). `ctx` may be NULL (use the default context); the + * call consumes the reference. `error` != 0 aborts the handshake. Safe to call + * after the socket closed (no-op). */ +void us_socket_sni_resolve(us_socket_r s, struct ssl_ctx_st *ctx, int error); void *us_socket_server_name_userdata(us_socket_r s); /* ── Connect ────────────────────────────────────────────────────────────── @@ -359,9 +376,10 @@ void *us_socket_server_name_userdata(us_socket_r s); * us_connecting_socket_t* (DNS / happy-eyeballs in flight, *is_connecting=0). * ssl_ctx may be NULL for plain TCP. */ void *us_socket_group_connect(us_socket_group_r group, unsigned char kind, - struct ssl_ctx_st *ssl_ctx, const char *host, int port, int options, + struct ssl_ctx_st *ssl_ctx, const char *host, int port, + const char *local_host, int local_port, int options, int socket_ext_size, int *is_connecting) - __attribute__((nonnull(1, 4, 8))); /* ssl_ctx nullable */ + __attribute__((nonnull(1, 4, 10))); /* ssl_ctx, local_host nullable */ struct us_socket_t *us_socket_group_connect_unix(us_socket_group_r group, unsigned char kind, struct ssl_ctx_st *ssl_ctx, const char *server_path, size_t pathlen, int options, int socket_ext_size) @@ -404,6 +422,9 @@ struct us_bun_socket_context_options_t { const char * const *ca; unsigned int ca_count; unsigned int secure_options; + // Minimum/maximum TLS protocol version (TLS1_VERSION..TLS1_3_VERSION); 0 = unset/default. + int ssl_min_version; + int ssl_max_version; int reject_unauthorized; int request_cert; unsigned int client_renegotiation_limit; @@ -438,6 +459,17 @@ struct ssl_ctx_st *us_ssl_ctx_from_options( void us_internal_ssl_ctx_up_ref(struct ssl_ctx_st *ssl_ctx); void us_internal_ssl_ctx_unref(struct ssl_ctx_st *ssl_ctx); long us_ssl_ctx_live_count(void); +/* Appends the certificates in the PEM `content` to `ctx`'s trust store; + * returns 0 when nothing could be added. */ +int us_ssl_ctx_add_ca_cert(struct ssl_ctx_st *ctx, const char *content); +/* TLS-over-duplex / named-pipe SSL owners (no us_socket_t): opt an SSL into + * the parked new-session/keylog queues, then drain them with the pop calls + * after each SSL_read/SSL_do_handshake stack unwinds. Pop returns the entry + * length (0 = queue empty); entries are capped at 64 KB (sessions) and + * 4 KB+1 (keylog lines). */ +void us_ssl_enable_pending_events(struct ssl_st *ssl); +int us_ssl_pop_pending_session(struct ssl_st *ssl, unsigned char *out, int out_cap); +int us_ssl_pop_pending_keylog(struct ssl_st *ssl, unsigned char *out, int out_cap); /* Public interfaces for loops */ @@ -507,6 +539,11 @@ int us_socket_write(us_socket_r s, const char *nonnull_arg data, int length) non int us_socket_write2(us_socket_r s, const char *header, int header_length, const char *payload, int payload_length) nonnull_fn_decl; /* Bypass TLS — write raw bytes to the fd even if `s->ssl` is set. */ int us_socket_raw_write(us_socket_r s, const char *data, int length); +/* Like us_socket_write, but additionally reports a fatal (non-would-block) + * send error through *fatal_write_error so opted-in callers can fail the + * write instead of retrying forever. TLS sockets fall back to + * us_socket_write (their errors propagate through the SSL layer). */ +int us_socket_write_check_error(us_socket_r s, const char *data, int length, int *fatal_write_error); void us_socket_timeout(us_socket_r s, unsigned int seconds) nonnull_fn_decl; void us_socket_long_timeout(us_socket_r s, unsigned int minutes) nonnull_fn_decl; @@ -564,6 +601,10 @@ void us_socket_unref(us_socket_r s); void us_socket_nodelay(us_socket_r s, int enabled); int us_socket_keepalive(us_socket_r s, int enabled, unsigned int delay); +/* IP type-of-service (IPv4 IP_TOS / IPv6 IPV6_TCLASS). set returns 0 or a + * negative platform errno; get returns the value (>= 0) or a negative errno. */ +int us_socket_set_tos(us_socket_r s, int tos); +int us_socket_get_tos(us_socket_r s); void us_socket_resume(us_socket_r s); void us_socket_pause(us_socket_r s); diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index f237edf57d21..c556ceb6f033 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -384,7 +384,20 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in /* Both connect and listen sockets are semi-sockets * but they poll for different events */ if (us_poll_events(p) == LIBUS_SOCKET_WRITABLE) { - us_internal_socket_after_open((struct us_socket_t *) p, error || eof); + /* The connecting fd became writable with an error/HUP flag also + * set: the handshake may have completed and then been reset + * before we collected the event. Report the kernel's actual + * SO_ERROR (ECONNRESET for that race) instead of the literal + * boolean, which downstream would misreport as ECONNREFUSED. + * libuv does the same getsockopt in uv__stream_connect. */ + int connect_error = 0; + if (error || eof) { + connect_error = us_socket_get_error((struct us_socket_t *) p); + if (connect_error == 0) { + connect_error = ECONNRESET; + } + } + us_internal_socket_after_open((struct us_socket_t *) p, connect_error); } else { struct us_listen_socket_t *listen_socket = (struct us_listen_socket_t *) p; struct us_socket_group_t *accept_group = listen_socket->accept_group; @@ -662,8 +675,17 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in return; } if(s->flags.allow_half_open) { - /* We got a Error but is EOF and we allow half open so stop polling for readable and keep going*/ - us_poll_change(&s->p, loop, us_poll_events(&s->p) & LIBUS_SOCKET_WRITABLE); + /* EOF with half-open allowed: stop polling readable but KEEP + * polling writable. Masking with the current events dropped + * writable when the EOF landed before the poll had been + * switched to writable for a just-queued write (an end() + * issued in the same tick as connect): the queued bytes + * never flushed, their drain callback never fired, and the + * stream's 'finish' never happened - the FIN-terminated + * http response tests hung on every Linux target. The + * writable dispatch disables writable polling again once + * the buffer is drained, so this does not busy-poll. */ + us_poll_change(&s->p, loop, LIBUS_SOCKET_WRITABLE); s = s->ssl ? us_internal_ssl_on_end(s) : us_dispatch_end(s); } else { /* We dont allow half open just emit end and close the socket */ diff --git a/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index a294fc59fed5..abe79d31a3d9 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -259,6 +259,16 @@ void us_connecting_socket_close(struct us_connecting_socket_t *c) { * handshake/secureConnection event. openssl.c re-enters here once that * graceful path is done. */ struct us_socket_t *us_internal_socket_close_raw(struct us_socket_t *s, int code, void *reason) { + if (s->ssl && s->ssl_in_use) { + /* A JS callback running from inside SSL_do_handshake/SSL_read (ALPN, SNI, + * keylog, ...) destroyed this socket. Closing now frees the SSL and + * releases context state BoringSSL is still reading on the stack; defer + * the close to the SSL driver's epilogue instead, preserving the close + * code so a requested reset still resets. */ + s->ssl_pending_detach = 1; + s->ssl_pending_close_code = (unsigned char) code; + return s; + } if (!us_socket_is_closed(s)) { struct us_loop_t *loop = s->group->loop; @@ -469,6 +479,38 @@ int us_socket_write(struct us_socket_t *s, const char *data, int length) { return written < 0 ? 0 : written; } +int us_socket_write_check_error(struct us_socket_t *s, const char *data, int length, int *fatal_write_error) { + if (fatal_write_error) *fatal_write_error = 0; + if (us_socket_is_closed(s) || us_socket_is_shut_down(s)) { + return 0; + } + if (s->ssl) { + /* TLS writes have their own error propagation; keep the existing path. */ + return us_socket_write(s, data, length); + } + + int written = bsd_send(us_poll_fd(&s->p), data, length); + if (written < 0) { + /* bsd_send already retries EINTR; bsd_would_block() reads errno on + * POSIX and WSAGetLastError() on Windows. */ + if (bsd_would_block()) { + s->flags.last_write_failed = 1; + us_poll_change(&s->p, s->group->loop, LIBUS_SOCKET_READABLE | LIBUS_SOCKET_WRITABLE); + return 0; + } + /* Fatal send error (EPIPE/ECONNRESET after the peer vanished): report + * it to callers that opt in instead of masking it as would-block, and + * do not keep polling writable - retrying can never succeed. */ + if (fatal_write_error) *fatal_write_error = 1; + return 0; + } + if (written != length) { + s->flags.last_write_failed = 1; + us_poll_change(&s->p, s->group->loop, LIBUS_SOCKET_READABLE | LIBUS_SOCKET_WRITABLE); + } + return written; +} + int us_socket_raw_write(struct us_socket_t *s, const char *data, int length) { /* Bypass-TLS path: openssl.c uses this to flush close_notify *after* * SSL_shutdown() has marked the SSL layer shut down, so checking @@ -632,6 +674,26 @@ void us_socket_nodelay(struct us_socket_t *s, int enabled) { } } +#ifndef EBADF +#define EBADF 9 +#endif + +/* Returns 0 on success or a negative platform errno. */ +int us_socket_set_tos(struct us_socket_t *s, int tos) { + if (us_socket_is_closed(s)) { + return -EBADF; + } + return bsd_socket_set_tos(us_poll_fd((struct us_poll_t *) s), tos); +} + +/* Returns the current TOS / traffic class (>= 0) or a negative platform errno. */ +int us_socket_get_tos(struct us_socket_t *s) { + if (us_socket_is_closed(s)) { + return -EBADF; + } + return bsd_socket_get_tos(us_poll_fd((struct us_poll_t *) s)); +} + /// Returns 0 on success. Returned error values depend on the platform. /// - on posix, returns `errno` /// - on windows, when libuv is used, returns a UV err code diff --git a/packages/bun-uws/src/App.h b/packages/bun-uws/src/App.h index 574758c6a2a6..8bb7b0361410 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -74,6 +74,8 @@ namespace uWS { const char **ca = nullptr; unsigned int ca_count = 0; unsigned int secure_options = 0; + int ssl_min_version = 0; + int ssl_max_version = 0; int reject_unauthorized = 0; int request_cert = 0; unsigned int client_renegotiation_limit = 3; @@ -287,9 +289,19 @@ struct TemplatedApp { TemplatedApp(TemplatedApp &&other) = delete; private: - static void onMissingServerName(struct us_listen_socket_t *ls, const char *hostname) { + static struct ssl_ctx_st *onMissingServerName(struct us_listen_socket_t *ls, const char *hostname, int *abort_handshake, struct us_socket_t *socket) { + /* Bun.serve's missingServerName handler registers a context or lets the + * default serve the request - it never aborts or suspends the handshake. */ + (void) abort_handshake; + (void) socket; auto *httpContext = (HttpContext *) us_socket_group_ext(us_listen_socket_group(ls)); httpContext->getSocketContextData()->missingServerNameHandler(hostname); + /* The handler is expected to have registered the name via + * addServerName(); hand the newly-registered context back so the + * in-flight handshake uses it (the resolver no longer re-checks the + * SNI tree after this callback returns). The handler may also have + * closed the listener, freeing the tree. */ + return us_listen_socket_find_server_name_ctx(ls, hostname); } TemplatedApp(SocketContextOptions options) { diff --git a/patches/lolhtml/0002-quiet-build-script-linker-warning.patch b/patches/lolhtml/0002-quiet-build-script-linker-warning.patch new file mode 100644 index 000000000000..0cfbe3b24df6 --- /dev/null +++ b/patches/lolhtml/0002-quiet-build-script-linker-warning.patch @@ -0,0 +1,12 @@ +The lol_html_c_api build script is linked by rustc with link args meant for +target artifacts (e.g. -no-pie from the static relocation model); clang warns +"argument unused during compilation" and rustc's linker_messages lint turns +that linker stderr into a warning on every build-rust CI job. Allow the lint +in the build script — real linker errors still fail the link. +--- a/c-api/build.rs ++++ b/c-api/build.rs +@@ -1,2 +1,4 @@ ++#![allow(unknown_lints)] ++#![allow(linker_messages)] + // Required for the links attribute + fn main() {} diff --git a/scripts/build/cargo-config.ts b/scripts/build/cargo-config.ts index cf70229d414c..aa155c410781 100644 --- a/scripts/build/cargo-config.ts +++ b/scripts/build/cargo-config.ts @@ -84,7 +84,17 @@ export function generateCargoConfig(cfg: Config): string { lines.push(""); lines.push(`[target.${triple}]${triple === host ? " # host" : ""}`); lines.push(`linker = ${JSON.stringify(linkerFor(triple, cfg))}`); - lines.push(`rustflags = ["-C", "link-arg=-fuse-ld=lld"]`); + // -Qunused-arguments: rustc passes link args that don't apply to every + // artifact kind (e.g. `-no-pie` when it links the lol_html_c_api cdylib), + // and its `linker_messages` lint re-surfaces clang's "argument unused + // during compilation" complaint as a warning on every build-rust job. + // These config rustflags reach the cargo invocations that don't set + // CARGO_ENCODED_RUSTFLAGS themselves (the lolhtml dep edge, plain + // `cargo build`/`cargo check`, rust-analyzer); real linker errors still + // fail the link. + lines.push( + `rustflags = ["-C", "link-arg=-fuse-ld=lld", "-C", "link-arg=-Qunused-arguments", "-A", "linker_messages"]`, + ); } lines.push(""); diff --git a/scripts/build/deps/lolhtml.ts b/scripts/build/deps/lolhtml.ts index 758a6a4c4941..d2a95f0d444c 100644 --- a/scripts/build/deps/lolhtml.ts +++ b/scripts/build/deps/lolhtml.ts @@ -31,7 +31,7 @@ export const lolhtml: Dependency = { // Drop staticlib/cdylib outputs — we only need the rlib (saves a wasted // link step and avoids `-Clinker-plugin-lto` tripping over BFD ld). - patches: ["patches/lolhtml/0001-rlib-only.patch"], + patches: ["patches/lolhtml/0001-rlib-only.patch", "patches/lolhtml/0002-quiet-build-script-linker-warning.patch"], // No separate build — compiled as part of the workspace cargo build via // `bun_lolhtml_sys`'s path dep on `vendor/lolhtml/c-api`. diff --git a/scripts/build/rust-lto-fix-cli.ts b/scripts/build/rust-lto-fix-cli.ts index eff8de65da54..e41da251eb65 100644 --- a/scripts/build/rust-lto-fix-cli.ts +++ b/scripts/build/rust-lto-fix-cli.ts @@ -140,7 +140,14 @@ function main(): void { run(join(llvmBin, "llvm-as"), [stubLl, "-o", stubBc]); const merged = join(tmp, "merged.bc"); - run(join(llvmBin, "llvm-link"), [...bitcode, stubBc, "-o", merged]); + // The stub goes FIRST: llvm-link uses the first module as the link + // destination, and IRMover silently inherits the data layout / target + // triple when the destination has none. With the stub last it is a + // *source* module whose empty layout differs from the destination's, + // and every build-bun job warns "Linking two modules of different data + // layouts". Same merged output either way (verified: the module flag and + // the real layout both survive). + run(join(llvmBin, "llvm-link"), [stubBc, ...bitcode, "-o", merged]); run(join(llvmBin, "opt"), ["--module-summary", merged, "-o", outObj]); } finally { rmSync(tmp, { recursive: true, force: true }); diff --git a/scripts/build/rust.ts b/scripts/build/rust.ts index 698ae952ba20..e93b536b940b 100644 --- a/scripts/build/rust.ts +++ b/scripts/build/rust.ts @@ -542,6 +542,19 @@ export function emitRust(n: Ninja, cfg: Config, inputs: RustBuildInputs): string // and the `bun_bin` staticlib has no link step, so it's normally dead — but // if a target cdylib ever appears it'd fail with "could not open '-fuse-ld=lld'". if (!cfg.windows) rustflags.push(`-Clink-arg=-fuse-ld=lld`); + // Keep the clang driver quiet about link args that don't apply to a given + // artifact kind: rustc adds `-no-pie` under `-Crelocation-model=static`, + // which is meaningless when it links a target cdylib (lol_html_c_api), and + // rustc's `linker_messages` lint then re-surfaces clang's + // "argument unused during compilation: '-no-pie'" as a warning on every + // build-rust job. Same approach as the WebKit configure + // (`-Qunused-arguments`); real linker errors still fail the link. + if (!cfg.windows) rustflags.push(`-Clink-arg=-Qunused-arguments`); + // And allow the lint itself: CI treats new warnings as failures, and the + // lint forwards anything any platform's linker prints to stderr - the + // -Qunused-arguments above only covers the clang-driver case. Real linker + // errors are unaffected (they fail the link, not the lint). + rustflags.push(`-Alinker_messages`); if (cfg.crossLangLto) { // Cross-language LTO: emit LLVM bitcode (not machine code) into the .a // so the final lld LTO link sees through Rust↔C++ call edges. The shape diff --git a/src/http/HTTPContext.rs b/src/http/HTTPContext.rs index f62929bf2bc1..8e4cc7cc0929 100644 --- a/src/http/HTTPContext.rs +++ b/src/http/HTTPContext.rs @@ -1342,16 +1342,34 @@ impl Handler { // 4. Dead socket: it is already marked as dead let tagged = HTTPContext::::get_tagged(ptr); HTTPContext::::mark_tagged_socket_as_dead(socket, tagged); - socket.close(uws::CloseKind::Failure); - + // An idle (pooled keep-alive) socket's FIN is answered with a graceful + // close so well-behaved servers don't observe ECONNRESET for + // connections we were simply done with, and so is a FIN that + // terminates an EOF-delimited response (the request was fully sent; + // this FIN *is* the end of the response). A FIN that cuts the request + // short while its body is still being sent is answered with a reset + // instead: a graceful close would queue our FIN behind the + // not-yet-delivered body bytes (a server that rejects an upload early + // stops reading them), so the peer would never observe the connection + // closing and it would leak. if let Some(client) = tagged.client_mut() { + if client.has_unsent_request_body() { + socket.close(uws::CloseKind::Failure); + } else { + socket.close(uws::CloseKind::Normal); + } client.on_close::(socket); return; } if let Some(session) = tagged.session_mut() { + // An HTTP/2 session's streams may still be uploading; the same + // undeliverable-bytes reasoning applies, and this matches the + // pre-existing behaviour for this branch. + socket.close(uws::CloseKind::Failure); session.on_close(bun_core::err!("ConnectionClosed")); return; } + socket.close(uws::CloseKind::Normal); } } diff --git a/src/http/ProxyTunnel.rs b/src/http/ProxyTunnel.rs index 5f8a09fc415a..faf9aa885cc5 100644 --- a/src/http/ProxyTunnel.rs +++ b/src/http/ProxyTunnel.rs @@ -625,6 +625,10 @@ impl ProxyTunnel { on_handshake, on_close, write: write_encrypted, + // fetch's proxy tunnel surfaces no 'session'/'keylog' events; + // opting out keeps its SSL off the parked queues entirely. + on_session: None, + on_keylog: None, ctx: this.as_erased_ptr().as_ptr(), }, ) { diff --git a/src/http/lib.rs b/src/http/lib.rs index 82ba943c9e12..7c1f61ab0ee9 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -1319,6 +1319,22 @@ pub(crate) fn get_cert_error_from_no(error_no: i32) -> bun_core::Error { // These helpers centralize the unsafe deref of the `Option>` // fields so the state-machine bodies stay readable. impl<'a> HTTPClient<'a> { + #[inline] + /// Whether closing this socket gracefully would queue our FIN behind + /// request-body bytes that have not yet been handed to the kernel - the + /// case where the peer (which may have stopped reading the body) would + /// never observe the connection closing. + pub fn has_unsent_request_body(&self) -> bool { + if self.state.request_stage == RequestStage::Done { + return false; + } + if self.flags.is_streaming_request_body { + // More body chunks may still be produced by JS. + return true; + } + !self.request_body().is_empty() + } + #[inline] fn request_body(&self) -> &[u8] { // `request_body` is a `RawSlice` into `original_request_body` (sibling diff --git a/src/http/ssl_config.rs b/src/http/ssl_config.rs index d351b38baa40..2e69e11ae3d9 100644 --- a/src/http/ssl_config.rs +++ b/src/http/ssl_config.rs @@ -34,6 +34,9 @@ pub struct SSLConfig { pub ca: CStrSlice, pub secure_options: u32, + /// Minimum/maximum TLS protocol version (TLS1_VERSION..TLS1_3_VERSION); 0 = unset/default. + pub ssl_min_version: i32, + pub ssl_max_version: i32, pub request_cert: i32, pub reject_unauthorized: i32, pub ssl_ciphers: CStrPtr, @@ -107,6 +110,8 @@ impl SSLConfig { cert: None, ca: None, secure_options: 0, + ssl_min_version: 0, + ssl_max_version: 0, request_cert: 0, reject_unauthorized: 0, ssl_ciphers: core::ptr::null(), @@ -204,6 +209,8 @@ impl SSLConfig { } ctx_opts.request_cert = self.request_cert; ctx_opts.reject_unauthorized = self.reject_unauthorized; + ctx_opts.ssl_min_version = self.ssl_min_version; + ctx_opts.ssl_max_version = self.ssl_max_version; ctx_opts } @@ -268,6 +275,12 @@ impl SSLConfig { if self.secure_options != other.secure_options { return false; } + if self.ssl_min_version != other.ssl_min_version { + return false; + } + if self.ssl_max_version != other.ssl_max_version { + return false; + } if self.request_cert != other.request_cert { return false; } @@ -332,6 +345,8 @@ impl SSLConfig { hash_slice!(cert); hash_slice!(ca); hasher.update(&self.secure_options.to_ne_bytes()); + hasher.update(&self.ssl_min_version.to_ne_bytes()); + hasher.update(&self.ssl_max_version.to_ne_bytes()); hasher.update(&self.request_cert.to_ne_bytes()); hasher.update(&self.reject_unauthorized.to_ne_bytes()); hash_cstr!(ssl_ciphers); @@ -421,6 +436,8 @@ impl Clone for SSLConfig { cert: clone_strings(&self.cert), ca: clone_strings(&self.ca), secure_options: self.secure_options, + ssl_min_version: self.ssl_min_version, + ssl_max_version: self.ssl_max_version, request_cert: self.request_cert, reject_unauthorized: self.reject_unauthorized, ssl_ciphers: clone_string(self.ssl_ciphers), diff --git a/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs b/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs index 43a080459734..34264cef7e1d 100644 --- a/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs +++ b/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs @@ -215,6 +215,10 @@ impl WebSocketProxyTunnel { on_handshake: Self::on_handshake, on_close: Self::on_close, write: Self::write_encrypted, + // No JS TLSSocket fronts the tunnel; opting out keeps the + // SSL off the parked session/keylog queues entirely. + on_session: None, + on_keylog: None, }, ) .map_err(|_| bun_core::err!("InvalidOptions"))?; diff --git a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs index a3f837982049..37ade63caadc 100644 --- a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs +++ b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs @@ -669,11 +669,12 @@ impl HTTPClient { // SAFETY: forwards `this` with root provenance; no `&mut Self` is live. unsafe { Self::dispatch_abrupt_close(this.as_ptr(), code) }; - if SSL { - tcp.close(uws::CloseCode::Normal); - } else { - tcp.close(uws::CloseCode::Failure); - } + // A failed upgrade (bad status line, mismatched subprotocol, invalid + // headers, ...) is an application-level rejection of a healthy TCP + // connection — close it gracefully (FIN) like Node's ws client does. + // A Failure close arms SO_LINGER{1,0} and sends an RST, which the + // server observes as ECONNRESET on a connection it served correctly. + tcp.close(uws::CloseCode::Normal); } /// # Safety diff --git a/src/js/internal/shared.ts b/src/js/internal/shared.ts index defd073927c0..b00bf9667056 100644 --- a/src/js/internal/shared.ts +++ b/src/js/internal/shared.ts @@ -151,6 +151,121 @@ function getLazy(initializer: () => T) { }; } +// ─── Node-style performance-entry observation ──────────────────────────────── +// For entry types the native (WebCore) PerformanceObserver does not implement +// ('net', 'dns', ...). Mirrors lib/internal/perf/observe.js: producers check +// hasObserver() before doing any work, startPerf() stashes a context on the +// producing object, and stopPerf() builds a plain entry and dispatches it to +// the registered observers on a fresh tick. +// https://github.com/nodejs/node/blob/v25.2.1/lib/internal/perf/observe.js + +const observerCounts = new Map(); +const kObservers = new Set(); + +/** Entry types routed through this JS-side registry instead of the native observer. */ +const kNodeEntryTypes = new Set(["net", "dns"]); + +function hasObserver(type) { + return (observerCounts.get(type) ?? 0) > 0; +} + +function startPerf(target, key, context) { + context.startTime = performance.now(); + target[key] = context; +} + +function stopPerf(target, key, context) { + const ctx = target[key]; + if (!ctx) { + return; + } + target[key] = undefined; + const startTime = ctx.startTime; + const entry = { + name: ctx.name, + entryType: ctx.type, + startTime, + duration: performance.now() - startTime, + detail: context?.detail !== undefined ? context.detail : ctx.detail, + }; + for (const observer of kObservers) { + observer.bufferEntry(entry); + } +} + +/** + * One registered observer of node-only entry types. The PerformanceObserver + * wrapper in node:perf_hooks owns one of these when it observes such a type. + */ +class NodeEntryObserver { + callback; + owner; + types = new Set(); + buffer = []; + scheduled = false; + + constructor(callback, owner) { + this.callback = callback; + this.owner = owner; + } + + observe(types) { + for (const type of this.types) { + observerCounts.set(type, (observerCounts.get(type) ?? 1) - 1); + } + this.types = new Set(types); + for (const type of this.types) { + observerCounts.set(type, (observerCounts.get(type) ?? 0) + 1); + } + kObservers.add(this); + } + + disconnect() { + for (const type of this.types) { + observerCounts.set(type, (observerCounts.get(type) ?? 1) - 1); + } + this.types.clear(); + this.buffer = []; + kObservers.delete(this); + } + + bufferEntry(entry) { + if (!this.types.has(entry.entryType)) { + return; + } + this.buffer.push(entry); + if (!this.scheduled) { + this.scheduled = true; + setImmediate(() => { + this.scheduled = false; + const entries = this.buffer; + if (entries.length === 0) { + return; + } + this.buffer = []; + this.callback.$call(undefined, makeNodeEntryList(entries), this.owner); + }); + } + } +} + +function makeNodeEntryList(entries) { + // Node's PerformanceObserverEntryList hands entries out in chronological + // (startTime) order and getEntriesByName takes an optional type filter. + const sorted = entries.slice().sort((a, b) => a.startTime - b.startTime); + return { + getEntries() { + return sorted.slice(); + }, + getEntriesByType(type) { + return sorted.filter(entry => entry.entryType === type); + }, + getEntriesByName(name, type) { + return sorted.filter(entry => entry.name === name && (type === undefined || entry.entryType === type)); + }, + }; +} + // export default { @@ -165,6 +280,12 @@ export default { once, getLazy, + hasObserver, + startPerf, + stopPerf, + kNodeEntryTypes, + NodeEntryObserver, + kHandle: Symbol("kHandle"), kAutoDestroyed: Symbol("kAutoDestroyed"), kResistStopPropagation: Symbol("kResistStopPropagation"), diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 796bc07163df..17fc86895dea 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -3062,6 +3062,17 @@ class ServerHttp2Session extends Http2Session { this.destroy(); } #onError(error: Error) { + if (this.listenerCount("error") === 0 && (error as NodeJS.ErrnoException)?.code === "ECONNRESET") { + // An unobserved transport teardown (the peer dropped a connection + // nobody is listening to anymore): destroy quietly - the destroy still + // errors any remaining streams - instead of re-emitting on a session + // with no 'error' listener and crashing the process. (The server + // attaches sessionOnError at accept time, so this branch only matters + // for standalone sessions.) Anything that is not teardown noise keeps + // Node's EventEmitter contract and surfaces when unobserved. + this.destroy(); + return; + } this.destroy(error); } #onTimeout() { @@ -3660,6 +3671,15 @@ class ClientHttp2Session extends Http2Session { this.destroy(); return; } + if (this.listenerCount("error") === 0 && (error as NodeJS.ErrnoException)?.code === "ECONNRESET") { + // A transport teardown on a session nobody observes (an idle pooled + // connection dropped by the peer): shut down quietly - the destroy + // still errors any remaining streams. Anything else (handshake + // failure, ECONNREFUSED, ...) keeps Node's EventEmitter contract and + // surfaces when unobserved. + this.destroy(); + return; + } this.destroy(error); } #onTimeout() { diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 0f31fd0358b1..a28ca4bc4646 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -26,7 +26,15 @@ const EventEmitter = require("node:events"); let dns: typeof import("node:dns"); const normalizedArgsSymbol = Symbol("normalizedArgs"); -const { ExceptionWithHostPort, ConnResetException, NodeAggregateError, ErrnoException } = require("internal/shared"); +const { + ExceptionWithHostPort, + ConnResetException, + NodeAggregateError, + ErrnoException, + hasObserver, + startPerf, + stopPerf, +} = require("internal/shared"); import type { Socket, SocketHandler, SocketListener } from "bun"; import type { Server as NetServer, Socket as NetSocket, ServerOpts } from "node:net"; import type { TLSSocket } from "node:tls"; @@ -35,6 +43,7 @@ const { validateFunction, validateNumber, validateAbortSignal, validatePort, val const { isIPv4, isIPv6, isIP } = require("internal/net/isIP"); const ArrayPrototypeIncludes = Array.prototype.includes; +const ArrayPrototypeJoin = Array.prototype.join; const ArrayPrototypePush = Array.prototype.push; const MathMax = Math.max; @@ -45,6 +54,55 @@ const getDefaultAutoSelectFamily = $zig("node_net_binding.zig", "getDefaultAutoS const setDefaultAutoSelectFamily = $zig("node_net_binding.zig", "setDefaultAutoSelectFamily"); const getDefaultAutoSelectFamilyAttemptTimeout = $zig("node_net_binding.zig", "getDefaultAutoSelectFamilyAttemptTimeout"); // prettier-ignore const setDefaultAutoSelectFamilyAttemptTimeout = $zig("node_net_binding.zig", "setDefaultAutoSelectFamilyAttemptTimeout"); // prettier-ignore + +/** + * `--tls-keylog=`: every TLS socket appends its NSS key-log lines here, + * the way Node's CLI option store seeds an implicit 'keylog' listener. + */ +let tlsKeylogPath: string | undefined; +let tlsKeylogWarned = false; +function appendTlsKeylog(line: Buffer) { + if (!tlsKeylogWarned) { + tlsKeylogWarned = true; + process.emitWarning( + "Using --tls-keylog makes TLS connections insecure by writing secret key material to file " + tlsKeylogPath, + ); + } + try { + // The keylog contains TLS master secrets; create it owner-readable only. + // The mode is only applied when the file is created. + require("node:fs").appendFileSync(tlsKeylogPath, line, { mode: 0o600 }); + } catch { + // Node ignores keylog write failures. + } +} + +// Node seeds the family-autoselection defaults from its CLI option store. +// The equivalent flags reach us through process.execArgv; apply them once at +// module load so getDefaultAutoSelectFamily*() reflect the command line. +{ + const execArgv = process.execArgv; + for (let i = 0; i < execArgv.length; i++) { + const arg = execArgv[i]; + if (arg === "--no-network-family-autoselection" || arg === "--no-enable-network-family-autoselection") { + setDefaultAutoSelectFamily(false); + } else if (arg === "--network-family-autoselection" || arg === "--enable-network-family-autoselection") { + setDefaultAutoSelectFamily(true); + } else if (arg.startsWith("--network-family-autoselection-attempt-timeout=")) { + const value = Number(arg.slice(arg.indexOf("=") + 1)); + // The setter validates >= 1 and clamps < 10 to 10, like Node's; ignore + // degenerate CLI values rather than throwing at module load. + if (Number.isFinite(value) && value >= 1) setDefaultAutoSelectFamilyAttemptTimeout(value); + } else if (arg === "--network-family-autoselection-attempt-timeout" && i + 1 < execArgv.length) { + const value = Number(execArgv[i + 1]); + if (Number.isFinite(value) && value >= 1) setDefaultAutoSelectFamilyAttemptTimeout(value); + } else if (arg.startsWith("--tls-keylog=")) { + tlsKeylogPath = arg.slice("--tls-keylog=".length); + } else if (arg === "--tls-keylog" && i + 1 < execArgv.length) { + tlsKeylogPath = execArgv[i + 1]; + } + } +} const SocketAddress = $zig("node_net_binding.zig", "SocketAddress"); const BlockList = $zig("node_net_binding.zig", "BlockList"); const newDetachedSocket = $newZigFunction("node_net_binding.zig", "newDetachedSocket", 1); @@ -62,10 +120,16 @@ const owner_symbol = Symbol("owner_symbol"); const kServerSocket = Symbol("kServerSocket"); const kBytesWritten = Symbol("kBytesWritten"); const bunTLSConnectOptions = Symbol.for("::buntlsconnectoptions::"); +// tls.Server exposes its native SecureContext constructor through this key so +// the SNI dispatch (below) can recognize a raw native context the way Node's +// `context.context || context` unwrap does - without net.ts needing its own +// binding to the constructor. +const kNativeSecureContextCtor = Symbol.for("::buntlsnativesecurecontextctor::"); const kReinitializeHandle = Symbol("kReinitializeHandle"); const kRealListen = Symbol("kRealListen"); const kSetNoDelay = Symbol("kSetNoDelay"); +const kSetTOS = Symbol("kSetTOS"); const kSetKeepAlive = Symbol("kSetKeepAlive"); const kSetKeepAliveInitialDelay = Symbol("kSetKeepAliveInitialDelay"); const kConnectOptions = Symbol("connect-options"); @@ -77,11 +141,24 @@ const ksocket = Symbol("ksocket"); const khandlers = Symbol("khandlers"); const kclosed = Symbol("closed"); const kended = Symbol("ended"); +const kpendingSession = Symbol("pendingSession"); +const kSNIError = Symbol("kSNIError"); +const kALPNError = Symbol("kALPNError"); +const kPerfHooksNetConnectContext = Symbol("kPerfHooksNetConnectContext"); +const khandshakeTimer = Symbol("khandshakeTimer"); +const kUserUnrefed = Symbol("kUserUnrefed"); +// Set when pause() dropped the handle's hold on the loop, so the read paths +// only restore a hold they actually removed - re-refing a handle that never +// held the loop (a wrapped duplex with no fd) would pin the process. +const kPausedUnref = Symbol("kPausedUnref"); const kwriteCallback = Symbol("writeCallback"); const kSocketClass = Symbol("kSocketClass"); function endNT(socket, callback, err) { - socket.$end(); + // Node's _final half-closes the writable side (sends FIN) and leaves the + // readable side open; the Duplex's allowHalfOpen drives the eventual destroy. + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/net.js#L500 + socket.shutdown(); callback(err); } function emitCloseNT(self, hasError) { @@ -142,6 +219,39 @@ function onConnectEnd() { } } +/** + * Build the Error for a handshake that failed before completing. A fatal SSL + * protocol error (wrong version number, bad record, ...) carries the OpenSSL + * error string in `verifyError.reason`; everything else is the peer + * disconnecting mid-handshake, which Node reports as ECONNRESET. + */ +function tlsHandshakeError(verifyError) { + if (verifyError && verifyError.code && verifyError.code !== "ECONNRESET") { + const reason = verifyError.reason || verifyError.message || "TLS handshake failed"; + const err = new Error(reason) as Error & { + code?: string; + library?: string; + function?: string; + reason?: string; + }; + // A fatal SSL-library error carries the full OpenSSL error string + // ("error:0a00042e:SSL routines:OPENSSL_internal:TLSV1_ALERT_PROTOCOL_VERSION"). + // Decompose it into Node's library/function/reason properties and the + // ERR_SSL_ code the way ThrowCryptoError does. + const match = /^error:[0-9a-f]+:SSL routines:([^:]*):(.+)$/.exec(reason); + if (match) { + err.library = "SSL routines"; + err.function = match[1]; + err.reason = match[2]; + err.code = `ERR_SSL_${match[2]}`; + } else { + err.code = verifyError.code; + } + return err; + } + return new ConnResetException("socket hang up"); +} + const SocketHandlers: SocketHandler = { close(socket, err) { const self = socket.data; @@ -186,6 +296,25 @@ const SocketHandlers: SocketHandler = { // we just reuse the same code but we can push null or enqueue right away SocketEmitEndNT(self); }, + // A new resumable TLS session arrived (the peer's NewSessionTicket was just + // processed). Mirrors Node's onnewsessionclient: emit once the handshake has + // been verified, otherwise park it and emit from the handshake handler. + session(socket, session) { + const self = socket.data; + if (!self) return; + if (self._secureEstablished) { + self.emit("session", session); + } else { + self[kpendingSession] = session; + } + }, + keylog(socket, line) { + const self = socket.data; + if (!self) return; + self.emit("keylog", line); + if (tlsKeylogPath !== undefined) appendTlsKeylog(line); + self.server?.emit?.("keylog", line, self); + }, error(socket, error) { const self = socket.data; if (!self) return; @@ -227,6 +356,12 @@ const SocketHandlers: SocketHandler = { socket.setKeepAlive(true, self[kSetKeepAliveInitialDelay]); } + // A TOS value set before the connection existed (setTypeOfService before + // connect) is applied to the live handle now. + if (self[kSetTOS] !== undefined && self._handle?.setTypeOfService) { + self._handle.setTypeOfService(self[kSetTOS]); + } + if (!self[kupgraded]) { self[kBytesWritten] = socket.bytesWritten; // this is not actually emitted on nodejs when socket used on the connection @@ -244,10 +379,34 @@ const SocketHandlers: SocketHandler = { // will be handled in onConnectEnd return; } + // The second argument is "authorized" (handshake + verification + + // hostname), matching the public Bun.connect handshake callback. node:tls + // decides what to do with verification results in JS via the + // rejectUnauthorized / checkServerIdentity handling below, so a + // verification-class result (an X509 code such as + // UNABLE_TO_VERIFY_LEAF_SIGNATURE, or the native hostname verdict) still + // means the TLS session itself was established. Only a fatal TLS protocol + // failure tears the socket down here: those arrive as EPROTO carrying the + // OpenSSL "error:...:SSL routines:..." reason (or an already decomposed + // ERR_SSL_* / ERR_OSSL_* code). + const isProtocolFailure = + !success && + verifyError?.code != null && + (verifyError.code === "EPROTO" || /^ERR_(SSL|OSSL)_/.test(verifyError.code)); + if (isProtocolFailure) { + // Surface the OpenSSL reason instead of letting the close path report a + // generic disconnect. + self.destroy(tlsHandshakeError(verifyError)); + return; + } self._securePending = false; self.secureConnecting = false; - self._secureEstablished = !!success; + // ECONNRESET and protocol-level failures returned above, so reaching here + // means the TLS session itself was established - even when `success` + // (authorized) is false purely because of the native hostname verdict, + // which arrives with no error object. + self._secureEstablished = true; self.emit("secure", self); self.alpnProtocol = socket.alpnProtocol; @@ -275,6 +434,15 @@ const SocketHandlers: SocketHandler = { } self.emit("secureConnect", verifyError); self.removeListener("end", onConnectEnd); + // For TLS 1.2 the NewSessionTicket is part of the handshake, so the + // new-session callback fired before the handshake completed and the + // session was parked; deliver it now that 'secureConnect' has been + // emitted, the way Node flushes its kPendingSession. + const pendingSession = self[kpendingSession]; + if (pendingSession) { + self[kpendingSession] = null; + self.emit("session", pendingSession); + } }, timeout(socket) { const self = socket.data; @@ -286,17 +454,138 @@ const SocketHandlers: SocketHandler = { } as const; function SocketEmitEndNT(self, _err?) { + // A read error delivered with the close (e.g. a received RST surfacing as + // ECONNRESET) is not a clean EOF — Node destroys the socket with the error + // ("read ECONNRESET") instead of emitting a graceful 'end'. Guard on + // !destroyed so an already-torn-down socket isn't re-destroyed, and on an + // 'error' listener so callers that opted into error handling get Node's + // behavior while those that did not keep the previous silent EOF (a server + // hard-closing after a clean response would otherwise surface here as an + // unhandled error across the proxy/http2/fetch suites under ASAN/baseline + // timing). + // A reset that lands after the exchange already finished in BOTH + // directions (clean EOF delivered and nothing left being written) is + // teardown noise - a peer hard-closing once the exchange completed - not + // data loss; Node would have destroyed the socket on 'end' for these + // non-keepalive flows before the RST could ever be observed. Surfacing it + // produced unhandled errors between tests across the fetch/http2 suites on + // Windows, where loopback RSTs at teardown are routine. A reset while the + // socket is still writing (the peer aborted mid-transfer) is real and is + // surfaced (test-net-error-twice). + // writableFinished (everything actually flushed) - NOT writableEnded (end() + // merely called): a peer reset while queued data is still unflushed is the + // peer aborting mid-transfer and must surface (test-net-error-twice). + const teardownNoise = self[kended] && self.writableFinished; + if (_err && !self.destroyed && !teardownNoise && self.listenerCount("error") > 0) { + // The consumer can detach its 'error' listener between this close + // callback and destroy()'s deferred 'error' emission (a request that + // finished just as the reset arrived); a last-resort no-op listener keeps + // that race from surfacing as an uncaught exception - the no-listener + // case is already a documented silent close. + self.once("error", () => {}); + if (_err.code === undefined && typeof _err.errno === "number" && _err.errno !== 0) { + // A codeless close error that still carries the errno (Windows IOCP + // delivers some this way): derive the proper code from it, like Node's + // errnoException(nread, 'read'). Raw WSA values (-10054, ...) that the + // errno table cannot name fall through to the reset shape below instead + // of surfacing "Unknown system error N". + const er = new ErrnoException(_err.errno, "read") as Error & { code?: string }; + if (typeof er.code === "string" && /^E[A-Z0-9]+$/.test(er.code)) { + self.destroy(er); + return; + } + } + if (_err.code === undefined || _err.code === "ECONNRESET") { + // Shape a reset (or a fully bare close error) like Node's + // errnoException(UV_ECONNRESET, 'read'). + const er = new ConnResetException("read ECONNRESET") as Error & { + code: string; + errno?: number; + syscall?: string; + }; + er.errno = _err.errno ?? (process.platform === "win32" ? -4077 : process.platform === "linux" ? -104 : -54); + er.syscall = "read"; + self.destroy(er); + } else { + // Any other coded error (ETIMEDOUT, EPIPE, ...) keeps its identity. + self.destroy(_err); + } + return; + } if (!self[kended]) { if (!self.allowHalfOpen) { self.write = writeAfterFIN; } self[kended] = true; self.push(null); + } else if (_err && !self.destroyed) { + // An error excluded from the synthesis above (teardown noise, or no + // listener attached): nothing more is coming, but the socket still has to + // finish its lifecycle - close it quietly instead of leaving it open with + // no further events. + self.destroy(); + } + // A write that was waiting on the native drain can never complete once the + // socket is gone - fail it so 'finish'/destroy are not stuck behind it. + const pendingWrite = self[kwriteCallback]; + if (pendingWrite && (self.destroyed || _err)) { + self[kwriteCallback] = null; + pendingWrite(_err ?? $ERR_SOCKET_CLOSED()); + } +} + +// --- SNICallback dispatch helpers (hoisted: no per-handshake closures) --- + +// Normalizes non-Error rejections (cb(true), cb("reason"), throw true): the +// native dispatch recognizes Error returns as the abort signal, and a literal +// `true` would collide with the handshake-suspension sentinel. +function toSNIError(err) { + return err instanceof Error ? err : Object.assign(new Error("SNI callback error"), { reason: err }); +} + +// Applies one SNICallback resolution to the dispatch state. Node assigns +// `sni_context = context.context || context`: both the SecureContext wrapper +// and a raw native context are accepted, null/undefined falls through to the +// default context, and anything else is an invalid SNI context that drops the +// connection before the handshake completes. +function consumeSNIResult(state, err, context) { + if (err) { + state.failed = toSNIError(err); + return; + } + if (context == null) return; + if (typeof context === "object" && context.context) { + state.selected = context.context; + } else if (state.server?.[kNativeSecureContextCtor] && context instanceof state.server[kNativeSecureContextCtor]) { + state.selected = context; + } else { + state.failed = new Error("Invalid SNI context"); + } +} + +// Stash per-connection (socketHandle.data is this connection's TLSSocket): +// with concurrent handshakes a per-server stash could hand one connection's +// error to another's failure handler. The server is the legacy fallback when +// no handle was available at dispatch time. +function stashSNIError(state) { + const target = state.socketHandle?.data ?? state.server; + if (target) target[kSNIError] = state.failed; +} + +// The user SNICallback's completion callback (bound to the per-handshake +// state). Synchronous resolutions are carried by serverName's return value; +// asynchronous ones complete the parked handshake via resumeSNI. +function onSNIResolution(state, err, context) { + if (state.settled) return; // an SNICallback must resolve exactly once + state.settled = true; + consumeSNIResult(state, err, context); + if (!state.suspended) return; // synchronous resolution - serverName's return carries it + if (state.failed !== undefined) { + stashSNIError(state); + state.socketHandle?.resumeSNI(undefined, true); + } else { + state.socketHandle?.resumeSNI(state.selected, false); } - // TODO: check how the best way to handle this - // if (err) { - // self.destroy(err); - // } } const ServerHandlers: SocketHandler = { @@ -310,6 +599,97 @@ const ServerHandlers: SocketHandler = { socket.pause(); } }, + keylog(socket, line) { + const { data: self } = socket; + if (!self) return; + self.emit("keylog", line); + if (tlsKeylogPath !== undefined) appendTlsKeylog(line); + self.server?.emit?.("keylog", line, self); + }, + alpnCallback(socket, servername, protocolsWire) { + // Returns false when this server has no ALPNCallback (the native side + // falls through to the static ALPNProtocols list), the selected protocol + // string, or undefined to refuse the connection - Node's contract. + const self = socket.data; + const server = self?.server ?? self; + const cb = server?._ALPNCallback; + if (typeof cb !== "function") return false; + const wire = Buffer.isBuffer(protocolsWire) ? protocolsWire : Buffer.from(protocolsWire); + const protocols = []; + for (let i = 0; i + 1 <= wire.length; ) { + const n = wire[i]; + protocols.push(wire.toString("latin1", i + 1, i + 1 + n)); + i += 1 + n; + } + let result; + try { + result = cb.$call(self, { servername, protocols }); + } catch (err) { + // Node: a throwing ALPNCallback refuses the connection (fatal + // no_application_protocol alert) and surfaces the thrown error as + // 'tlsClientError'. + if (self) self[kALPNError] = err; + return undefined; + } + if (result !== undefined && !ArrayPrototypeIncludes.$call(protocols, result)) { + // Node: the callback selected a protocol the client did not offer - + // refuse the connection and report ERR_TLS_ALPN_CALLBACK_INVALID_RESULT + // through 'tlsClientError'. + const err = new TypeError( + `ALPN callback returned a value (${result}) that did not match any of the client's offered protocols (${ArrayPrototypeJoin.$call(protocols, ", ")})`, + ) as TypeError & { code?: string }; + err.code = "ERR_TLS_ALPN_CALLBACK_INVALID_RESULT"; + if (self) self[kALPNError] = err; + return undefined; + } + return result; + }, + serverName(server, servername, socketHandle) { + // Returns what the SNICallback selects for this handshake: + // - the native SecureContext (synchronous selection) + // - undefined to fall through to the default context + // - an Error to abort the handshake (stashed for tlsClientError) + // - `true` to SUSPEND the handshake: the callback is asynchronous, and + // `socketHandle.resumeSNI(ctx, isError)` completes it when the + // callback finally resolves. The native side parks the connection + // (BoringSSL select-certificate retry) until then. + // Nothing is cached - the callback runs per-connection the way Node's + // does. The native dispatch passes the listener's `data` (the owning + // tls.Server) and the accepted connection's handle. + const cb = server?._SNICallback; + if (typeof cb !== "function" || !servername) return undefined; + const state = { + server, + socketHandle, + selected: undefined, + failed: undefined, + settled: false, + suspended: false, + }; + try { + cb.$call(server, servername, onSNIResolution.bind(null, state)); + } catch (err) { + state.settled = true; + state.failed = toSNIError(err); + } + if (!state.settled) { + // The SNICallback did not resolve synchronously. Without a connection + // handle the suspension could never be resumed - keep the legacy + // fall-through-to-default behavior in that (unexpected) case. + if (!socketHandle) return undefined; + state.suspended = true; + return true; + } + if (state.failed !== undefined) { + // Stash the error so the handshake-failure handler emits + // 'tlsClientError' with it, and return it - the native dispatch + // detects an Error return and aborts the handshake, dropping the + // connection without a TLS alert the way Node does. + stashSNIError(state); + return state.failed; + } + return state.selected; + }, close(socket, err) { $debug("Bun.Server close"); const data = this.data; @@ -332,77 +712,64 @@ const ServerHandlers: SocketHandler = { open(socket) { $debug("Bun.Server open"); const self = socket.data as any as NetServer; - socket[kServerSocket] = self._handle; - const options = self[bunSocketServerOptions]; - const { pauseOnConnect, connectionListener, [kSocketClass]: SClass, requestCert, rejectUnauthorized } = options; - const _socket = new SClass({}) as NetSocket | TLSSocket; - _socket.isServer = true; - _socket._requestCert = requestCert; - // The raw options object only has rejectUnauthorized when the user passed it explicitly; - // fall back to the server's normalized value (defaults to true for tls.Server). - _socket._rejectUnauthorized = rejectUnauthorized ?? self._rejectUnauthorized; - - _socket[kAttach](this.localPort, socket); - - if (self.blockList) { - const addressType = isIP(socket.remoteAddress); - if (addressType && self.blockList.check(socket.remoteAddress, `ipv${addressType}`)) { - const data = { - localAddress: _socket.localAddress, - localPort: _socket.localPort || this.localPort, - localFamily: _socket.localFamily, - remoteAddress: _socket.remoteAddress, - remotePort: _socket.remotePort, - remoteFamily: _socket.remoteFamily || "IPv4", - }; - socket.end(); - self.emit("drop", data); - return; - } - } - if (self.maxConnections != null && self._connections >= self.maxConnections) { - const data = { - localAddress: _socket.localAddress, - localPort: _socket.localPort || this.localPort, - localFamily: _socket.localFamily, - remoteAddress: _socket.remoteAddress, - remotePort: _socket.remotePort, - remoteFamily: _socket.remoteFamily || "IPv4", - }; - - socket.end(); - self.emit("drop", data); - return; - } - - const bunTLS = _socket[bunTlsSymbol]; - const isTLS = typeof bunTLS === "function"; - - self._connections++; - _socket.server = self; - - if (pauseOnConnect) { - _socket.pause(); - } - - if (typeof connectionListener === "function") { - this.pauseOnConnect = pauseOnConnect; - if (!isTLS) { - self.prependOnceListener("connection", connectionListener); - } - } - self.emit("connection", _socket); - // the duplex implementation start paused, so we resume when pauseOnConnect is falsy - if (!pauseOnConnect && !isTLS) { - _socket.resume(); + if (!self) return; + // Dispatch through the listener handle's onconnection hook so user code + // (and node:cluster RoundRobinHandle) can intercept accepted sockets the + // same way Node.js exposes TCP/Pipe wrap onconnection. + // For a standalone server-side wrap (new TLSSocket(duplex, { isServer })), + // `self` is the wrapping socket - not a Server - and its handle has no + // onconnection; throwing here would tear the brand-new TLS engine down + // before the ClientHello ever arrives. + const handle = self._handle || socket.listener; + if (handle && typeof handle.onconnection === "function") { + handle.onconnection(0, socket); } }, handshake(socket, success, verifyError) { const self = socket.data; - if (!success && verifyError?.code === "ECONNRESET") { - const err = new ConnResetException("socket hang up"); + // `server` is null for a standalone `new tls.TLSSocket(socket, { isServer: true })` + // (no listening server owns it) — guard every server.emit / server option read. + const server = self.server; + if (self[khandshakeTimer]) { + clearTimeout(self[khandshakeTimer]); + self[khandshakeTimer] = undefined; + } + // On the server side the second argument is the raw handshake result + // (client-certificate verification is reported separately through + // `verifyError` and handled below), so !success always means the TLS + // session was never established. + if (!success) { + // The handshake never completed: there is no TLS session, so there is + // no secureConnection. Report the failure through tlsClientError the + // way Node does and tear the connection down. A connection that was + // already reported (handshake timeout, explicit destroy) is not + // reported a second time when its teardown unwinds the handshake. + if (self._hadError || self.destroyed) { + if (!self.destroyed) self.destroy(); + return; + } + // An SNICallback that reported an error (or returned an invalid + // context) aborted this handshake: surface that error through + // 'tlsClientError' instead of the generic disconnect message. + let err; + if (self[kSNIError]) { + err = self[kSNIError]; + self[kSNIError] = undefined; + } else if (server?.[kSNIError]) { + // Legacy/fallback stash location (no connection handle was available + // at SNI-dispatch time). + err = server[kSNIError]; + server[kSNIError] = undefined; + } else if (self[kALPNError]) { + // The ALPNCallback refused the connection (threw, or selected a + // protocol the client did not offer). + err = self[kALPNError]; + self[kALPNError] = undefined; + } else { + err = tlsHandshakeError(verifyError); + } self.emit("_tlsError", err); - self.server.emit("tlsClientError", err, self); + server?.emit("tlsClientError", err, self); self._hadError = true; // error before handshake on the server side will only be emitted using tlsClientError self.destroy(); @@ -412,7 +779,6 @@ const ServerHandlers: SocketHandler = { self.secureConnecting = false; self._secureEstablished = !!success; self.servername = socket.getServername(); - const server = self.server!; self.alpnProtocol = socket.alpnProtocol; // The native verifier reports a non-OK code when there is no peer certificate, // which is the normal case for plain TLS servers. @@ -420,8 +786,11 @@ const ServerHandlers: SocketHandler = { if (verifyError) { self.authorized = false; self.authorizationError = verifyError.code || verifyError.message; - server.emit("tlsClientError", verifyError, self); - if (self._rejectUnauthorized) { + server?.emit("tlsClientError", verifyError, self); + // Node only enforces client-cert verification (and the resulting destroy) + // when the server actually requested a cert; a server without requestCert + // leaves `authorized` false but keeps the connection open. + if (self._rejectUnauthorized && self._requestCert) { // if we reject we still need to emit secure self.emit("secure", self); // No error argument: the socket has no 'error' listener yet, so destroy(err) @@ -433,15 +802,17 @@ const ServerHandlers: SocketHandler = { self.authorized = true; } } - const connectionListener = server[bunSocketServerOptions]?.connectionListener; - if (typeof connectionListener === "function") { - server.prependOnceListener("secureConnection", connectionListener); + if (server) { + const connectionListener = server[bunSocketServerOptions]?.connectionListener; + if (typeof connectionListener === "function") { + server.prependOnceListener("secureConnection", connectionListener); + } + server.emit("secureConnection", self); } - server.emit("secureConnection", self); // after secureConnection event we emmit secure and secureConnect self.emit("secure", self); self.emit("secureConnect", verifyError); - if (server.pauseOnConnect) { + if (server?.pauseOnConnect) { self.pause(); } else { self.resume(); @@ -487,6 +858,127 @@ const ServerHandlers: SocketHandler = { binaryType: "buffer", } as const; +// Node.js-compatible onconnection: assigned to server._handle.onconnection in +// kRealListen and invoked from ServerHandlers.open with `this` bound to the +// listener handle. Kept as a standalone function so tests/cluster can wrap it. +function onconnection(err, clientHandle) { + const handle = this; + const self = handle[owner_symbol] as NetServer; + if (err) { + self.emit("error", err); + return; + } + clientHandle[kServerSocket] = handle; + const options = self[bunSocketServerOptions]; + const { pauseOnConnect, connectionListener, [kSocketClass]: SClass, requestCert, rejectUnauthorized } = options; + // Propagate the server's half-open/highWaterMark settings to the accepted + // socket so the Duplex's allowHalfOpen matches what the native layer was + // configured with in kRealListen; without this, net.createServer({ + // allowHalfOpen: true }) would be ignored on accepted connections. + // Matches Node's onconnection: + // https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/net.js#L2349 + const _socket = new SClass({ + allowHalfOpen: self.allowHalfOpen, + highWaterMark: self.highWaterMark, + }) as NetSocket | TLSSocket; + _socket.isServer = true; + _socket._requestCert = requestCert; + // The raw options object only has rejectUnauthorized when the user passed it explicitly; + // fall back to the server's normalized value (defaults to true for tls.Server). + _socket._rejectUnauthorized = rejectUnauthorized ?? self._rejectUnauthorized; + + _socket[kAttach](clientHandle.localPort, clientHandle); + + if (self.blockList) { + const addressType = isIP(clientHandle.remoteAddress); + if (addressType && self.blockList.check(clientHandle.remoteAddress, `ipv${addressType}`)) { + const data = { + localAddress: _socket.localAddress, + localPort: _socket.localPort || clientHandle.localPort, + localFamily: _socket.localFamily, + remoteAddress: _socket.remoteAddress, + remotePort: _socket.remotePort, + remoteFamily: _socket.remoteFamily || "IPv4", + }; + clientHandle.end(); + self.emit("drop", data); + return; + } + } + if (self.maxConnections != null && self._connections >= self.maxConnections) { + const data = { + localAddress: _socket.localAddress, + localPort: _socket.localPort || clientHandle.localPort, + localFamily: _socket.localFamily, + remoteAddress: _socket.remoteAddress, + remotePort: _socket.remotePort, + remoteFamily: _socket.remoteFamily || "IPv4", + }; + + clientHandle.end(); + self.emit("drop", data); + return; + } + + const bunTLS = _socket[bunTlsSymbol]; + const isTLS = typeof bunTLS === "function"; + + if (self.noDelay && clientHandle.setNoDelay) { + _socket[kSetNoDelay] = true; + clientHandle.setNoDelay(true); + } + if (self.keepAlive && clientHandle.setKeepAlive) { + _socket[kSetKeepAlive] = true; + _socket[kSetKeepAliveInitialDelay] = self.keepAliveInitialDelay; + clientHandle.setKeepAlive(true, self.keepAliveInitialDelay); + } + + self._connections++; + _socket.server = self; + _socket._server = self; + + if (pauseOnConnect) { + _socket.pause(); + } + + if (typeof connectionListener === "function") { + clientHandle.pauseOnConnect = pauseOnConnect; + if (!isTLS) { + self.prependOnceListener("connection", connectionListener); + } + } + // A client that never completes the TLS handshake must not hold the + // accepted socket open forever: report it through tlsClientError after + // handshakeTimeout the way Node does. The timer is cleared when the + // handshake settles (either way) or the socket closes first. + if (isTLS && self._handshakeTimeout > 0) { + const timer = setTimeout(() => { + _socket[khandshakeTimer] = undefined; + const err = $ERR_TLS_HANDSHAKE_TIMEOUT(); + _socket._hadError = true; + self.emit("tlsClientError", err, _socket); + if (!_socket.destroyed) _socket.destroy(); + }, self._handshakeTimeout); + // Node's handshake timer is unref'd: a fully-unref'd server (the + // graceful-shutdown pattern) must not be held open by a client that + // stalls mid-handshake. + timer.unref?.(); + _socket[khandshakeTimer] = timer; + _socket.once("close", () => { + if (_socket[khandshakeTimer]) { + clearTimeout(_socket[khandshakeTimer]); + _socket[khandshakeTimer] = undefined; + } + }); + } + + self.emit("connection", _socket); + // the duplex implementation start paused, so we resume when pauseOnConnect is falsy + if (!pauseOnConnect && !isTLS) { + _socket.resume(); + } +} + // TODO: SocketHandlers2 is a bad name but its temporary. reworking the Server in a followup PR const SocketHandlers2: SocketHandler["data"]> = { open(socket) { @@ -494,13 +986,11 @@ const SocketHandlers2: SocketHandler 0 + ) { + // Shape it like Node's errnoException(UV_ECONNRESET, 'read'): message, + // code, errno and syscall all populated. + // Same late-detach guard as SocketEmitEndNT: the listener seen at + // close-time can be gone by the deferred 'error' emission. + self.once("error", () => {}); + const er = new ConnResetException("read ECONNRESET") as Error & { errno?: number; syscall?: string }; + er.errno = err.errno; + er.syscall = "read"; + self.destroy(er); + return; + } self[kended] = true; if (!self.allowHalfOpen) self.write = writeAfterFIN; self.push(null); self.read(0); + // A write that was waiting on the native drain can never complete once the + // socket is gone - fail it so 'finish'/destroy are not stuck behind it + // (mirrors SocketEmitEndNT). + const pendingWrite = self[kwriteCallback]; + if (pendingWrite) { + self[kwriteCallback] = null; + pendingWrite($ERR_SOCKET_CLOSED()); + } }, handshake(socket, success, verifyError) { $debug("Bun.Socket handshake"); @@ -563,10 +1109,34 @@ const SocketHandlers2: SocketHandler { if (!this.destroyed) { this.emit("error", error); @@ -908,7 +1519,11 @@ Socket.prototype.connect = function connect(...args) { this.pause(); } else { process.nextTick(() => { - this.resume(); + // Honor pause()/resume() calls made while connecting — only start + // flowing if the user hasn't explicitly paused the stream. Matches + // Node's afterConnect, which calls socket.read(0) only when not paused: + // https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/net.js#L1649 + if (!this.isPaused()) this.resume(); }); this.connecting = true; } @@ -971,6 +1586,14 @@ Socket.prototype.connect = function connect(...args) { } // start using existing connection if (connection) { + // A generic duplex transport is already established, so this socket is + // not "connecting" - only the TLS layer is pending, which + // secureConnecting tracks. Node reports false here. A provided + // net.Socket keeps its existing accounting (its own connect lifecycle + // drives this flag). + if (!(connection instanceof Socket)) { + this.connecting = false; + } if (connectListener != null) this.once("secureConnect", connectListener); try { // reset the underlying writable object when establishing a new connection @@ -996,12 +1619,16 @@ Socket.prototype.connect = function connect(...args) { connection.on("close", events[3]); this._handle = result; } else { - if (socket) { + // upgradeTLS requires an established socket; a socket that is still + // connecting (e.g. tls.connect({ socket: net.connect(port) })) must be + // upgraded once it emits 'connect'. + if (socket && !connection.connecting) { this[kupgraded] = connection; const result = socket.upgradeTLS({ data: { self: this, req: { oncomplete: afterConnect } }, tls, socket: this[khandlers], + isServer: false, }); if (result) { const [raw, tls] = result; @@ -1017,6 +1644,13 @@ Socket.prototype.connect = function connect(...args) { } else { // wait to be connected connection.once("connect", () => { + // The TLS socket may have been destroyed before the underlying + // socket connected (e.g. tls.connect({ socket }).destroy()); don't + // start a handshake on a dead socket. + if (this.destroyed) { + connection.destroy(); + return; + } const socket = connection._handle; if (!upgradeDuplex && socket) { // if is named pipe socket we can upgrade it using the same wrapper than we use for duplex @@ -1040,6 +1674,7 @@ Socket.prototype.connect = function connect(...args) { data: { self: this, req: { oncomplete: afterConnect } }, tls, socket: this[khandlers], + isServer: false, }); if (result) { const [raw, tls] = result; @@ -1120,6 +1755,14 @@ Socket.prototype._destroy = function _destroy(err, callback) { $debug("Socket.prototype._destroy"); this.connecting = false; + // Tear down a wrapped generic duplex with this socket: the native handle's + // close only flushes close_notify and lets the wrapper drain; without an + // explicit destroy here a late RST on the underlying transport can surface + // as an unhandled error after this socket is gone. + const upgraded = this[kupgraded]; + if (upgraded && !(upgraded instanceof Socket) && !upgraded.destroyed) { + upgraded.destroy?.(); + } for (let s = this; s !== null; s = s._parent) { clearTimeout(s[kTimeout]); @@ -1135,7 +1778,11 @@ Socket.prototype._destroy = function _destroy(err, callback) { if (this.resetAndClosing) { this.resetAndClosing = false; - const err = this._handle.close(); + // resetAndDestroy() must send an RST (not a graceful FIN) so the peer sees + // ECONNRESET. `close()` does a fast shutdown (clean close) which only + // happens to surface as RST on some platforms; `terminate()` arms + // SO_LINGER{1,0} for a real reset on all platforms. + const err = this._handle.terminate(); setImmediate(() => { $debug("emit close"); this.emit("close", isException); @@ -1157,7 +1804,7 @@ Socket.prototype._destroy = function _destroy(err, callback) { callback(err); } else { callback(err); - process.nextTick(emitCloseNT, this, false); + process.nextTick(emitCloseNT, this, !!err); } if (this.server) { @@ -1172,7 +1819,7 @@ Socket.prototype._destroy = function _destroy(err, callback) { Socket.prototype._final = function _final(callback) { $debug("Socket.prototype._final"); if (this.connecting) { - return this.once("connect", () => this._final(callback)); + return this.once("connect", this._final.bind(this, callback)); } const socket = this._handle; @@ -1217,19 +1864,93 @@ Socket.prototype.resume = function resume() { if (!this.connecting) { this._handle?.resume(); } + // Restore the hold pause() removed - even while still connecting, so the + // pause-then-resume sequence is symmetric. Gated on the pause flag so a + // socket that was never paused (e.g. a wrapped duplex with no fd) is not + // newly pinned to the loop. + if (this[kPausedUnref] && !this[kUserUnrefed]) { + this._handle?.ref?.(); + this[kPausedUnref] = false; + } return Duplex.prototype.resume.$call(this); }; Socket.prototype.pause = function pause() { if (!this.destroyed) { this._handle?.pause(); + // libuv only counts a stream handle as active - and therefore as keeping + // the event loop alive - while it is reading. A paused socket lets the + // process exit; resume() re-refs it unless the user explicitly unref'd. + this._handle?.unref?.(); + // Only remember the unref when this handle can actually hold the loop: a + // TLS socket wrapped over a generic duplex has no fd, so re-refing it + // later would newly pin the process. + if (!this[kupgraded] || this[kupgraded] instanceof Socket) { + this[kPausedUnref] = true; + } } return Duplex.prototype.pause.$call(this); }; +// Server-side TLS upgrade over an accepted socket, for +// `new tls.TLSSocket(socket, { isServer: true })`. Adopts the connection's fd +// into an accept-state TLS socket (us_socket_adopt_tls with is_client=0) so the +// native read path drives the handshake. Lives here, not tls.ts, to reach +// ServerHandlers — the shared accepted-socket handler table, with per-socket +// state carried via `data` (mirrors tls.createServer's one-handler-for-all model). +Socket.prototype[Symbol.for("::bunUpgradeServerTLS::")] = function (connection, tls) { + const socket = connection._handle; + if (!socket) { + // A generic Duplex (or a not-yet-connected net.Socket) has no native fd + // to adopt into a TLS socket; run the TLS engine over the stream itself. + // The returned events feed the stream's bytes into the engine and back. + const [result, events] = upgradeDuplexToTLS(connection, { + data: this, + tls, + socket: serverHandlersFor(this), + isServer: true, + }); + connection.on("data", events[0]); + connection.on("end", events[1]); + connection.on("drain", events[2]); + connection.on("close", events[3]); + this[kupgraded] = connection; + this._handle = result; + return; + } + this[kupgraded] = connection; + // Bytes that already arrived before the wrap (e.g. the ClientHello) were + // pulled off the fd into the connection's readable buffer; hand them to the + // TLS engine so the handshake doesn't stall. + const pending = connection.read(); + const result = socket.upgradeTLS({ + data: this, + tls, + socket: serverHandlersFor(this), + isServer: true, + initialData: pending || undefined, + }); + if (!result) { + this._handle = null; + throw new Error("Invalid socket"); + } + const [raw, tlsHandle] = result; + connection._handle = raw; + this.once("end", this[kCloseRawConnection]); + raw.connecting = false; + this._handle = tlsHandle; +}; + Socket.prototype.read = function read(size) { if (!this.connecting) { this._handle?.resume(); + // Restarting kernel reads makes the handle hold the loop open again; + // mirror resume()'s re-ref or a paused-then-read() socket waits for + // data without keeping the process alive. + if (this[kPausedUnref] && !this[kUserUnrefed]) { + this._handle?.ref?.(); + this[kPausedUnref] = false; + } } return Duplex.prototype.read.$call(this, size); }; @@ -1240,6 +1961,12 @@ Socket.prototype._read = function _read(size) { this.once("connect", () => this._read(size)); } else { socket?.resume(); + // See read() above - the Readable machinery's pull path must also + // restore the handle's hold on the loop. + if (this[kPausedUnref] && !this[kUserUnrefed]) { + socket?.ref?.(); + this[kPausedUnref] = false; + } } }; @@ -1290,6 +2017,7 @@ Object.defineProperty(Socket.prototype, "readyState", { }); Socket.prototype.ref = function ref() { + this[kUserUnrefed] = false; const socket = this._handle; if (!socket) { this.once("connect", this.ref); @@ -1365,6 +2093,49 @@ Socket.prototype.setNoDelay = function setNoDelay(enable = true) { return this; }; +// Matches Node's setTypeOfService/getTypeOfService (lib/net.js + TCPWrap). +// The native handle does the setsockopt (IP_TOS / IPV6_TCLASS); a socket +// without a handle yet caches the value and applies it on connect. +// https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/net.js#L661 +Socket.prototype.setTypeOfService = function setTypeOfService(tos) { + if (Number.isNaN(tos)) { + throw $ERR_INVALID_ARG_TYPE("tos", "number", tos); + } + validateInt32(tos, "tos", 0, 255); + + if (!this._handle || !this._handle.setTypeOfService) { + this[kSetTOS] = tos; + return this; + } + + if (tos !== this[kSetTOS]) { + this[kSetTOS] = tos; + const err = this._handle.setTypeOfService(tos); + // Windows often restricts TOS or reports errors even when partially + // applied - best-effort there, the way Node treats it. + if (err && process.platform !== "win32") { + throw new ErrnoException(err, "setTypeOfService"); + } + } + return this; +}; + +Socket.prototype.getTypeOfService = function getTypeOfService() { + if (!this._handle || !this._handle.getTypeOfService) { + return this[kSetTOS] !== undefined ? this[kSetTOS] : 0; + } + const res = this._handle.getTypeOfService(); + if (typeof res === "number" && res < 0) { + // getsockopt(IP_TOS) commonly fails on Windows: fall back to the cached + // value the way Node does. + if (process.platform === "win32") { + return this[kSetTOS] !== undefined ? this[kSetTOS] : 0; + } + throw new ErrnoException(res, "getTypeOfService"); + } + return res; +}; + Socket.prototype.setTimeout = { setTimeout(msecs, callback) { if (this.destroyed) return this; @@ -1399,6 +2170,7 @@ Socket.prototype._unrefTimer = function _unrefTimer() { }; Socket.prototype.unref = function unref() { + this[kUserUnrefed] = true; const socket = this._handle; if (!socket) { this.once("connect", this.unref); @@ -1472,10 +2244,34 @@ Socket.prototype._write = function _write(chunk, encoding, callback) { return false; } this._unrefTimer(); + if (socket.readyState < 0) { + // The handle's native socket was already closed (e.g. handle.close() was + // called directly): fail the write the way a write(2) on a closed fd does + // in Node instead of waiting forever for a drain that never comes. + // Node reports this as errnoException(UV_EBADF/UV_EPIPE, 'write'), with + // message, code, errno and syscall all populated. + const er = new ErrnoException(process.platform === "win32" ? -4047 /* UV_EPIPE */ : -9 /* UV_EBADF */, "write"); + process.nextTick(callback, er); + return false; + } const success = socket.$write(chunk, encoding); this[kBytesWritten] = socket.bytesWritten; if (success) { - callback(); + if (this.encrypted) { + // TLS batches writes through the SSL engine, so the bytes stay buffered + // after $write returns. Defer the callback so writableLength/bufferSize + // reflects the queued bytes until they are flushed (test-tls-buffersize.js). + // Node's bufferSize getter is just writableLength: + // https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/net.js#L752 + process.nextTick(callback); + } else { + // A plain TCP write completes synchronously once $write reports success. + // Calling the callback synchronously lets writableLength drain so a tight + // write() loop backpressures at the kernel rather than the JS + // highWaterMark, matching Node's _write (test-net-throttle.js): + // https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/net.js#L1036 + callback(); + } } else if (this[kwriteCallback]) { callback(new Error("overlapping _write()")); } else { @@ -1780,6 +2576,18 @@ function internalConnect(self, options, address, port, addressType, localAddress req.tls = tls; err = kConnectTcp(self, addressType, req, address, port); + // kConnectTcp returns 0 (not undefined) on the async-connect path, so the + // perf context must be established whenever the attempt was dispatched + // without a synchronous error — matching the `if (err)` failure check + // below. Guarding on `err === undefined` never fired, so the 'net' entry + // was never produced for the TCP path. + if (!err && hasObserver("net")) { + startPerf(self, kPerfHooksNetConnectContext, { + type: "net", + name: "connect", + detail: { host: address, port }, + }); + } } else { const req: any = {}; req.address = address; @@ -1925,6 +2733,17 @@ function internalConnectMultiple(context, canceled?) { return; } + // Match the single-address path (and Node): the 'net' perf entry starts when + // the attempt is dispatched, not when it completes; the winning attempt's + // context is transferred to the socket in afterConnectMultiple. + if (hasObserver("net")) { + startPerf(context, kPerfHooksNetConnectContext, { + type: "net", + name: "connect", + detail: { host: address, port, addressType }, + }); + } + if (current < context.addresses.length - 1) { $debug("connect/multiple: setting the attempt timeout to %d ms", context.timeout); @@ -1959,7 +2778,12 @@ function afterConnect(status, handle, req, readable, writable) { $debug("afterConnect", status, readable, writable); - $assert(self.connecting); + // A pre-open error on a user-supplied duplex (tls.connect({ socket })) can + // clear `connecting` before the queued StartTLS task fires this callback. + // The socket is already being torn down, so bail out instead of asserting: + // this both avoids the debug $assert abort and stops the late callback from + // proceeding to touch a handle that the error path already freed. + if (!self.connecting) return; self.connecting = false; self._sockname = null; @@ -1973,6 +2797,9 @@ function afterConnect(status, handle, req, readable, writable) { } self._unrefTimer(); + if (self[kSetTOS] !== undefined && self._handle.setTypeOfService) { + self._handle.setTypeOfService(self[kSetTOS]); + } if (self[kSetNoDelay] && self._handle.setNoDelay) { self._handle.setNoDelay(true); } @@ -1984,6 +2811,10 @@ function afterConnect(status, handle, req, readable, writable) { self.emit("connect"); self.emit("ready"); + if (self[kPerfHooksNetConnectContext] && hasObserver("net")) { + stopPerf(self, kPerfHooksNetConnectContext); + } + // Start the first read, or get an immediate EOF. // this doesn't actually consume any bytes, because len=0. if (readable && !self.isPaused()) self.read(0); @@ -2034,6 +2865,13 @@ function afterConnectMultiple(context, current, status, handle, req, readable, w return; } + // The attempt's perf entry was started in internalConnectMultiple on the + // shared context; hand it to the socket so afterConnect's stopPerf records + // the real connect duration. + if (hasObserver("net") && context[kPerfHooksNetConnectContext]) { + self[kPerfHooksNetConnectContext] = context[kPerfHooksNetConnectContext]; + } + afterConnect(status, self._handle, req, readable, writable); } @@ -2076,15 +2914,22 @@ function Server(options?, connectionListener?) { } // https://nodejs.org/api/net.html#netcreateserveroptions-connectionlistener - const { + let { allowHalfOpen = false, keepAlive = false, - keepAliveInitialDelay = 0, + keepAliveInitialDelay, highWaterMark = getDefaultHighWaterMark(), pauseOnConnect = false, noDelay = false, } = options; + if (keepAliveInitialDelay !== undefined) { + validateNumber(keepAliveInitialDelay, "options.keepAliveInitialDelay"); + if (keepAliveInitialDelay < 0) keepAliveInitialDelay = 0; + } else { + keepAliveInitialDelay = 0; + } + this._connections = 0; this._handle = null as MaybeListener; @@ -2094,12 +2939,14 @@ function Server(options?, connectionListener?) { this.listeningId = 1; this[bunSocketServerOptions] = undefined; + // Server option coercion matches Node's Server constructor: + // https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/net.js#L1880 this.allowHalfOpen = allowHalfOpen; - this.keepAlive = keepAlive; - this.keepAliveInitialDelay = keepAliveInitialDelay; + this.keepAlive = Boolean(keepAlive); + this.keepAliveInitialDelay = ~~(keepAliveInitialDelay / 1000); this.highWaterMark = highWaterMark; this.pauseOnConnect = Boolean(pauseOnConnect); - this.noDelay = noDelay; + this.noDelay = Boolean(noDelay); options.connectionListener = connectionListener; this[bunSocketServerOptions] = options; @@ -2205,9 +3052,10 @@ Server.prototype.listen = function listen(port, hostname, onListen) { let backlog; let path; let exclusive = false; - let allowHalfOpen = false; let reusePort = false; let ipv6Only = false; + let readableAll = false; + let writableAll = false; let fd; //port is actually path if (typeof port === "string") { @@ -2239,7 +3087,7 @@ Server.prototype.listen = function listen(port, hostname, onListen) { if (typeof port === "function") { onListen = port; port = 0; - } else if (typeof port === "object") { + } else if (port !== null && typeof port === "object") { const options = port; addServerAbortSignalOption(this, options); @@ -2248,9 +3096,15 @@ Server.prototype.listen = function listen(port, hostname, onListen) { path = options.path; port = options.port; ipv6Only = options.ipv6Only; - allowHalfOpen = options.allowHalfOpen; + // NOTE: options.allowHalfOpen for a server is consumed by the Server + // constructor (it shapes accepted sockets' Duplex behavior); the native + // listen always uses allowHalfOpen: true. reusePort = options.reusePort; backlog = options.backlog; + // For a unix-socket listen, readableAll/writableAll chmod the socket file + // in kRealListen; threaded through as locals (not stashed on the instance). + readableAll = options.readableAll; + writableAll = options.writableAll; if (typeof options.fd === "number" && options.fd >= 0) { fd = options.fd; @@ -2259,31 +3113,49 @@ Server.prototype.listen = function listen(port, hostname, onListen) { const isLinux = process.platform === "linux" || process.platform === "android"; - if (!Number.isSafeInteger(port) || port < 0) { - if (path) { - const isAbstractPath = path.startsWith("\0"); - if (isLinux && isAbstractPath && (options.writableAll || options.readableAll)) { - const message = `The argument 'options' can not set readableAll or writableAll to true when path is abstract unix socket. Received ${JSON.stringify(options)}`; - - const error = new TypeError(message); - error.code = "ERR_INVALID_ARG_VALUE"; - throw error; - } + // Match Node's listen() option normalization + validation. + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/net.js#L2145 + if ((port === undefined && "port" in options) || port === null) { + port = 0; + } - hostname = path; - port = undefined; - } else { - let message = 'The argument \'options\' must have the property "port" or "path"'; - try { - message = `${message}. Received ${JSON.stringify(options)}`; - } catch {} + if (typeof port === "number" || typeof port === "string") { + // validatePort coerces "0" -> 0 and throws ERR_SOCKET_BAD_PORT for + // out-of-range/non-numeric values; a valid port takes precedence over path. + validatePort(port, "options.port"); + port = port | 0; + // A valid port takes precedence over `path` (Node listens on TCP when both are given). + path = undefined; + } else if (isPipeName(path)) { + const isAbstractPath = path.startsWith("\0"); + if (isLinux && isAbstractPath && (options.writableAll || options.readableAll)) { + const message = `The argument 'options' can not set readableAll or writableAll to true when path is abstract unix socket. Received ${JSON.stringify(options)}`; const error = new TypeError(message); error.code = "ERR_INVALID_ARG_VALUE"; throw error; } - } else if (port === undefined) { - port = 0; + + hostname = path; + port = undefined; + } else if (!("port" in options) && !("path" in options)) { + let message = 'The argument \'options\' must have the property "port" or "path"'; + try { + message = `${message}. Received ${JSON.stringify(options)}`; + } catch {} + + const error = new TypeError(message); + error.code = "ERR_INVALID_ARG_VALUE"; + throw error; + } else { + let message = "The argument 'options' is invalid"; + try { + message = `${message}. Received ${JSON.stringify(options)}`; + } catch {} + + const error = new TypeError(message); + error.code = "ERR_INVALID_ARG_VALUE"; + throw error; } // port @@ -2297,8 +3169,21 @@ Server.prototype.listen = function listen(port, hostname, onListen) { // signal An AbortSignal that may be used to close a listening server. if (typeof options.callback === "function") onListen = options?.callback; - } else if (!Number.isSafeInteger(port) || port < 0) { + } else if (port === undefined || port === null) { port = 0; + } else if (typeof port === "number" || typeof port === "string") { + // Positional port: validatePort coerces and throws ERR_SOCKET_BAD_PORT for + // out-of-range/non-numeric values, matching Node's normalizeArgs + validatePort. + validatePort(port, "options.port"); + port = port | 0; + } else { + let message = "The argument 'options' is invalid"; + try { + message = `${message}. Received ${JSON.stringify(port)}`; + } catch {} + const error = new TypeError(message); + error.code = "ERR_INVALID_ARG_VALUE"; + throw error; } hostname = hostname || "::"; } @@ -2342,8 +3227,9 @@ Server.prototype.listen = function listen(port, hostname, onListen) { fd, exclusive, ipv6Only, - allowHalfOpen, reusePort, + readableAll, + writableAll, undefined, undefined, path, @@ -2353,7 +3239,8 @@ Server.prototype.listen = function listen(port, hostname, onListen) { onListen, ); } catch (err) { - setTimeout(emitErrorNextTick, 1, this, err); + const isUnix = path != null; + setTimeout(emitErrorNextTick, 1, this, formatListenError(err, isUnix ? path : hostname, isUnix ? undefined : port)); } return this; }; @@ -2364,34 +3251,60 @@ Server.prototype[kRealListen] = function ( hostname, exclusive, ipv6Only, - allowHalfOpen, reusePort, + readableAll, + writableAll, tls, contexts, _onListen, fd, ) { + // NOTE: accepted sockets are always allowHalfOpen:true at the native layer + // (hardcoded below); the stream layer implements allowHalfOpen=false + // semantics itself, so the server option is consumed in JS only. if (path) { this._handle = Bun.listen({ unix: path, tls, - allowHalfOpen: allowHalfOpen || this[bunSocketServerOptions]?.allowHalfOpen || false, + // Accepted sockets are always half-open natively; the stream layer + // implements allowHalfOpen=false (see kConnect / onSocketEnd). + allowHalfOpen: true, reusePort: reusePort || this[bunSocketServerOptions]?.reusePort || false, ipv6Only: ipv6Only || this[bunSocketServerOptions]?.ipv6Only || false, exclusive: exclusive || this[bunSocketServerOptions]?.exclusive || false, - socket: ServerHandlers, + socket: serverHandlersFor(this), data: this, }); + // Mirror libuv uv_pipe_chmod: readableAll/writableAll relax the unix socket + // file's group/other permission bits. Skipped on Windows and abstract + // sockets (no filesystem entry). uSockets binds synchronously, so the file + // exists by the time Bun.listen returns. + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/net.js#L1899 + if ((readableAll || writableAll) && process.platform !== "win32" && path.charCodeAt(0) !== 0) { + let desired = 0; + if (readableAll) desired |= 0o44; // S_IRGRP | S_IROTH + if (writableAll) desired |= 0o22; // S_IWGRP | S_IWOTH + try { + const fs = require("node:fs"); + const cur = fs.statSync(path).mode; + if ((cur & desired) !== desired) fs.chmodSync(path, cur | desired); + } catch (e) { + // _handle is a Bun.listen SocketListener: it exposes stop(), not close(). + this._handle?.stop?.(true); + this._handle = null; + throw e; + } + } } else if (fd != null) { this._handle = Bun.listen({ fd, hostname, tls, - allowHalfOpen: allowHalfOpen || this[bunSocketServerOptions]?.allowHalfOpen || false, + allowHalfOpen: true, reusePort: reusePort || this[bunSocketServerOptions]?.reusePort || false, ipv6Only: ipv6Only || this[bunSocketServerOptions]?.ipv6Only || false, exclusive: exclusive || this[bunSocketServerOptions]?.exclusive || false, - socket: ServerHandlers, + socket: serverHandlersFor(this), data: this, }); } else { @@ -2399,15 +3312,18 @@ Server.prototype[kRealListen] = function ( port, hostname, tls, - allowHalfOpen: allowHalfOpen || this[bunSocketServerOptions]?.allowHalfOpen || false, + allowHalfOpen: true, reusePort: reusePort || this[bunSocketServerOptions]?.reusePort || false, ipv6Only: ipv6Only || this[bunSocketServerOptions]?.ipv6Only || false, exclusive: exclusive || this[bunSocketServerOptions]?.exclusive || false, - socket: ServerHandlers, + socket: serverHandlersFor(this), data: this, }); } + this._handle[owner_symbol] = this; + this._handle.onconnection = onconnection; + const addr = this.address(); if (addr && typeof addr === "object") { const familyLast = String(addr.family).slice(-1); @@ -2438,6 +3354,7 @@ Server.prototype[kRealListen] = function ( Server.prototype[EventEmitter.captureRejectionSymbol] = function (err, event, sock) { switch (event) { case "connection": + case "secureConnection": sock.destroy(err); break; default: @@ -2488,8 +3405,9 @@ function listenInCluster( fd, exclusive, ipv6Only, - allowHalfOpen, reusePort, + readableAll, + writableAll, flags, options, path, @@ -2509,8 +3427,9 @@ function listenInCluster( hostname, exclusive, ipv6Only, - allowHalfOpen, reusePort, + readableAll, + writableAll, tls, contexts, onListen, @@ -2539,8 +3458,9 @@ function listenInCluster( hostname, exclusive, ipv6Only, - allowHalfOpen, reusePort, + readableAll, + writableAll, tls, contexts, onListen, @@ -2613,6 +3533,38 @@ function closeSocketHandle(self, isException, isCleanupPending = false) { } } +// Reformat a native listen error to Node's "listen : " +// (Node uses exceptionWithHostPort). Only rewrites known uv codes; the code is +// already set natively. +// https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/net.js#L1899 +function uvListenErrorDescription(code) { + switch (code) { + case "EADDRINUSE": + return "address already in use"; + case "EACCES": + return "permission denied"; + case "EADDRNOTAVAIL": + return "address not available"; + case "EINVAL": + return "invalid argument"; + default: + return undefined; + } +} +function formatListenError(err, address, port) { + const desc = err && typeof err.code === "string" ? uvListenErrorDescription(err.code) : undefined; + if (desc) { + err.syscall = "listen"; + // Node's exceptionWithHostPort also exposes the failing address/port as + // own properties; user code commonly reads them off listen errors. + err.address = address; + if (port) err.port = port; + const where = port ? `${address}:${port}` : address; + err.message = `listen ${err.code}: ${desc}${where ? ` ${where}` : ""}`; + } + return err; +} + function checkBindError(err, port, handle) { // EADDRINUSE may not be reported until we call listen() or connect(). // To complicate matters, a failed bind() followed by listen() or connect() diff --git a/src/js/node/perf_hooks.ts b/src/js/node/perf_hooks.ts index f5b05ed542fd..de20bfc411d6 100644 --- a/src/js/node/perf_hooks.ts +++ b/src/js/node/perf_hooks.ts @@ -1,5 +1,5 @@ // Hardcoded module "node:perf_hooks" -const { throwNotImplemented } = require("internal/shared"); +const { throwNotImplemented, kNodeEntryTypes, NodeEntryObserver } = require("internal/shared"); const cppCreateHistogram = $newCppFunction("JSNodePerformanceHooksHistogram.cpp", "jsFunction_createHistogram", 3) as ( min: number, @@ -12,7 +12,7 @@ var { PerformanceEntry, PerformanceMark, PerformanceMeasure, - PerformanceObserver, + PerformanceObserver: NodePerformanceObserver, PerformanceObserverEntryList, } = globalThis; @@ -113,6 +113,91 @@ class PerformanceResourceTiming { } $toClass(PerformanceResourceTiming, "PerformanceResourceTiming", PerformanceEntry); +const kNodeObserver = Symbol("kNodeObserver"); +const kObserverCallback = Symbol("kObserverCallback"); + +/** + * The native (WebCore) observer only understands mark/measure/resource. + * Node-only entry types ('net', 'dns', ...) are routed to the JS-side + * registry in internal/shared; everything else is delegated to the native + * observer unchanged. (`NodePerformanceObserver` is the existing alias for + * the native class destructured from globalThis above.) + */ +class PerformanceObserverForNodeTypes extends NodePerformanceObserver { + constructor(callback) { + super(callback); + this[kObserverCallback] = callback; + } + + /** The native list plus the Node-only types routed through the JS registry. */ + static get supportedEntryTypes() { + return [...new Set([...(NodePerformanceObserver.supportedEntryTypes ?? []), ...kNodeEntryTypes])].sort(); + } + + observe(options) { + let requested; + let isTypeMode = false; + if (options != null && typeof options === "object") { + if (options.entryTypes !== undefined && Array.isArray(options.entryTypes)) { + requested = options.entryTypes; + } else if (options.type !== undefined) { + requested = [options.type]; + isTypeMode = true; + } + } + if (requested) { + const nodeTypes = requested.filter(type => kNodeEntryTypes.has(type)); + let registration = this[kNodeObserver]; + if (nodeTypes.length > 0 && !registration) { + registration = this[kNodeObserver] = new NodeEntryObserver(this[kObserverCallback], this); + } + if (registration) { + if (isTypeMode) { + // observe({type}) appends to the observed set per the spec. + registration.observe([...registration.types, ...nodeTypes]); + } else { + // observe({entryTypes}) replaces the observed set, including + // dropping a previously-observed node type when the new set has + // none. + registration.observe(nodeTypes); + } + } + if (nodeTypes.length > 0) { + const webTypes = requested.filter(type => !kNodeEntryTypes.has(type)); + if (webTypes.length === 0) { + // observe({entryTypes}) replaces the whole observed set: a + // previously-subscribed web type must stop firing when the new set + // is node-only. The native impl rejects an empty entryTypes array, + // so drop the subscription instead of re-observing with []. + if (!isTypeMode) { + try { + super.disconnect(); + } catch {} + } + return; + } + // A non-empty webTypes set alongside a node type is only possible in + // entryTypes mode (observe({type}) requests exactly one type), so the + // forwarded subscription is always an entryTypes one. + return super.observe({ ...options, entryTypes: webTypes }); + } + } + return super.observe(options); + } + + disconnect() { + this[kNodeObserver]?.disconnect(); + this[kNodeObserver] = undefined; + return super.disconnect(); + } +} +// Not $toClass: that resets the prototype object and would drop the +// observe/disconnect overrides above. Only the public name needs fixing. +Object.defineProperty(PerformanceObserverForNodeTypes, "name", { + value: "PerformanceObserver", + configurable: true, +}); + export default { performance: { mark(_) { @@ -169,7 +254,7 @@ export default { PerformanceEntry, PerformanceMark, PerformanceMeasure, - PerformanceObserver, + PerformanceObserver: PerformanceObserverForNodeTypes, PerformanceObserverEntryList, PerformanceNodeTiming, monitorEventLoopDelay: function monitorEventLoopDelay(options?: { resolution?: number }) { diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index dfa910558b5a..7cbf500677c3 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -1,11 +1,18 @@ // Hardcoded module "node:tls" -const { isArrayBufferView, isTypedArray } = require("node:util/types"); +const { isArrayBufferView } = require("node:util/types"); const net = require("node:net"); const Duplex = require("internal/streams/duplex"); +const EventEmitter = require("node:events"); const addServerName = $newZigFunction("Listener.zig", "jsAddServerName", 3); const { throwNotImplemented } = require("internal/shared"); const { throwOnInvalidTLSArray } = require("internal/tls"); -const { validateString, validateFunction } = require("internal/validators"); +const { + validateString, + validateNumber, + validateUint32, + validateBuffer, + validateFunction, +} = require("internal/validators"); const { Server: NetServer, Socket: NetSocket } = net; @@ -189,6 +196,61 @@ function getValidCiphersSet() { return _VALID_CIPHERS_SET; } +// OpenSSL cipher-list selector keywords that are not literal suite names. +const CIPHER_LIST_SELECTORS = new Set([ + "DEFAULT", + "ALL", + "COMPLEMENTOFDEFAULT", + "COMPLEMENTOFALL", + "HIGH", + "MEDIUM", + "LOW", + "PSK", + "aNULL", + "eNULL", + "NULL", + "EXPORT", + "EXP", + "kRSA", + "aRSA", + "RSA", + "kDHE", + "kEDH", + "DH", + "DHE", + "EDH", + "kECDHE", + "kEECDH", + "ECDHE", + "EECDH", + "ECDH", + "aECDSA", + "ECDSA", + "aDSS", + "DSS", + "AES", + "AESGCM", + "AESCCM", + "CHACHA20", + "3DES", + "DES", + "RC4", + "RC2", + "MD5", + "SHA", + "SHA1", + "SHA256", + "SHA384", + "CAMELLIA", + "ARIA", + "SRP", + "TLSv1", + "TLSv1.0", + "TLSv1.2", + "TLSv1.3", + "SSLv3", +]); + function validateCiphers(ciphers: string, name: string = "options") { // Set the cipher list and cipher suite before anything else because // @SECLEVEL= changes the security level and that affects subsequent @@ -202,12 +264,156 @@ function validateCiphers(ciphers: string, name: string = "options") { const requested = ciphers.split(":"); for (const r of requested) { if (r && !ciphersSet.has(r)) { + // OpenSSL cipher-list grammar: `!X`/`-X`/`+X` operators, `A+B` + // intersections, `@SECLEVEL=n`/`@STRENGTH` directives and selector + // keywords (HIGH, PSK, aNULL, ...) are not literal cipher names - + // leave their evaluation to BoringSSL. Only an unrecognized literal + // suite name is rejected here. + // BoringSSL has no security levels: its cipher parser rejects + // @SECLEVEL with INVALID_COMMAND. Report that the way the native + // parser would, with Node's decomposed error shape. + if (r.includes("@SECLEVEL")) { + const err = new Error("error:0f000076:SSL routines:OPENSSL_internal:INVALID_COMMAND") as Error & { + code: string; + library: string; + function: string; + reason: string; + }; + err.code = "ERR_SSL_INVALID_COMMAND"; + err.library = "SSL routines"; + err.function = "OPENSSL_internal"; + err.reason = "INVALID_COMMAND"; + throw err; + } + const first = r.charCodeAt(0); + if ( + first === 0x21 /* ! */ || + first === 0x2d /* - */ || + first === 0x2b /* + */ || + first === 0x40 /* @ */ || + r.includes("+") || + CIPHER_LIST_SELECTORS.has(r) + ) { + continue; + } throw $ERR_SSL_NO_CIPHER_MATCH(); } } } } +const VALID_TLS_VERSIONS = new Set(["TLSv1", "TLSv1.1", "TLSv1.2", "TLSv1.3"]); + +// Subset of Node's configSecureContext() validations: +// https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/internal/tls/secure-context.js#L318 +// Valid OpenSSL/BoringSSL secureProtocol method names (legacy API). Built lazily +// so the Set is only allocated when a secureProtocol option is actually used. +let _SECURE_PROTOCOL_METHODS: Set | undefined; +function getSecureProtocolMethods() { + if (!_SECURE_PROTOCOL_METHODS) { + _SECURE_PROTOCOL_METHODS = new Set([ + "TLS_method", + "TLS_client_method", + "TLS_server_method", + "SSLv23_method", + "SSLv23_client_method", + "SSLv23_server_method", + "TLSv1_method", + "TLSv1_client_method", + "TLSv1_server_method", + "TLSv1_1_method", + "TLSv1_1_client_method", + "TLSv1_1_server_method", + "TLSv1_2_method", + "TLSv1_2_client_method", + "TLSv1_2_server_method", + ]); + } + return _SECURE_PROTOCOL_METHODS; +} +// Matches Node: SSLv2/SSLv3 methods are disabled, anything unrecognized is an +// unknown method. +// https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/secure-context.js#L100 +function invalidProtocolMethod(message) { + // Node throws all secureProtocol failures (SSLv2/SSLv3 disabled + unknown + // method) via THROW_ERR_TLS_INVALID_PROTOCOL_METHOD: a TypeError carrying the + // ERR_TLS_INVALID_PROTOCOL_METHOD code, varying only the message. + const error = new TypeError(message); + error.code = "ERR_TLS_INVALID_PROTOCOL_METHOD"; + return error; +} +function validateSecureProtocol(secureProtocol) { + if (secureProtocol === undefined || secureProtocol === null) return; + validateString(secureProtocol, "options.secureProtocol"); + if (secureProtocol.startsWith("SSLv2_")) throw invalidProtocolMethod("SSLv2 methods disabled"); + if (secureProtocol.startsWith("SSLv3_")) throw invalidProtocolMethod("SSLv3 methods disabled"); + if (!getSecureProtocolMethods().has(secureProtocol)) { + throw invalidProtocolMethod(`Unknown method: ${secureProtocol}`); + } +} + +function validateSecureContextOptions(options) { + const { + ciphers, + passphrase, + ecdhCurve, + minVersion, + maxVersion, + sessionTimeout, + ticketKeys, + clientCertEngine, + dhparam, + secureProtocol, + } = options; + validateSecureProtocol(secureProtocol); + if (ciphers !== undefined && ciphers !== null) validateString(ciphers, "options.ciphers"); + if (passphrase !== undefined && passphrase !== null) validateString(passphrase, "options.passphrase"); + if (ecdhCurve !== undefined && ecdhCurve !== null) validateString(ecdhCurve, "options.ecdhCurve"); + // clientCertEngine must be a string (engine name); a provided engine then + // fails because BoringSSL (which Bun always uses) has no OpenSSL ENGINE + // support, matching Node's setClientCertEngine. Node: + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/secure-context.js#L296 + if (clientCertEngine !== undefined && clientCertEngine !== null) { + if (typeof clientCertEngine !== "string") { + throw $ERR_INVALID_ARG_TYPE("options.clientCertEngine", ["string", "null", "undefined"], clientCertEngine); + } + throw $ERR_CRYPTO_CUSTOM_ENGINE_NOT_SUPPORTED("Custom engines not supported by this OpenSSL"); + } + // BoringSSL (always used by Bun) has no automatic DH parameter selection. + // Matches Node's setDHParam('auto') throwing ERR_CRYPTO_UNSUPPORTED_OPERATION. + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/secure-context.js#L254 + if (dhparam === "auto") { + throw $ERR_CRYPTO_UNSUPPORTED_OPERATION("Automatic DH parameter selection is not supported"); + } + if (minVersion != null && !VALID_TLS_VERSIONS.has(minVersion)) + throw $ERR_TLS_INVALID_PROTOCOL_VERSION(String(minVersion), "minimum"); + if (maxVersion != null && !VALID_TLS_VERSIONS.has(maxVersion)) + throw $ERR_TLS_INVALID_PROTOCOL_VERSION(String(maxVersion), "maximum"); + if (ticketKeys !== undefined && ticketKeys !== null) { + validateBuffer(ticketKeys, "options.ticketKeys"); + if (ticketKeys.byteLength !== 48) { + throw $ERR_INVALID_ARG_VALUE("options.ticketKeys", ticketKeys.byteLength, "must be exactly 48 bytes"); + } + } + // Negative session timeouts are rejected (min 0), matching Node — newer + // OpenSSL/BoringSSL do not handle negative values as users expect. + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/secure-context.js#L319 + if (sessionTimeout !== undefined && sessionTimeout !== null) { + // Node validates this with validateInt32(..., 0), whose range message + // reads ">= 0 && <= 2147483647"; the shared validator here words it + // differently, so spell the check out to match. + if (typeof sessionTimeout !== "number") { + throw $ERR_INVALID_ARG_TYPE("options.sessionTimeout", "number", sessionTimeout); + } + if (!Number.isInteger(sessionTimeout)) { + throw $ERR_OUT_OF_RANGE("options.sessionTimeout", "an integer", sessionTimeout); + } + if (sessionTimeout < 0 || sessionTimeout > 2147483647) { + throw $ERR_OUT_OF_RANGE("options.sessionTimeout", ">= 0 && <= 2147483647", sessionTimeout); + } + } +} + const SymbolReplace = Symbol.replace; const RegExpPrototypeSymbolReplace = RegExp.prototype[SymbolReplace]; const RegExpPrototypeExec = RegExp.prototype.exec; @@ -407,7 +613,111 @@ const NativeSecureContext = $zig("SecureContext.zig", "js.getConstructor"); // accepts null|string|ArrayBuffer|Blob|array, so coerce falsy → null before // crossing into native so `{ key: false }` etc. doesn't throw // ERR_INVALID_ARG_TYPE from the bindgen layer. -function newNativeSecureContext(options) { +// BoringSSL TLS1_x_VERSION constants (from openssl/tls1.h). The native context +// applies these via SSL_CTX_set_min/max_proto_version. +const TLS1_VERSION = 0x0301; +const TLS1_1_VERSION = 0x0302; +const TLS1_2_VERSION = 0x0303; +const TLS1_3_VERSION = 0x0304; +function tlsStringToProtocolVersion(v) { + switch (v) { + case "TLSv1": + return TLS1_VERSION; + case "TLSv1.1": + return TLS1_1_VERSION; + case "TLSv1.2": + return TLS1_2_VERSION; + case "TLSv1.3": + return TLS1_3_VERSION; + default: + return 0; + } +} +// Node's legacy secureProtocol string pins both bounds to a single version +// (e.g. 'TLSv1_2_method'); 'TLS_method'/'SSLv23_method' leave the range open. +// https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/secure-context.js#L120 +function secureProtocolToVersionRange(secureProtocol) { + if (typeof secureProtocol !== "string") return null; + if ( + secureProtocol === "TLSv1_method" || + secureProtocol === "TLSv1_client_method" || + secureProtocol === "TLSv1_server_method" + ) + return [TLS1_VERSION, TLS1_VERSION]; + if ( + secureProtocol === "TLSv1_1_method" || + secureProtocol === "TLSv1_1_client_method" || + secureProtocol === "TLSv1_1_server_method" + ) + return [TLS1_1_VERSION, TLS1_1_VERSION]; + if ( + secureProtocol === "TLSv1_2_method" || + secureProtocol === "TLSv1_2_client_method" || + secureProtocol === "TLSv1_2_server_method" + ) + return [TLS1_2_VERSION, TLS1_2_VERSION]; + return null; +} + +/** + * Node's `pfx` option: parse each PKCS#12 blob into PEM key/cert/ca and fold + * them into the regular options so every downstream consumer (the native + * config, the multi-identity check, the CA store) sees plain key/cert/ca. + * Returns the original object untouched when no pfx is present. + */ +function processPfxOptions(options) { + if (options == null || options.pfx == null) return options; + const out = { ...options }; + const keys = out.key == null ? [] : Array.isArray(out.key) ? [...out.key] : [out.key]; + const certs = out.cert == null ? [] : Array.isArray(out.cert) ? [...out.cert] : [out.cert]; + const pfxCAs = []; + const entries = Array.isArray(out.pfx) ? out.pfx : [out.pfx]; + for (const entry of entries) { + let buf = entry; + let passphrase = out.passphrase; + if ( + entry != null && + typeof entry === "object" && + !Buffer.isBuffer(entry) && + !$isTypedArrayView(entry) && + entry.buf !== undefined + ) { + buf = entry.buf; + if (entry.passphrase !== undefined) passphrase = entry.passphrase; + } + const parsed = NativeSecureContext.parsePkcs12(buf, passphrase); + keys.push(parsed.key); + certs.push(parsed.cert); + // A CA bundled inside the PKCS#12 EXTENDS the trust set (Node loads it + // via addCACert on top of the default roots); folding it into the `ca` + // option would instead REPLACE the trust store and break verification + // against the default/NODE_EXTRA_CA_CERTS roots for pfx-only clients. + if (parsed.ca) pfxCAs.push(parsed.ca); + } + out.key = keys.length === 1 ? keys[0] : keys; + out.cert = certs.length === 1 ? certs[0] : certs; + if (pfxCAs.length) out._pfxExtraCACerts = pfxCAs; + out.pfx = undefined; + return out; +} + +function newNativeSecureContext(options, cached = true) { + maybeWarnAboutExtraCACerts(); + // tls.createSecureContext() with no options still goes through the version + // translation below so the module-level DEFAULT_MIN/MAX_VERSION apply. + options = options == null ? {} : processPfxOptions(options); + // PKCS#12-embedded CAs extend the trust set after the context is built; a + // mutated context must not be the shared cached one. + const pfxExtraCAs = options._pfxExtraCACerts; + if (pfxExtraCAs) cached = false; + // ALPN protocols given as an array of strings are converted to the + // length-prefixed wire format before crossing into native, the way Node's + // convertALPNProtocols normalizes them on the socket options. + if (Array.isArray(options.ALPNProtocols)) { + const normalized = {}; + convertALPNProtocols(options.ALPNProtocols, normalized); + options = { ...options, ALPNProtocols: normalized.ALPNProtocols }; + } if (options && (!options.key || !options.cert || !options.ca)) { options = { ...options, @@ -416,20 +726,51 @@ function newNativeSecureContext(options) { ca: options.ca || null, }; } - return NativeSecureContext.intern(options); + if (options) { + // Read each option once. Translate minVersion/maxVersion/secureProtocol to + // the integer protocol range the native layer applies, so the bindings + // receive numbers, not the user-facing strings. When none are given the + // module-level tls.DEFAULT_MIN_VERSION / DEFAULT_MAX_VERSION apply, the + // way Node's createSecureContext does. + const { minVersion: optMinVersion, maxVersion: optMaxVersion, secureProtocol: optSecureProtocol } = options; + { + let minVersion, maxVersion; + const range = secureProtocolToVersionRange(optSecureProtocol); + if (range) { + minVersion = range[0]; + maxVersion = range[1]; + } else { + minVersion = tlsStringToProtocolVersion(optMinVersion ?? DEFAULT_MIN_VERSION); + maxVersion = tlsStringToProtocolVersion(optMaxVersion ?? DEFAULT_MAX_VERSION); + } + options = { ...options, minVersion, maxVersion }; + } + } + const ctx = (cached ? NativeSecureContext.intern : NativeSecureContext.createPrivate)(options); + if (pfxExtraCAs) { + for (const pem of pfxExtraCAs) ctx.addCACert(pem); + } + return ctx; } var InternalSecureContext = class SecureContext { context; servername; - constructor(options) { + constructor(options, cached = true) { + // When tls.setDefaultCACertificates() has installed an override and no + // explicit `ca` was given, use the override as the default CA set so the + // process-wide default applies on every construction path (the public + // createSecureContext(), the connect/TLSSocket path, addContext and + // setSecureContext), matching Node's secure-context default. + if (_defaultCACertificatesOverride !== undefined && (options == null || options.ca == null)) { + options = { ...options, ca: _defaultCACertificatesOverride }; + } if (options) { + validateSecureContextOptions(options); if (options.cert) throwOnInvalidTLSArray("options.cert", options.cert); if (options.key) throwOnInvalidTLSArray("options.key", options.key); if (options.ca) throwOnInvalidTLSArray("options.ca", options.ca); - if (options.passphrase != null && typeof options.passphrase !== "string") - throw new TypeError("passphrase argument must be an string"); if (options.servername != null && typeof options.servername !== "string") throw new TypeError("servername argument must be an string"); if (options.secureOptions != null && typeof options.secureOptions !== "number") @@ -454,7 +795,7 @@ var InternalSecureContext = class SecureContext { // The native handle (SSL_CTX wrapper) is what's memoised — not this JS // object — so per-call fields like `servername` come from THIS call's // options while the expensive SSL_CTX is shared. - this.context = newNativeSecureContext(options); + this.context = newNativeSecureContext(options, cached); this.servername = options?.servername; } }; @@ -465,10 +806,14 @@ function SecureContext(options): void { function createSecureContext(options) { if (options instanceof InternalSecureContext) return options; + // The setDefaultCACertificates() override is applied inside the + // InternalSecureContext constructor so every construction path honors it. // The native handle (SSL_CTX) is memoised inside `NativeSecureContext.intern` // by the per-VM `SSLContextCache`, so no JS-side hashing here. The JS wrapper // is built fresh because it carries the per-call `servername`. - return new InternalSecureContext(options); + // The user-facing constructor owns its SSL_CTX exclusively so addCACert + // cannot leak across contexts; internal connect/listen paths stay cached. + return new InternalSecureContext(options, false); } // Translate some fields from the handle's C-friendly format into more idiomatic @@ -484,6 +829,10 @@ const ksession = Symbol("ksession"); const krenegotiationDisabled = Symbol("renegotiationDisabled"); const buntls = Symbol.for("::buntls::"); +// net.ts's SNI dispatch uses this to recognize a raw native SecureContext +// (Node's `context.context || context` unwrap accepts both the wrapper and +// the unwrapped native context). +const kNativeSecureContextCtor = Symbol.for("::buntlsnativesecurecontextctor::"); function TLSSocket(socket?, options?) { this[ksecureContext] = undefined; @@ -506,10 +855,43 @@ function TLSSocket(socket?, options?) { const isNetSocketOrDuplex = socket instanceof Duplex; + // A provided underlying socket must be a Duplex/net.Socket. An event emitter + // that isn't a stream (e.g. a bare EventEmitter) is not a valid socket — Node + // throws when wrapping it. Distinguished from a TLS options object, which is + // not an EventEmitter. + if (socket != null && !isNetSocketOrDuplex && socket instanceof EventEmitter) { + throw $ERR_INVALID_ARG_TYPE("socket", "Duplex", socket); + } + options = isNetSocketOrDuplex ? { ...options, allowHalfOpen: false } : options || socket || {}; NetSocket.$call(this, options); + // A server-side TLSSocket is created with { isServer: true }; track it so + // server-only guards (e.g. setServername throwing ERR_TLS_SNI_FROM_SERVER) + // behave like Node. Accepted sockets set this again in onconnection. + const isServer = !!options.isServer; + this.isServer = isServer; + + // A custom SNICallback must be a function — but Node only validates it on the + // server side (it is meaningless for a client), inside the isServer branch. + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/wrap.js#L929 + if (isServer) { + const sniCallback = options.SNICallback; + if (sniCallback != null) { + validateFunction(sniCallback, "options.SNICallback"); + this._SNICallback = sniCallback; + } + const alpnCallback = options.ALPNCallback; + if (alpnCallback != null) { + validateFunction(alpnCallback, "options.ALPNCallback"); + if (options.ALPNProtocols) { + throw $ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS(); + } + this._ALPNCallback = alpnCallback; + } + } + this.ciphers = options.ciphers; if (this.ciphers) { validateCiphers(options.ciphers); @@ -521,13 +903,18 @@ function TLSSocket(socket?, options?) { convertALPNProtocols(ALPNProtocols, this); } - if (isNetSocketOrDuplex) { + if (isNetSocketOrDuplex && !this.isServer) { this._handle = socket; // keep compatibility with http2-wrapper or other places that try to grab JSStreamSocket in node.js, with here is just the TLSSocket this._handle._parentWrap = this; } + // For the server wrap, _handle is assigned the upgraded TLS handle by the + // server-upgrade method below; leaving it unset until then means a synchronous + // teardown during upgradeTLS won't call close() on the bare net.Socket. } - this[ksecureContext] = options.secureContext || createSecureContext(options); + // Internal path: keep the per-digest cache (only the user-facing + // tls.createSecureContext() owns its SSL_CTX exclusively). + this[ksecureContext] = options.secureContext || new InternalSecureContext(options); this.authorized = false; this.secureConnecting = true; this._secureEstablished = false; @@ -537,20 +924,60 @@ function TLSSocket(socket?, options?) { } this[kcheckServerIdentity] = options.checkServerIdentity || checkServerIdentity; this[ksession] = options.session || null; + + // `new tls.TLSSocket(socket, { isServer: true })`: drive the server-side TLS + // handshake over the provided socket via net.ts's native upgrade path (reaches + // the module-private kupgraded + the shared ServerHandlers). Client-side wraps + // go through the connect path elsewhere. + if (isNetSocketOrDuplex && this.isServer) { + this[Symbol.for("::bunUpgradeServerTLS::")](socket, this[buntls](null, null)); + } } $toClass(TLSSocket, "TLSSocket", NetSocket); +TLSSocket.prototype._destroySSL = function _destroySSL() { + // Releases the TLS state for this socket; the connection itself is torn + // down by the caller (Node's callers always destroy() right after). The + // native socket frees its SSL when it closes, so there is nothing to free + // separately here. + this.secureConnecting = false; + this._secureEstablished = false; +}; + TLSSocket.prototype._start = function _start() { // some frameworks uses this _start internal implementation is suposed to start TLS handshake/connect this.connect(); }; +TLSSocket.prototype._final = function _final(callback) { + // Defer the FIN until the TLS handshake completes. net.Socket._final calls + // socket.shutdown(), which while SSL is still in init half-closes the write + // side before the client's TLS Finished is flushed — the peer then sees a + // bare FIN and reports ECONNRESET (e.g. socket.end('') right after + // tls.connect()). Node's native TLSWrap.DoShutdown likewise flushes the + // handshake output before the underlying stream's FIN. + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/src/crypto/crypto_tls.cc#L1203 + // A never-connected TLSSocket (e.g. new tls.TLSSocket().end(cb)) has no handle + // and no handshake to wait for; finish immediately like NetSocket._final's + // no-handle fast path, otherwise the deferred callback would never fire. + if (!this._handle) return callback(); + if (this.secureConnecting) { + return this.once("secureConnect", NetSocket.prototype._final.bind(this, callback)); + } + return NetSocket.prototype._final.$call(this, callback); +}; + TLSSocket.prototype.getSession = function getSession() { return this._handle?.getSession?.(); }; TLSSocket.prototype.getEphemeralKeyInfo = function getEphemeralKeyInfo() { - return this._handle?.getEphemeralKeyInfo?.(); + const info = this._handle?.getEphemeralKeyInfo?.(); + if (info == null) return info; + // Node always returns an object shaped { type, name, size } (each undefined + // when there is no ephemeral key, e.g. a non-(EC)DHE key exchange). + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/_tls_wrap.js#L1437 + return { type: info.type, name: info.name, size: info.size }; }; TLSSocket.prototype.getCipher = function getCipher() { @@ -562,7 +989,10 @@ TLSSocket.prototype.getSharedSigalgs = function getSharedSigalgs() { }; TLSSocket.prototype.getProtocol = function getProtocol() { - return this._handle?.getTLSVersion?.(); + // Node returns the negotiated protocol string, or null once the socket is no + // longer connected (e.g. after 'close'). + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/_tls_wrap.js#L1455 + return this._handle?.getTLSVersion?.() ?? null; }; TLSSocket.prototype.getFinished = function getFinished() { @@ -578,6 +1008,18 @@ TLSSocket.prototype.isSessionReused = function isSessionReused() { }; TLSSocket.prototype.renegotiate = function renegotiate(options, callback) { + // https://github.com/nodejs/node/blob/v25.2.1/lib/_tls_wrap.js#L878 + if (options === null || typeof options !== "object") { + throw $ERR_INVALID_ARG_TYPE("options", "object", options); + } + if (callback !== undefined) { + validateFunction(callback, "callback"); + } + + if (this.destroyed) { + return; + } + if (this[krenegotiationDisabled]) { // if renegotiation is disabled should emit error event in nextTick for nodejs compatibility const error = $ERR_TLS_RENEGOTIATION_DISABLED(); @@ -589,29 +1031,22 @@ TLSSocket.prototype.renegotiate = function renegotiate(options, callback) { // if the socket is detached we can't renegotiate, nodejs do a noop too (we should not return false or true here) if (!socket) return; - if (options) { - let requestCert = !!this._requestCert; - let rejectUnauthorized = !!this._rejectUnauthorized; - - if (options.requestCert !== undefined) requestCert = !!options.requestCert; - if (options.rejectUnauthorized !== undefined) rejectUnauthorized = !!options.rejectUnauthorized; - - if (requestCert !== this._requestCert || rejectUnauthorized !== this._rejectUnauthorized) { - socket.setVerifyMode?.(requestCert, rejectUnauthorized); - this._requestCert = requestCert; - this._rejectUnauthorized = rejectUnauthorized; - } - } - try { - socket.renegotiate?.(); - // if renegotiate is successful should emit secure event when done - if (typeof callback === "function") this.once("secure", () => callback(null)); - return true; - } catch (err) { - // if renegotiate fails should emit error event in nextTick for nodejs compatibility - if (typeof callback === "function") process.nextTick(callback, err); - return false; + let requestCert = !!this._requestCert; + let rejectUnauthorized = !!this._rejectUnauthorized; + if (options.requestCert !== undefined) requestCert = !!options.requestCert; + if (options.rejectUnauthorized !== undefined) rejectUnauthorized = !!options.rejectUnauthorized; + if (requestCert !== this._requestCert || rejectUnauthorized !== this._rejectUnauthorized) { + socket.setVerifyMode?.(requestCert, rejectUnauthorized); + this._requestCert = requestCert; + this._rejectUnauthorized = rejectUnauthorized; } + + // BoringSSL does not implement TLS renegotiation; Node built against + // BoringSSL reports exactly this from renegotiate() regardless of the + // protocol version, and so do we. + const error = $ERR_TLS_RENEGOTIATION_UNSUPPORTED(); + if (typeof callback === "function") process.nextTick(callback, error); + return false; }; TLSSocket.prototype.disableRenegotiation = function disableRenegotiation() { @@ -624,7 +1059,24 @@ TLSSocket.prototype.getTLSTicket = function getTLSTicket() { return this._handle?.getTLSTicket?.(); }; +TLSSocket.prototype.setKeyCert = function setKeyCert(context) { + // Serve this connection's identity from the given context (Node calls this + // from ALPNCallback/SNICallback before the certificate is sent). Accepts a + // SecureContext or the same options object createSecureContext takes. + const ctx = context?.context ? context : new InternalSecureContext(context); + this._handle?.setKeyCert?.(ctx.context); +}; + TLSSocket.prototype.exportKeyingMaterial = function exportKeyingMaterial(length, label, context) { + // https://github.com/nodejs/node/blob/v25.2.1/lib/internal/tls/wrap.js#L1039 + validateUint32(length, "length", true); + validateString(label, "label"); + if (context !== undefined) validateBuffer(context, "context"); + + if (!this._secureEstablished) { + throw $ERR_TLS_INVALID_STATE(); + } + if (context) { return this._handle?.exportKeyingMaterial?.(length, label, context); } @@ -640,6 +1092,7 @@ TLSSocket.prototype.enableTrace = function enableTrace() { }; TLSSocket.prototype.setServername = function setServername(name) { + validateString(name, "name"); if (this.isServer) { throw $ERR_TLS_SNI_FROM_SERVER(); } @@ -654,10 +1107,13 @@ TLSSocket.prototype.setSession = function setSession(session) { return this._handle?.setSession?.(session); }; -TLSSocket.prototype.getPeerCertificate = function getPeerCertificate(abbreviated) { +TLSSocket.prototype.getPeerCertificate = function getPeerCertificate(detailed) { if (this._handle) { + // The native parameter means "abbreviated" - the inverse of Node's + // `detailed`. Detailed requests get the whole chain with + // issuerCertificate links; everything else gets just the leaf. const cert = - arguments.length < 1 ? this._handle.getPeerCertificate?.() : this._handle.getPeerCertificate?.(abbreviated); + arguments.length < 1 ? this._handle.getPeerCertificate?.() : this._handle.getPeerCertificate?.(!detailed); if (cert) { return translatePeerCertificate(cert); } @@ -676,7 +1132,36 @@ TLSSocket.prototype.getCertificate = function getCertificate() { }; TLSSocket.prototype.getPeerX509Certificate = function getPeerX509Certificate() { - return this._handle?.getPeerX509Certificate?.(); + // Build the X509Certificate chain from the detailed peer-certificate + // objects, linking each to its issuer the way Node does. The + // `issuerCertificate` own property shadows the prototype getter (which is + // always undefined for certificates parsed outside a TLS connection). + const cert = this.getPeerCertificate(true); + if (!cert || !cert.raw) { + return this._handle?.getPeerX509Certificate?.(); + } + const { X509Certificate } = require("node:crypto"); + const seen = new Map(); + const toX509 = chainCert => { + if (!chainCert || !chainCert.raw) return undefined; + const cached = seen.get(chainCert); + if (cached) return cached; + const x509 = new X509Certificate(chainCert.raw); + seen.set(chainCert, x509); + if (chainCert.issuerCertificate && chainCert.issuerCertificate !== chainCert) { + const issuer = toX509(chainCert.issuerCertificate); + if (issuer) { + Object.defineProperty(x509, "issuerCertificate", { + __proto__: null, + value: issuer, + configurable: true, + enumerable: false, + }); + } + } + return x509; + }; + return toX509(cert); }; TLSSocket.prototype.getX509Certificate = function getX509Certificate() { @@ -715,6 +1200,30 @@ function Server(options, secureConnectionListener): void { return new Server(options, secureConnectionListener); } + // tls.createServer(options) requires an object (a function is the connection + // listener); matches Node throwing ERR_INVALID_ARG_TYPE for e.g. a string. + if (options != null && typeof options !== "object" && typeof options !== "function") { + throw $ERR_INVALID_ARG_TYPE("options", "object", options); + } + // A custom SNICallback must be a function. + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/wrap.js#L929 + if (options != null && typeof options === "object") { + const sniCallback = options.SNICallback; + if (sniCallback != null) { + validateFunction(sniCallback, "options.SNICallback"); + this._SNICallback = sniCallback; + } + const alpnCallback = options.ALPNCallback; + if (alpnCallback != null) { + validateFunction(alpnCallback, "options.ALPNCallback"); + // Node forbids combining the dynamic callback with a static list. + if (options.ALPNProtocols) { + throw $ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS(); + } + this._ALPNCallback = alpnCallback; + } + } + NetServer.$apply(this, [options, secureConnectionListener]); this.key = undefined; @@ -734,7 +1243,7 @@ function Server(options, secureConnectionListener): void { throw new TypeError("hostname must be a string"); } if (!(context instanceof InternalSecureContext)) { - context = createSecureContext(context); + context = new InternalSecureContext(context); } if (this._handle) { // Pass the native SSL_CTX wrapper, not the JS InternalSecureContext — @@ -751,29 +1260,88 @@ function Server(options, secureConnectionListener): void { options = options.context; } if (options) { + validateSecureContextOptions(options); + options = processPfxOptions(options); const { ALPNProtocols } = options; if (ALPNProtocols) { convertALPNProtocols(ALPNProtocols, this); + } else { + // An omitted ALPNProtocols clears the previous call's protocols. + this.ALPNProtocols = undefined; } let cert = options.cert; + // Assign unconditionally so a later setSecureContext() that omits an + // option clears the previous call's value (Node resets each omitted + // field) instead of silently keeping stale key material. if (cert) { throwOnInvalidTLSArray("options.cert", cert); - this.cert = cert; } + this.cert = cert; let key = options.key; if (key) { throwOnInvalidTLSArray("options.key", key); - this.key = key; + } + this.key = key; + + // BoringSSL rejects a mixed EC/RSA multi-identity configuration while + // loading the chain. The native context is built lazily at listen time, + // so surface the most common mismatch synchronously here: a key whose + // type differs from its own index-paired certificate. This is a + // best-effort check - the native loader at listen time remains the + // authority and still rejects configurations that pass it. + if (Array.isArray(key) && key.length > 1 && cert) { + const certs = Array.isArray(cert) ? cert : [cert]; + try { + const { createPrivateKey, X509Certificate } = require("node:crypto"); + for (let i = 0; i < key.length; i++) { + const k = key[i]; + if (typeof k !== "string" && !$isTypedArrayView(k)) continue; + const pairedCert = certs[i < certs.length ? i : certs.length - 1]; + const certType = new X509Certificate(pairedCert).publicKey.asymmetricKeyType; + if (createPrivateKey(k).asymmetricKeyType !== certType) { + const err = new Error( + "error:0b000074:X.509 certificate routines:OPENSSL_internal:KEY_TYPE_MISMATCH", + ) as Error & { code: string; library: string; function: string; reason: string }; + err.code = "ERR_OSSL_X509_KEY_TYPE_MISMATCH"; + err.library = "X.509 certificate routines"; + err.function = "OPENSSL_internal"; + err.reason = "KEY_TYPE_MISMATCH"; + throw err; + } + } + } catch (e: any) { + if (e?.code === "ERR_OSSL_X509_KEY_TYPE_MISMATCH") throw e; + // An unparseable key or certificate falls through to the native + // load, which produces its own error. + } } let ca = options.ca; + // The process-wide default-CA override (tls.setDefaultCACertificates) + // applies here too when no explicit `ca` was given: this path hands raw + // {key, cert, ca} to the native listener and never goes through + // InternalSecureContext, so without this an mTLS server would verify + // client certificates against the bundled roots instead of the + // overridden defaults. + if (_defaultCACertificatesOverride !== undefined && ca == null) { + ca = _defaultCACertificatesOverride; + } + // PKCS#12-embedded CAs are stashed separately so createSecureContext can + // extend (not replace) the default trust set via addCACert. The server + // path hands raw {key, cert, ca} to the native listener and has no + // addCACert hook, so fold them into `ca` here - an mTLS server should + // verify client certificates against the bundle's own CA chain. + const pfxExtraCAs = options._pfxExtraCACerts; + if (pfxExtraCAs?.length) { + ca = ca == null ? pfxExtraCAs : Array.isArray(ca) ? [...ca, ...pfxExtraCAs] : [ca, ...pfxExtraCAs]; + } if (ca) { throwOnInvalidTLSArray("options.ca", ca); - this.ca = ca; } + this.ca = ca; let passphrase = options.passphrase; if (passphrase && typeof passphrase !== "string") { @@ -810,17 +1378,37 @@ function Server(options, secureConnectionListener): void { } validateCiphers(options.ciphers); - - this.ciphers = options.ciphers; } + // Unconditional so an omitted `ciphers` clears the previous value. + this.ciphers = options.ciphers; + + // Pin the protocol version range the server will negotiate. + // validateSecureContextOptions already rejected unknown method names. + // Assign unconditionally so a later setSecureContext() without these + // options clears the previous call's version constraints instead of + // re-applying them on the next listen. + this.secureProtocol = options.secureProtocol; + this.minVersion = options.minVersion; + this.maxVersion = options.maxVersion; } }; + // Lets net.ts's SNI dispatch recognize a raw native SecureContext handed to + // an SNICallback (the `context.context || context` unwrap accepts both the + // wrapper and the unwrapped native context). + Server.prototype[kNativeSecureContextCtor] = NativeSecureContext; + Server.prototype.getTicketKeys = function () { throw Error("Not implented in Bun yet"); }; - Server.prototype.setTicketKeys = function () { + Server.prototype.setTicketKeys = function (keys) { + if (!ArrayBuffer.isView(keys)) { + throw $ERR_INVALID_ARG_TYPE("buffer", ["Buffer", "TypedArray", "DataView"], keys); + } + if (keys.byteLength !== 48) { + throw $ERR_INVALID_ARG_VALUE("buffer", keys, "Session ticket keys must be a 48-byte buffer"); + } throw Error("Not implented in Bun yet"); }; @@ -840,23 +1428,60 @@ function Server(options, secureConnectionListener): void { clientRenegotiationWindow: CLIENT_RENEG_WINDOW, contexts: contexts, ciphers: this.ciphers, + // Translate minVersion/maxVersion/secureProtocol to the integer + // protocol range the native layer applies (secureProtocol wins, like + // Node's SecureContext::Init). When none are given the module-level + // tls.DEFAULT_MIN_VERSION / DEFAULT_MAX_VERSION apply. + ...(() => { + let minVersion, maxVersion; + const range = secureProtocolToVersionRange(this.secureProtocol); + if (range) { + minVersion = range[0]; + maxVersion = range[1]; + } else { + minVersion = tlsStringToProtocolVersion(this.minVersion ?? DEFAULT_MIN_VERSION); + maxVersion = tlsStringToProtocolVersion(this.maxVersion ?? DEFAULT_MAX_VERSION); + } + return { minVersion, maxVersion }; + })(), }, TLSSocket, ]; }; this.setSecureContext(options); + maybeWarnAboutExtraCACerts(); + // Matches Node's tls.Server handshakeTimeout default + validation: + // https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/internal/tls/wrap.js#L1386 + const handshakeTimeout = (options && options.handshakeTimeout) || 120 * 1000; + validateNumber(handshakeTimeout, "options.handshakeTimeout"); + this._handshakeTimeout = handshakeTimeout; } $toClass(Server, "Server", NetServer); function createServer(options, connectionListener) { return new Server(options, connectionListener); } -const DEFAULT_ECDH_CURVE = "auto", - // https://github.com/Jarred-Sumner/uSockets/blob/fafc241e8664243fc0c51d69684d5d02b9805134/src/crypto/openssl.c#L519-L523 - DEFAULT_MIN_VERSION = "TLSv1.2", +const DEFAULT_ECDH_CURVE = "auto"; +// https://github.com/Jarred-Sumner/uSockets/blob/fafc241e8664243fc0c51d69684d5d02b9805134/src/crypto/openssl.c#L519-L523 +let DEFAULT_MIN_VERSION = "TLSv1.2", DEFAULT_MAX_VERSION = "TLSv1.3"; +// Node seeds the protocol-version defaults from its --tls-min-vX.Y / +// --tls-max-vX.Y CLI flags; the equivalent flags reach us through +// process.execArgv. The lowest requested minimum and the highest requested +// maximum win when several are passed, matching node_options precedence. +{ + const execArgv = process.execArgv; + const hasFlag = (flag: string) => execArgv.includes(flag); + if (hasFlag("--tls-min-v1.0")) DEFAULT_MIN_VERSION = "TLSv1"; + else if (hasFlag("--tls-min-v1.1")) DEFAULT_MIN_VERSION = "TLSv1.1"; + else if (hasFlag("--tls-min-v1.2")) DEFAULT_MIN_VERSION = "TLSv1.2"; + else if (hasFlag("--tls-min-v1.3")) DEFAULT_MIN_VERSION = "TLSv1.3"; + if (hasFlag("--tls-max-v1.3")) DEFAULT_MAX_VERSION = "TLSv1.3"; + else if (hasFlag("--tls-max-v1.2")) DEFAULT_MAX_VERSION = "TLSv1.2"; +} + function normalizeConnectArgs(listArgs) { const args = net._normalizeArgs(listArgs); $assert($isObject(args[0])); @@ -883,6 +1508,12 @@ function connect(...args) { const options = normal[0]; const { ALPNProtocols, servername } = options as { ALPNProtocols?: unknown; servername?: unknown }; + if ("checkServerIdentity" in options) { + // Node validates whenever the key is present - an explicit `undefined` + // throws ERR_INVALID_ARG_TYPE (test-tls-basic-validations). + validateFunction(options.checkServerIdentity, "options.checkServerIdentity"); + } + if (servername && net.isIP(servername)) { throw $ERR_INVALID_ARG_VALUE( "options.servername", @@ -895,7 +1526,15 @@ function connect(...args) { convertALPNProtocols(ALPNProtocols, options); } - return new TLSSocket(options).connect(normal); + const tlssock = new TLSSocket(options); + // Honor the `timeout` option here: Socket.prototype.connect does not (only + // the net.createConnection factory does), so tls.connect applies it + // explicitly, exactly like Node's tls connect. + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/wrap.js#L1791 + if (options.timeout) { + tlssock.setTimeout(options.timeout); + } + return tlssock.connect(normal); } function getCiphers() { @@ -912,9 +1551,11 @@ function convertProtocols(protocols) { (p, c, i) => { const len = Buffer.byteLength(c); if (len > 255) { - throw new RangeError( + const err = new RangeError( `The byte length of the protocol at index ${i} exceeds the maximum length. It must be <= 255. Received ${len}`, ); + (err as any).code = "ERR_OUT_OF_RANGE"; + throw err; } lens[i] = len; return p + 1 + len; @@ -933,19 +1574,17 @@ function convertProtocols(protocols) { return buff; } +// Matches Node's convertALPNProtocols: +// https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/tls.js#L268 function convertALPNProtocols(protocols, out) { // If protocols is Array - translate it into buffer if (Array.isArray(protocols)) { out.ALPNProtocols = convertProtocols(protocols); - } else if (isTypedArray(protocols)) { - // Copy new buffer not to be modified by user. - out.ALPNProtocols = Buffer.from(protocols); } else if (isArrayBufferView(protocols)) { + // Copy new buffer not to be modified by user. out.ALPNProtocols = Buffer.from( protocols.buffer.slice(protocols.byteOffset, protocols.byteOffset + protocols.byteLength), ); - } else if (Buffer.isBuffer(protocols)) { - out.ALPNProtocols = protocols; } } @@ -997,11 +1636,112 @@ function cacheExtraCACertificates(): string[] { return extraCACertificates; } +let warnedAboutExtraCACerts = false; +/** + * Match Node's crypto_context.cc: a NODE_EXTRA_CA_CERTS file that cannot be + * loaded is ignored with a one-time warning on stderr - emitted when the + * first secure context is created, not at startup - rather than failing the + * process. The reason text mirrors the strerror()-derived string Node prints. + */ +function maybeWarnAboutExtraCACerts() { + if (warnedAboutExtraCACerts) return; + warnedAboutExtraCACerts = true; + const extraPath = process.env.NODE_EXTRA_CA_CERTS; + if (!extraPath) return; + try { + require("node:fs").accessSync(extraPath); + } catch (err: any) { + // Node prints this with a raw fprintf(stderr, ...) from + // crypto_context.cc, not through process.emitWarning - no pid prefix and + // no colorization. + process.stderr.write( + `Warning: Ignoring extra certs from \`${extraPath}\`, load failed: ${ + err?.code === "ENOENT" ? "No such file or directory" : err?.message + }\n`, + ); + } +} + +// Runtime override for the "default" CA certificate set, installed by +// tls.setDefaultCACertificates(). undefined = no override (use the real +// bundled/system default). Only affects type "default"/implicit — "bundled", +// "system" and "extra" are unchanged. +// https://github.com/nodejs/node/blob/main/lib/internal/tls/secure-context.js +let _defaultCACertificatesOverride: Array | undefined; + +type CACertInput = string | NodeJS.ArrayBufferView; +interface X509CertificateLike { + readonly fingerprint256: string; + toString(): string; +} +type X509CertificateCtor = new (cert: CACertInput) => X509CertificateLike; +let _X509CertificateClass: X509CertificateCtor | undefined; + +// tls.setDefaultCACertificates(certs) +// https://github.com/nodejs/node/blob/v25.2.1/lib/tls.js#L202 +// Node validates `certs` as an Array (its ERR_INVALID_ARG_TYPE renders the +// 'Array' name as "an instance of Array"; Bun's validateArray renders the same +// name as "of type Array", so build the error directly to match Node here), +// then hands the certs to the native root store. Bun has no equivalent native +// store override, so keep a JS-side override that getCACertificates('default') +// and createSecureContext() read. +function setDefaultCACertificates(certs: ReadonlyArray): void { + if (!$isArray(certs)) { + let received: string; + if (certs === null) received = "null"; + else if (typeof certs === "object") received = `an instance of ${(certs as object).constructor?.name ?? "Object"}`; + else if (typeof certs === "string") received = `type string ('${certs}')`; + else received = `type ${typeof certs} (${String(certs)})`; + const error = new TypeError(`The "certs" argument must be an instance of Array. Received ${received}`) as Error & { + code: string; + }; + error.code = "ERR_INVALID_ARG_TYPE"; + throw error; + } + _X509CertificateClass ??= require("node:crypto").X509Certificate as X509CertificateCtor; + // Parse each cert and de-duplicate by fingerprint so getCACertificates() + // returns a normalized, unique PEM set (matching Node, whose native store + // collapses duplicates). Build into a temp array and only commit on success, + // so an invalid element leaves the previous default untouched. + const seen = new Set(); + const normalized: Array = []; + for (let i = 0; i < certs.length; i++) { + const cert = certs[i]; + if (typeof cert !== "string" && !isArrayBufferView(cert)) { + throw $ERR_INVALID_ARG_TYPE(`certs[${i}]`, "string or an instance of ArrayBufferView", cert); + } + // An element may be a concatenated PEM bundle; Node adds every certificate + // it contains, so split on certificate boundaries before parsing (a single + // X509Certificate parse only consumes the first block). + const text = + typeof cert === "string" ? cert : Buffer.from(cert.buffer, cert.byteOffset, cert.byteLength).toString("latin1"); + const blocks = text.includes("-----BEGIN") + ? // Keep only the blocks that actually start a PEM certificate: bundle + // files routinely begin with comment headers (curl's cacert.pem, + // RHEL's ca-bundle.crt) that the lookahead split leaves as a leading + // non-PEM element. + text.split(/(?=-----BEGIN [A-Z0-9 ]*CERTIFICATE-----)/).filter(block => block.includes("CERTIFICATE-----")) + : [cert]; + for (const block of blocks) { + const x509 = new _X509CertificateClass(block as CACertInput); + const fingerprint = x509.fingerprint256; + if (!seen.has(fingerprint)) { + seen.add(fingerprint); + normalized.push(x509.toString()); + } + } + } + _defaultCACertificatesOverride = normalized; +} + function getCACertificates(type = "default") { validateString(type, "type"); switch (type) { case "default": + if (_defaultCACertificatesOverride !== undefined) { + return _defaultCACertificatesOverride.slice(); + } return cacheDefaultCACertificates(); case "bundled": return cacheBundledRootCertificates(); @@ -1044,9 +1784,23 @@ export default { setTLSDefaultCiphers(value); }, DEFAULT_ECDH_CURVE, - DEFAULT_MAX_VERSION, - DEFAULT_MIN_VERSION, + // Accessors so `tls.DEFAULT_MAX_VERSION = 'TLSv1.2'` reaches the + // module-level variables that context construction reads (Node mutates the + // exports object the same way). + get DEFAULT_MAX_VERSION() { + return DEFAULT_MAX_VERSION; + }, + set DEFAULT_MAX_VERSION(value) { + DEFAULT_MAX_VERSION = value; + }, + get DEFAULT_MIN_VERSION() { + return DEFAULT_MIN_VERSION; + }, + set DEFAULT_MIN_VERSION(value) { + DEFAULT_MIN_VERSION = value; + }, getCiphers, + setDefaultCACertificates, parseCertString, SecureContext, Server, diff --git a/src/jsc/ErrorCode.rs b/src/jsc/ErrorCode.rs index 3be6659c1881..28024ed1ae0f 100644 --- a/src/jsc/ErrorCode.rs +++ b/src/jsc/ErrorCode.rs @@ -549,150 +549,154 @@ impl ErrorCode { pub const TLS_PSK_SET_IDENTITY_HINT_FAILED: ErrorCode = ErrorCode(245); /// `ERR_TLS_RENEGOTIATION_DISABLED` (instanceof Error) pub const TLS_RENEGOTIATION_DISABLED: ErrorCode = ErrorCode(246); + /// `ERR_TLS_RENEGOTIATION_UNSUPPORTED` (instanceof Error) + pub const TLS_RENEGOTIATION_UNSUPPORTED: ErrorCode = ErrorCode(247); /// `ERR_TLS_SNI_FROM_SERVER` (instanceof Error) - pub const TLS_SNI_FROM_SERVER: ErrorCode = ErrorCode(247); + pub const TLS_SNI_FROM_SERVER: ErrorCode = ErrorCode(248); + /// `ERR_TLS_INVALID_STATE` (instanceof Error) + pub const TLS_INVALID_STATE: ErrorCode = ErrorCode(249); /// `ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS` (instanceof TypeError) - pub const TLS_ALPN_CALLBACK_WITH_PROTOCOLS: ErrorCode = ErrorCode(248); + pub const TLS_ALPN_CALLBACK_WITH_PROTOCOLS: ErrorCode = ErrorCode(250); /// `ERR_SSL_NO_CIPHER_MATCH` (instanceof Error) - pub const SSL_NO_CIPHER_MATCH: ErrorCode = ErrorCode(249); + pub const SSL_NO_CIPHER_MATCH: ErrorCode = ErrorCode(251); /// `ERR_UNAVAILABLE_DURING_EXIT` (instanceof Error) - pub const UNAVAILABLE_DURING_EXIT: ErrorCode = ErrorCode(250); + pub const UNAVAILABLE_DURING_EXIT: ErrorCode = ErrorCode(252); /// `ERR_UNCAUGHT_EXCEPTION_CAPTURE_ALREADY_SET` (instanceof Error) - pub const UNCAUGHT_EXCEPTION_CAPTURE_ALREADY_SET: ErrorCode = ErrorCode(251); + pub const UNCAUGHT_EXCEPTION_CAPTURE_ALREADY_SET: ErrorCode = ErrorCode(253); /// `ERR_UNESCAPED_CHARACTERS` (instanceof TypeError) - pub const UNESCAPED_CHARACTERS: ErrorCode = ErrorCode(252); + pub const UNESCAPED_CHARACTERS: ErrorCode = ErrorCode(254); /// `ERR_UNHANDLED_ERROR` (instanceof Error) - pub const UNHANDLED_ERROR: ErrorCode = ErrorCode(253); + pub const UNHANDLED_ERROR: ErrorCode = ErrorCode(255); /// `ERR_UNKNOWN_CREDENTIAL` (instanceof Error) - pub const UNKNOWN_CREDENTIAL: ErrorCode = ErrorCode(254); + pub const UNKNOWN_CREDENTIAL: ErrorCode = ErrorCode(256); /// `ERR_UNKNOWN_ENCODING` (instanceof TypeError) - pub const UNKNOWN_ENCODING: ErrorCode = ErrorCode(255); + pub const UNKNOWN_ENCODING: ErrorCode = ErrorCode(257); /// `ERR_UNKNOWN_SIGNAL` (instanceof TypeError) - pub const UNKNOWN_SIGNAL: ErrorCode = ErrorCode(256); + pub const UNKNOWN_SIGNAL: ErrorCode = ErrorCode(258); /// `ERR_ZSTD_INVALID_PARAM` (instanceof RangeError) - pub const ZSTD_INVALID_PARAM: ErrorCode = ErrorCode(257); + pub const ZSTD_INVALID_PARAM: ErrorCode = ErrorCode(259); /// `ERR_USE_AFTER_CLOSE` (instanceof Error) - pub const USE_AFTER_CLOSE: ErrorCode = ErrorCode(258); + pub const USE_AFTER_CLOSE: ErrorCode = ErrorCode(260); /// `ERR_WASI_NOT_STARTED` (instanceof Error) - pub const WASI_NOT_STARTED: ErrorCode = ErrorCode(259); + pub const WASI_NOT_STARTED: ErrorCode = ErrorCode(261); /// `ERR_WEBASSEMBLY_RESPONSE` (instanceof TypeError) - pub const WEBASSEMBLY_RESPONSE: ErrorCode = ErrorCode(260); + pub const WEBASSEMBLY_RESPONSE: ErrorCode = ErrorCode(262); /// `ERR_WORKER_INIT_FAILED` (instanceof Error) - pub const WORKER_INIT_FAILED: ErrorCode = ErrorCode(261); + pub const WORKER_INIT_FAILED: ErrorCode = ErrorCode(263); /// `ERR_WORKER_NOT_RUNNING` (instanceof Error) - pub const WORKER_NOT_RUNNING: ErrorCode = ErrorCode(262); + pub const WORKER_NOT_RUNNING: ErrorCode = ErrorCode(264); /// `ERR_WORKER_UNSUPPORTED_OPERATION` (instanceof TypeError) - pub const WORKER_UNSUPPORTED_OPERATION: ErrorCode = ErrorCode(263); + pub const WORKER_UNSUPPORTED_OPERATION: ErrorCode = ErrorCode(265); /// `ERR_ZLIB_INITIALIZATION_FAILED` (instanceof Error) - pub const ZLIB_INITIALIZATION_FAILED: ErrorCode = ErrorCode(264); + pub const ZLIB_INITIALIZATION_FAILED: ErrorCode = ErrorCode(266); /// `MODULE_NOT_FOUND` (instanceof Error) - pub const MODULE_NOT_FOUND: ErrorCode = ErrorCode(265); + pub const MODULE_NOT_FOUND: ErrorCode = ErrorCode(267); /// `ERR_INTERNAL_ASSERTION` (instanceof Error) - pub const INTERNAL_ASSERTION: ErrorCode = ErrorCode(266); + pub const INTERNAL_ASSERTION: ErrorCode = ErrorCode(268); /// `ERR_OSSL_EVP_INVALID_DIGEST` (instanceof Error) - pub const OSSL_EVP_INVALID_DIGEST: ErrorCode = ErrorCode(267); + pub const OSSL_EVP_INVALID_DIGEST: ErrorCode = ErrorCode(269); /// `ERR_KEY_GENERATION_JOB_FAILED` (instanceof Error) - pub const KEY_GENERATION_JOB_FAILED: ErrorCode = ErrorCode(268); + pub const KEY_GENERATION_JOB_FAILED: ErrorCode = ErrorCode(270); /// `ERR_MISSING_OPTION` (instanceof TypeError) - pub const MISSING_OPTION: ErrorCode = ErrorCode(269); + pub const MISSING_OPTION: ErrorCode = ErrorCode(271); /// `ERR_REDIS_AUTHENTICATION_FAILED` (instanceof Error) - pub const REDIS_AUTHENTICATION_FAILED: ErrorCode = ErrorCode(270); + pub const REDIS_AUTHENTICATION_FAILED: ErrorCode = ErrorCode(272); /// `ERR_REDIS_CONNECTION_CLOSED` (instanceof Error) - pub const REDIS_CONNECTION_CLOSED: ErrorCode = ErrorCode(271); + pub const REDIS_CONNECTION_CLOSED: ErrorCode = ErrorCode(273); /// `ERR_REDIS_CONNECTION_TIMEOUT` (instanceof Error) - pub const REDIS_CONNECTION_TIMEOUT: ErrorCode = ErrorCode(272); + pub const REDIS_CONNECTION_TIMEOUT: ErrorCode = ErrorCode(274); /// `ERR_REDIS_IDLE_TIMEOUT` (instanceof Error) - pub const REDIS_IDLE_TIMEOUT: ErrorCode = ErrorCode(273); + pub const REDIS_IDLE_TIMEOUT: ErrorCode = ErrorCode(275); /// `ERR_REDIS_INVALID_ARGUMENT` (instanceof Error) - pub const REDIS_INVALID_ARGUMENT: ErrorCode = ErrorCode(274); + pub const REDIS_INVALID_ARGUMENT: ErrorCode = ErrorCode(276); /// `ERR_REDIS_INVALID_ARRAY` (instanceof Error) - pub const REDIS_INVALID_ARRAY: ErrorCode = ErrorCode(275); + pub const REDIS_INVALID_ARRAY: ErrorCode = ErrorCode(277); /// `ERR_REDIS_INVALID_BULK_STRING` (instanceof Error) - pub const REDIS_INVALID_BULK_STRING: ErrorCode = ErrorCode(276); + pub const REDIS_INVALID_BULK_STRING: ErrorCode = ErrorCode(278); /// `ERR_REDIS_INVALID_COMMAND` (instanceof Error) - pub const REDIS_INVALID_COMMAND: ErrorCode = ErrorCode(277); + pub const REDIS_INVALID_COMMAND: ErrorCode = ErrorCode(279); /// `ERR_REDIS_INVALID_DATABASE` (instanceof Error) - pub const REDIS_INVALID_DATABASE: ErrorCode = ErrorCode(278); + pub const REDIS_INVALID_DATABASE: ErrorCode = ErrorCode(280); /// `ERR_REDIS_INVALID_ERROR_STRING` (instanceof Error) - pub const REDIS_INVALID_ERROR_STRING: ErrorCode = ErrorCode(279); + pub const REDIS_INVALID_ERROR_STRING: ErrorCode = ErrorCode(281); /// `ERR_REDIS_INVALID_INTEGER` (instanceof Error) - pub const REDIS_INVALID_INTEGER: ErrorCode = ErrorCode(280); + pub const REDIS_INVALID_INTEGER: ErrorCode = ErrorCode(282); /// `ERR_REDIS_INVALID_PASSWORD` (instanceof Error) - pub const REDIS_INVALID_PASSWORD: ErrorCode = ErrorCode(281); + pub const REDIS_INVALID_PASSWORD: ErrorCode = ErrorCode(283); /// `ERR_REDIS_INVALID_RESPONSE` (instanceof Error) - pub const REDIS_INVALID_RESPONSE: ErrorCode = ErrorCode(282); + pub const REDIS_INVALID_RESPONSE: ErrorCode = ErrorCode(284); /// `ERR_REDIS_INVALID_RESPONSE_TYPE` (instanceof Error) - pub const REDIS_INVALID_RESPONSE_TYPE: ErrorCode = ErrorCode(283); + pub const REDIS_INVALID_RESPONSE_TYPE: ErrorCode = ErrorCode(285); /// `ERR_REDIS_INVALID_SIMPLE_STRING` (instanceof Error) - pub const REDIS_INVALID_SIMPLE_STRING: ErrorCode = ErrorCode(284); + pub const REDIS_INVALID_SIMPLE_STRING: ErrorCode = ErrorCode(286); /// `ERR_REDIS_INVALID_STATE` (instanceof Error) - pub const REDIS_INVALID_STATE: ErrorCode = ErrorCode(285); + pub const REDIS_INVALID_STATE: ErrorCode = ErrorCode(287); /// `ERR_REDIS_INVALID_USERNAME` (instanceof Error) - pub const REDIS_INVALID_USERNAME: ErrorCode = ErrorCode(286); + pub const REDIS_INVALID_USERNAME: ErrorCode = ErrorCode(288); /// `ERR_REDIS_TLS_NOT_AVAILABLE` (instanceof Error) - pub const REDIS_TLS_NOT_AVAILABLE: ErrorCode = ErrorCode(287); + pub const REDIS_TLS_NOT_AVAILABLE: ErrorCode = ErrorCode(289); /// `ERR_REDIS_TLS_UPGRADE_FAILED` (instanceof Error) - pub const REDIS_TLS_UPGRADE_FAILED: ErrorCode = ErrorCode(288); + pub const REDIS_TLS_UPGRADE_FAILED: ErrorCode = ErrorCode(290); /// `HPE_UNEXPECTED_CONTENT_LENGTH` (instanceof Error) - pub const HPE_UNEXPECTED_CONTENT_LENGTH: ErrorCode = ErrorCode(289); + pub const HPE_UNEXPECTED_CONTENT_LENGTH: ErrorCode = ErrorCode(291); /// `HPE_INVALID_TRANSFER_ENCODING` (instanceof Error) - pub const HPE_INVALID_TRANSFER_ENCODING: ErrorCode = ErrorCode(290); + pub const HPE_INVALID_TRANSFER_ENCODING: ErrorCode = ErrorCode(292); /// `HPE_INVALID_EOF_STATE` (instanceof Error) - pub const HPE_INVALID_EOF_STATE: ErrorCode = ErrorCode(291); + pub const HPE_INVALID_EOF_STATE: ErrorCode = ErrorCode(293); /// `HPE_INVALID_METHOD` (instanceof Error) - pub const HPE_INVALID_METHOD: ErrorCode = ErrorCode(292); + pub const HPE_INVALID_METHOD: ErrorCode = ErrorCode(294); /// `HPE_INTERNAL` (instanceof Error) - pub const HPE_INTERNAL: ErrorCode = ErrorCode(293); + pub const HPE_INTERNAL: ErrorCode = ErrorCode(295); /// `ERR_VM_MODULE_STATUS` (instanceof Error) - pub const VM_MODULE_STATUS: ErrorCode = ErrorCode(294); + pub const VM_MODULE_STATUS: ErrorCode = ErrorCode(296); /// `ERR_VM_MODULE_ALREADY_LINKED` (instanceof Error) - pub const VM_MODULE_ALREADY_LINKED: ErrorCode = ErrorCode(295); + pub const VM_MODULE_ALREADY_LINKED: ErrorCode = ErrorCode(297); /// `ERR_VM_MODULE_CANNOT_CREATE_CACHED_DATA` (instanceof Error) - pub const VM_MODULE_CANNOT_CREATE_CACHED_DATA: ErrorCode = ErrorCode(296); + pub const VM_MODULE_CANNOT_CREATE_CACHED_DATA: ErrorCode = ErrorCode(298); /// `ERR_VM_MODULE_NOT_MODULE` (instanceof Error) - pub const VM_MODULE_NOT_MODULE: ErrorCode = ErrorCode(297); + pub const VM_MODULE_NOT_MODULE: ErrorCode = ErrorCode(299); /// `ERR_VM_MODULE_DIFFERENT_CONTEXT` (instanceof Error) - pub const VM_MODULE_DIFFERENT_CONTEXT: ErrorCode = ErrorCode(298); + pub const VM_MODULE_DIFFERENT_CONTEXT: ErrorCode = ErrorCode(300); /// `ERR_VM_MODULE_LINK_FAILURE` (instanceof Error) - pub const VM_MODULE_LINK_FAILURE: ErrorCode = ErrorCode(299); + pub const VM_MODULE_LINK_FAILURE: ErrorCode = ErrorCode(301); /// `ERR_VM_MODULE_CACHED_DATA_REJECTED` (instanceof Error) - pub const VM_MODULE_CACHED_DATA_REJECTED: ErrorCode = ErrorCode(300); + pub const VM_MODULE_CACHED_DATA_REJECTED: ErrorCode = ErrorCode(302); /// `ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING` (instanceof TypeError) - pub const VM_DYNAMIC_IMPORT_CALLBACK_MISSING: ErrorCode = ErrorCode(301); + pub const VM_DYNAMIC_IMPORT_CALLBACK_MISSING: ErrorCode = ErrorCode(303); /// `HPE_INVALID_HEADER_TOKEN` (instanceof Error) - pub const HPE_INVALID_HEADER_TOKEN: ErrorCode = ErrorCode(302); + pub const HPE_INVALID_HEADER_TOKEN: ErrorCode = ErrorCode(304); /// `HPE_HEADER_OVERFLOW` (instanceof Error) - pub const HPE_HEADER_OVERFLOW: ErrorCode = ErrorCode(303); + pub const HPE_HEADER_OVERFLOW: ErrorCode = ErrorCode(305); /// `ERR_SECRETS_NOT_AVAILABLE` (instanceof Error) - pub const SECRETS_NOT_AVAILABLE: ErrorCode = ErrorCode(304); + pub const SECRETS_NOT_AVAILABLE: ErrorCode = ErrorCode(306); /// `ERR_SECRETS_NOT_FOUND` (instanceof Error) - pub const SECRETS_NOT_FOUND: ErrorCode = ErrorCode(305); + pub const SECRETS_NOT_FOUND: ErrorCode = ErrorCode(307); /// `ERR_SECRETS_ACCESS_DENIED` (instanceof Error) - pub const SECRETS_ACCESS_DENIED: ErrorCode = ErrorCode(306); + pub const SECRETS_ACCESS_DENIED: ErrorCode = ErrorCode(308); /// `ERR_SECRETS_PLATFORM_ERROR` (instanceof Error) - pub const SECRETS_PLATFORM_ERROR: ErrorCode = ErrorCode(307); + pub const SECRETS_PLATFORM_ERROR: ErrorCode = ErrorCode(309); /// `ERR_SECRETS_USER_CANCELED` (instanceof Error) - pub const SECRETS_USER_CANCELED: ErrorCode = ErrorCode(308); + pub const SECRETS_USER_CANCELED: ErrorCode = ErrorCode(310); /// `ERR_SECRETS_INTERACTION_NOT_ALLOWED` (instanceof Error) - pub const SECRETS_INTERACTION_NOT_ALLOWED: ErrorCode = ErrorCode(309); + pub const SECRETS_INTERACTION_NOT_ALLOWED: ErrorCode = ErrorCode(311); /// `ERR_SECRETS_AUTH_FAILED` (instanceof Error) - pub const SECRETS_AUTH_FAILED: ErrorCode = ErrorCode(310); + pub const SECRETS_AUTH_FAILED: ErrorCode = ErrorCode(312); /// `ERR_SECRETS_INTERACTION_REQUIRED` (instanceof Error) - pub const SECRETS_INTERACTION_REQUIRED: ErrorCode = ErrorCode(311); + pub const SECRETS_INTERACTION_REQUIRED: ErrorCode = ErrorCode(313); /// `ERR_POSTGRES_CONNECTION_FAILED` (instanceof Error) - pub const POSTGRES_CONNECTION_FAILED: ErrorCode = ErrorCode(312); + pub const POSTGRES_CONNECTION_FAILED: ErrorCode = ErrorCode(314); /// `ERR_MYSQL_CONNECTION_FAILED` (instanceof Error) - pub const MYSQL_CONNECTION_FAILED: ErrorCode = ErrorCode(313); + pub const MYSQL_CONNECTION_FAILED: ErrorCode = ErrorCode(315); /// `ERR_POSTGRES_CONNECTION_REFUSED` (instanceof Error) - pub const POSTGRES_CONNECTION_REFUSED: ErrorCode = ErrorCode(314); + pub const POSTGRES_CONNECTION_REFUSED: ErrorCode = ErrorCode(316); /// `ERR_MYSQL_CONNECTION_REFUSED` (instanceof Error) - pub const MYSQL_CONNECTION_REFUSED: ErrorCode = ErrorCode(315); + pub const MYSQL_CONNECTION_REFUSED: ErrorCode = ErrorCode(317); /// `ERR_HTTP2_GOAWAY_SESSION` - pub const HTTP2_GOAWAY_SESSION: ErrorCode = ErrorCode(316); + pub const HTTP2_GOAWAY_SESSION: ErrorCode = ErrorCode(318); /// == C++ `NODE_ERROR_COUNT`. - pub const COUNT: u16 = 317; + pub const COUNT: u16 = 319; } // ────────────────────────────────────────────────────────────────────────── @@ -983,11 +987,14 @@ impl ErrorCode { pub const ERR_TLS_HANDSHAKE_TIMEOUT: ErrorCode = ErrorCode::TLS_HANDSHAKE_TIMEOUT; pub const ERR_TLS_INVALID_PROTOCOL_METHOD: ErrorCode = ErrorCode::TLS_INVALID_PROTOCOL_METHOD; pub const ERR_TLS_INVALID_PROTOCOL_VERSION: ErrorCode = ErrorCode::TLS_INVALID_PROTOCOL_VERSION; + pub const ERR_TLS_INVALID_STATE: ErrorCode = ErrorCode::TLS_INVALID_STATE; pub const ERR_TLS_PROTOCOL_VERSION_CONFLICT: ErrorCode = ErrorCode::TLS_PROTOCOL_VERSION_CONFLICT; pub const ERR_TLS_PSK_SET_IDENTITY_HINT_FAILED: ErrorCode = ErrorCode::TLS_PSK_SET_IDENTITY_HINT_FAILED; pub const ERR_TLS_RENEGOTIATION_DISABLED: ErrorCode = ErrorCode::TLS_RENEGOTIATION_DISABLED; + pub const ERR_TLS_RENEGOTIATION_UNSUPPORTED: ErrorCode = + ErrorCode::TLS_RENEGOTIATION_UNSUPPORTED; pub const ERR_TLS_SNI_FROM_SERVER: ErrorCode = ErrorCode::TLS_SNI_FROM_SERVER; pub const ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS: ErrorCode = ErrorCode::TLS_ALPN_CALLBACK_WITH_PROTOCOLS; @@ -1316,7 +1323,9 @@ static CODE_STR: [&str; ErrorCode::COUNT as usize] = [ "ERR_TLS_PROTOCOL_VERSION_CONFLICT", "ERR_TLS_PSK_SET_IDENTITY_HINT_FAILED", "ERR_TLS_RENEGOTIATION_DISABLED", + "ERR_TLS_RENEGOTIATION_UNSUPPORTED", "ERR_TLS_SNI_FROM_SERVER", + "ERR_TLS_INVALID_STATE", "ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS", "ERR_SSL_NO_CIPHER_MATCH", "ERR_UNAVAILABLE_DURING_EXIT", diff --git a/src/jsc/bindings/ErrorCode.cpp b/src/jsc/bindings/ErrorCode.cpp index 02810d77a1d3..67e43aaddd2b 100644 --- a/src/jsc/bindings/ErrorCode.cpp +++ b/src/jsc/bindings/ErrorCode.cpp @@ -2435,12 +2435,16 @@ JSC_DEFINE_HOST_FUNCTION(Bun::jsFunctionMakeErrorWithCode, (JSC::JSGlobalObject return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_SOCKET_CLOSED_BEFORE_CONNECTION, "Socket closed before the connection was established"_s)); case ErrorCode::ERR_TLS_RENEGOTIATION_DISABLED: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_TLS_RENEGOTIATION_DISABLED, "TLS session renegotiation disabled for this socket"_s)); + case ErrorCode::ERR_TLS_RENEGOTIATION_UNSUPPORTED: + return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_TLS_RENEGOTIATION_UNSUPPORTED, "TLS session renegotiation is unsupported by this TLS implementation"_s)); case ErrorCode::ERR_UNAVAILABLE_DURING_EXIT: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_UNAVAILABLE_DURING_EXIT, "Cannot call function in process exit handler"_s)); case ErrorCode::ERR_TLS_CERT_ALTNAME_FORMAT: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_TLS_CERT_ALTNAME_FORMAT, "Invalid subject alternative name string"_s)); case ErrorCode::ERR_TLS_SNI_FROM_SERVER: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_TLS_SNI_FROM_SERVER, "Cannot issue SNI from a TLS server-side socket"_s)); + case ErrorCode::ERR_TLS_INVALID_STATE: + return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_TLS_INVALID_STATE, "TLS socket connection must be securely established"_s)); case ErrorCode::ERR_INVALID_URI: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_INVALID_URI, "URI malformed"_s)); case ErrorCode::ERR_HTTP2_PSEUDOHEADER_NOT_ALLOWED: diff --git a/src/jsc/bindings/ErrorCode.ts b/src/jsc/bindings/ErrorCode.ts index 172f12877a73..a43f8a4fa2fc 100644 --- a/src/jsc/bindings/ErrorCode.ts +++ b/src/jsc/bindings/ErrorCode.ts @@ -257,7 +257,9 @@ const errors: ErrorCodeMapping = [ ["ERR_TLS_PROTOCOL_VERSION_CONFLICT", TypeError], ["ERR_TLS_PSK_SET_IDENTITY_HINT_FAILED", Error], ["ERR_TLS_RENEGOTIATION_DISABLED", Error], + ["ERR_TLS_RENEGOTIATION_UNSUPPORTED", Error], ["ERR_TLS_SNI_FROM_SERVER", Error], + ["ERR_TLS_INVALID_STATE", Error], ["ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS", TypeError], ["ERR_SSL_NO_CIPHER_MATCH", Error], ["ERR_UNAVAILABLE_DURING_EXIT", Error], diff --git a/src/jsc/bindings/NodeValidator.cpp b/src/jsc/bindings/NodeValidator.cpp index b647beb090f7..9e6aa3b6d41c 100644 --- a/src/jsc/bindings/NodeValidator.cpp +++ b/src/jsc/bindings/NodeValidator.cpp @@ -607,6 +607,9 @@ JSC_DEFINE_HOST_FUNCTION(jsFunction_validateUndefined, (JSC::JSGlobalObject * gl return JSValue::encode(jsUndefined()); } +// Matches Node's validateBuffer, which throws ERR_INVALID_ARG_TYPE with the +// "must be an instance of Buffer, TypedArray, or DataView" message: +// https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/internal/validators.js#L396 JSC_DEFINE_HOST_FUNCTION(jsFunction_validateBuffer, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { auto& vm = JSC::getVM(globalObject); @@ -616,12 +619,10 @@ JSC_DEFINE_HOST_FUNCTION(jsFunction_validateBuffer, (JSC::JSGlobalObject * globa auto name = callFrame->argument(1); if (!buffer.isUndefined()) { - if (!buffer.isCell()) return Bun::ERR::INVALID_ARG_TYPE(scope, globalObject, name, "Buffer, TypedArray, or DataView"_s, buffer); - - auto ty = buffer.asCell()->type(); - - if (JSC::typedArrayType(ty) == NotTypedArray) { - return Bun::ERR::INVALID_ARG_TYPE(scope, globalObject, name, "Buffer, TypedArray, or DataView"_s, buffer); + if (!buffer.isCell() || JSC::typedArrayType(buffer.asCell()->type()) == NotTypedArray) { + auto nameStr = name.isUndefined() ? String("buffer"_s) : name.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + return Bun::ERR::INVALID_ARG_INSTANCE(scope, globalObject, nameStr, "Buffer, TypedArray, or DataView"_s, buffer); } } return JSValue::encode(jsUndefined()); diff --git a/src/jsc/generated.rs b/src/jsc/generated.rs index f8b5f64ce005..f23a92f17812 100644 --- a/src/jsc/generated.rs +++ b/src/jsc/generated.rs @@ -197,6 +197,10 @@ pub struct SocketConfigHandlers { pub on_end: JSValue, pub on_error: JSValue, pub on_handshake: JSValue, + pub on_session: JSValue, + pub on_keylog: JSValue, + pub on_server_name: JSValue, + pub on_alpn_callback: JSValue, pub binary_type: SocketConfigHandlersBinaryType, } @@ -212,6 +216,10 @@ struct ExternSocketConfigHandlers { onEnd: JSValue, onConnectError: JSValue, onTimeout: JSValue, + onSession: JSValue, + onKeylog: JSValue, + onServerName: JSValue, + onALPNCallback: JSValue, binary_type: SocketConfigHandlersBinaryType, } @@ -239,6 +247,10 @@ impl SocketConfigHandlers { on_end: ext.onEnd, on_connect_error: ext.onConnectError, on_timeout: ext.onTimeout, + on_session: ext.onSession, + on_keylog: ext.onKeylog, + on_server_name: ext.onServerName, + on_alpn_callback: ext.onALPNCallback, binary_type: ext.binary_type, } } @@ -291,6 +303,8 @@ pub struct SSLConfig { pub reject_unauthorized: Option, pub request_cert: bool, pub secure_options: u32, + pub ssl_min_version: i32, + pub ssl_max_version: i32, pub ca: SSLConfigFile, pub cert: SSLConfigFile, pub key: SSLConfigFile, @@ -537,6 +551,8 @@ struct ExternSSLConfig { cert: ExternSSLConfigFile, key: ExternSSLConfigFile, secure_options: u32, + ssl_min_version: i32, + ssl_max_version: i32, key_file: RawWTFStringImpl, cert_file: RawWTFStringImpl, ca_file: RawWTFStringImpl, @@ -569,6 +585,8 @@ impl SSLConfig { cert: SSLConfigFile::convert_from_extern(ext.cert), key: SSLConfigFile::convert_from_extern(ext.key), secure_options: ext.secure_options, + ssl_min_version: ext.ssl_min_version, + ssl_max_version: ext.ssl_max_version, key_file: adopt_opt_string(ext.key_file), cert_file: adopt_opt_string(ext.cert_file), ca_file: adopt_opt_string(ext.ca_file), diff --git a/src/runtime/api/SecureContext.classes.ts b/src/runtime/api/SecureContext.classes.ts index dd23aaa6ec72..31cafc1610d7 100644 --- a/src/runtime/api/SecureContext.classes.ts +++ b/src/runtime/api/SecureContext.classes.ts @@ -12,6 +12,13 @@ export default [ // digest so identical configs return the same JS cell. Replaces the // old SHA-256/WeakRef cache that lived in `tls.ts`. intern: { fn: "intern", length: 1 }, + // `tls.createSecureContext()` — exclusive-ownership variant: no digest + // memoisation at either cache level, so addCACert on one context can + // never affect another. The connect/listen paths keep using `intern`. + createPrivate: { fn: "create_private", length: 1 }, + // Parses a PKCS#12 (`pfx`) blob into { key, cert, ca } PEM strings so + // the regular key/cert/ca option plumbing can consume it. + parsePkcs12: { fn: "parse_pkcs12", length: 2 }, }, // No prototype surface — node:tls hands out the SecureContext object // itself as `.context`. We deliberately do NOT expose the underlying @@ -19,6 +26,13 @@ export default [ // `context._external` is a V8 External (opaque) used only by N-API // addons that link OpenSSL directly, which Bun's BoringSSL build can't // satisfy anyway. - proto: {}, + proto: { + // `secureContext.context.addCACert(pem)` — Node's SecureContext exposes + // this so extra CAs can be appended to an existing context's store. + addCACert: { + fn: "add_ca_cert", + length: 1, + }, + }, }), ]; diff --git a/src/runtime/api/bun/SecureContext.rs b/src/runtime/api/bun/SecureContext.rs index 1fbeb267a1ac..5fd94b9e4761 100644 --- a/src/runtime/api/bun/SecureContext.rs +++ b/src/runtime/api/bun/SecureContext.rs @@ -85,6 +85,149 @@ impl SecureContext { // wraps this in `host_fn_result` and exports the C-ABI shim, so no // `#[bun_jsc::host_fn]` here — that macro's Free shim calls by bare name // and cannot resolve an associated fn. + /// `SecureContext.parsePkcs12(pfx, passphrase)` - parses a PKCS#12 blob + /// into `{ key, cert, ca? }` PEM strings so the regular key/cert/ca + /// option plumbing can consume Node's `pfx` option. Same codegen shim + /// arrangement as `intern` (no `#[host_fn]` attribute here). + pub fn parse_pkcs12(global: &JSGlobalObject, callframe: &CallFrame) -> JsResult { + let args = callframe.arguments(); + if args.is_empty() { + return Err(global.throw(format_args!("PFX certificate argument is mandatory"))); + } + // The pfx arrives as a Buffer/TypedArray (binary DER) or a string; + // a string-conversion would mangle the DER bytes, so read the raw + // view when one exists. + let pfx_string; + let pfx_bytes: &[u8] = if let Some(ab) = args[0].as_array_buffer(global) { + // SAFETY: the ArrayBuffer view is alive for the duration of the + // call (the argument is rooted by the call frame). + unsafe { core::slice::from_raw_parts(ab.ptr, ab.len) } + } else { + pfx_string = args[0].to_slice(global)?; + pfx_string.slice() + }; + if pfx_bytes.is_empty() { + return Err(global.throw(format_args!("PFX certificate argument is mandatory"))); + } + // The passphrase is optional; the C side treats NULL as "". + let pass_owned: Option> = if args.len() > 1 && !args[1].is_undefined_or_null() { + let p = args[1].to_slice(global)?; + let mut v = p.slice().to_vec(); + v.push(0); + Some(v) + } else { + None + }; + let mut out_key: *mut core::ffi::c_char = core::ptr::null_mut(); + let mut out_cert: *mut core::ffi::c_char = core::ptr::null_mut(); + let mut out_ca: *mut core::ffi::c_char = core::ptr::null_mut(); + let mut key_len = 0usize; + let mut cert_len = 0usize; + let mut ca_len = 0usize; + let mut err_reason: *const core::ffi::c_char = core::ptr::null(); + // SAFETY: the buffers are live for the call; the out-pointers are + // freed below with libc free per the helper's contract. + let ok = unsafe { + c::us_ssl_parse_pkcs12( + pfx_bytes.as_ptr().cast(), + pfx_bytes.len(), + pass_owned + .as_ref() + .map_or(core::ptr::null(), |v| v.as_ptr().cast()), + &raw mut out_key, + &raw mut key_len, + &raw mut out_cert, + &raw mut cert_len, + &raw mut out_ca, + &raw mut ca_len, + &raw mut err_reason, + ) + }; + unsafe extern "C" { + fn free(ptr: *mut core::ffi::c_void); + } + if ok == 0 { + let reason = if err_reason.is_null() { + "" + } else { + // SAFETY: the helper sets a static NUL-terminated tag on failure. + unsafe { core::ffi::CStr::from_ptr(err_reason) } + .to_str() + .unwrap_or("") + }; + let message = match reason { + "key" => "Unable to load private key from PFX data", + "cert" => "Unable to load certificate from PFX data", + _ => "Unable to load PFX certificate", + }; + return Err(global.throw(format_args!("{message}"))); + } + let result = JSValue::create_empty_object(global, 0); + // SAFETY: the helper returned NUL-terminated PEM strings of the given + // lengths; ZigString::to_js copies into the JS heap before `free`. + unsafe { + let key_slice = core::slice::from_raw_parts(out_key.cast::(), key_len); + result.put(global, b"key", ZigString::init(key_slice).to_js(global)); + let cert_slice = core::slice::from_raw_parts(out_cert.cast::(), cert_len); + result.put(global, b"cert", ZigString::init(cert_slice).to_js(global)); + if !out_ca.is_null() && ca_len > 0 { + let ca_slice = core::slice::from_raw_parts(out_ca.cast::(), ca_len); + result.put(global, b"ca", ZigString::init(ca_slice).to_js(global)); + } + free(out_key.cast()); + free(out_cert.cast()); + if !out_ca.is_null() { + free(out_ca.cast()); + } + } + Ok(result) + } + + /// `tls.createSecureContext()` entry - builds a context that owns its + /// SSL_CTX exclusively: no digest memoisation at either the JS-wrapper + /// cache or the native SSLContextCache level, so prototype mutators like + /// `addCACert` can never affect another context (or the cached + /// connect/listen contexts). The internal connect/listen paths keep using + /// `intern` for the per-digest cache. + pub fn create_private(global: &JSGlobalObject, callframe: &CallFrame) -> JsResult { + let args = callframe.arguments(); + let opts = if args.len() > 0 { + args[0] + } else { + JSValue::UNDEFINED + }; + + // SAFETY: `bun_vm()` returns the live per-global VM pointer; valid for the call. + let vm = global.bun_vm().as_mut(); + let config = SSLConfig::from_js(vm, global, opts)?.unwrap_or_else(SSLConfig::zero); + // `defer config.deinit()` — handled by Drop. + + let ctx_opts = config.as_usockets(); + let d = ctx_opts.digest(); + + let mut err = uws::create_bun_socket_error_t::none; + let Some(ctx) = ctx_opts.create_ssl_context(&mut err) else { + if err == uws::create_bun_socket_error_t::none + || err == uws::create_bun_socket_error_t::invalid_ciphers + { + let code = boringssl::ERR_get_error(); + if code != 0 { + return Err(global.throw_value(err_to_js(global, code))); + } + if err == uws::create_bun_socket_error_t::none { + return Err(global.throw(format_args!("Failed to create SSL context"))); + } + } + return Err(global.throw_value(create_bun_socket_error_to_js(err, global))); + }; + let sc = Box::new(SecureContext { + ctx, + digest: d, + extra_memory: ctx_opts.approx_cert_bytes() + SSL_CTX_BASE_COST, + }); + Ok(Self::to_js_boxed(sc, global)) + } + pub fn intern(global: &JSGlobalObject, callframe: &CallFrame) -> JsResult { let args = callframe.arguments(); let opts = if args.len() > 0 { @@ -156,15 +299,22 @@ impl SecureContext { // `err` is only set for the input-validation paths (bad PEM, missing // file, …). When BoringSSL itself fails (e.g. unsupported curve) the // enum is still `.none`; surface the library error stack instead of - // throwing an empty placeholder. - if err == uws::create_bun_socket_error_t::none { + // throwing an empty placeholder. A rejected cipher list also keeps + // its specific reason (NO_CIPHER_MATCH, INVALID_COMMAND) on the + // queue - Node reports that decomposed error rather than a generic + // "invalid ciphers". + if err == uws::create_bun_socket_error_t::none + || err == uws::create_bun_socket_error_t::invalid_ciphers + { // `ERR_get_error` is declared `safe fn` in `boringssl_sys` (no // preconditions; reads the thread-local error queue). let code = boringssl::ERR_get_error(); if code != 0 { return Err(global.throw_value(err_to_js(global, code))); } - return Err(global.throw(format_args!("Failed to create SSL context"))); + if err == uws::create_bun_socket_error_t::none { + return Err(global.throw(format_args!("Failed to create SSL context"))); + } } return Err(global.throw_value(create_bun_socket_error_to_js(err, global))); }; @@ -186,6 +336,42 @@ impl SecureContext { self.ctx } + /// `secureContext.context.addCACert(pem)` — appends the certificates in + /// the given PEM string or buffer to this context's trust store, the way + /// Node's SecureContext exposes it. + #[bun_jsc::host_fn(method)] + pub fn add_ca_cert( + this: &Self, + global: &JSGlobalObject, + frame: &CallFrame, + ) -> JsResult { + let args = frame.arguments(); + if args.is_empty() { + return Err( + global.throw_invalid_arguments(format_args!("addCACert requires a certificate")) + ); + } + let pem = args[0].to_slice(global)?; + let bytes = pem.slice(); + if bytes.is_empty() { + return Err( + global.throw_invalid_arguments(format_args!("addCACert requires a certificate")) + ); + } + // The C side wants a NUL-terminated PEM document. + let mut owned = bytes.to_vec(); + owned.push(0); + // SAFETY: `this.ctx` is the live SSL_CTX this object owns a reference + // to, and `owned` is a NUL-terminated buffer valid for the call. + let ok = unsafe { + c::us_ssl_ctx_add_ca_cert(this.ctx, owned.as_ptr().cast::()) + }; + if ok == 0 { + return Err(global.throw(format_args!("Invalid CA certificate"))); + } + Ok(JSValue::UNDEFINED) + } + // Codegen's `host_fn_finalize` calls this via `|b| SecureContext::finalize(b)` // and requires `fn finalize(self: Box)`; clippy::boxed_local is a // false positive on that contract. @@ -202,6 +388,8 @@ impl SecureContext { const SSL_CTX_BASE_COST: usize = 50 * 1024; +use bun_jsc::ZigStringJsc as _; +use bun_jsc::zig_string::ZigString; use bun_uws_sys::socket_context::c; mod cpp { diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index df18b3be902d..4724ca0ddeab 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -1238,6 +1238,11 @@ pub struct H2FrameParser { remaining_length: Cell, // buffer if more data is needed for the current frame read_buffer: JsCell, + // depth of read dispatches currently on the stack (read()/on_native_read); + // detach() defers freeing read_buffer/write_buffer/hpack while > 0 because a + // re-entrant teardown from a frame handler must not free memory the + // in-flight parse still references (deinit() frees them later). + read_dispatch_depth: Cell, // local Window limits the download of data // current window size for the connection @@ -7351,23 +7356,36 @@ impl H2FrameParser { // of the function, and the window-size update still runs on the error // path. let array_buffer = buffer.as_pinned_arraybuffer(global_object); - let result = (|| { - if let Some(array_buffer) = &array_buffer { - let mut bytes = array_buffer.byte_slice(); + // This entry point is only used for JS-stream sockets (createConnection + // hands us chunks from a user Duplex; real sockets feed on_native_read). + // A frame handler dispatched while parsing can transfer or detach this + // buffer, and a transferred backing store can be freed by GC before the + // loop finishes - so parse from a parser-owned copy of the chunk and let + // go of the pin immediately. + let owned: Option> = array_buffer + .as_ref() + .map(|array_buffer| array_buffer.byte_slice().to_vec()); + if let Some(array_buffer) = &array_buffer { + array_buffer.unpin(); + } + let result = if let Some(owned) = &owned { + this.read_dispatch_depth + .set(this.read_dispatch_depth.get() + 1); + let parse = (|| { + let mut bytes = owned.as_slice(); // read all the bytes while !bytes.is_empty() { let result = this.read_bytes(bytes)?; bytes = &bytes[result..]; } Ok(JSValue::UNDEFINED) - } else { - Err(global_object - .throw(format_args!("Expected data to be a Buffer or ArrayBuffer"))) - } - })(); - if let Some(array_buffer) = &array_buffer { - array_buffer.unpin(); - } + })(); + this.read_dispatch_depth + .set(this.read_dispatch_depth.get() - 1); + parse + } else { + Err(global_object.throw(format_args!("Expected data to be a Buffer or ArrayBuffer"))) + }; this.increment_window_size_if_needed(); result } @@ -7375,6 +7393,8 @@ impl H2FrameParser { pub(crate) fn on_native_read(&self, data: &[u8]) -> JsResult<()> { bun_output::scoped_log!(H2FrameParser, "onNativeRead"); self.ref_(); + self.read_dispatch_depth + .set(self.read_dispatch_depth.get() + 1); let mut bytes = data; let result: JsResult<()> = (|| { while !bytes.is_empty() { @@ -7383,6 +7403,8 @@ impl H2FrameParser { } Ok(()) })(); + self.read_dispatch_depth + .set(self.read_dispatch_depth.get() - 1); self.increment_window_size_if_needed(); self.deref(); result @@ -7518,6 +7540,7 @@ impl H2FrameParser { current_frame: Cell::new(None), remaining_length: Cell::new(0), read_buffer: JsCell::new(MutableString::default()), + read_dispatch_depth: Cell::new(0), window_size: Cell::new(DEFAULT_WINDOW_SIZE), used_window_size: Cell::new(0), remote_window_size: Cell::new(DEFAULT_WINDOW_SIZE), @@ -7714,6 +7737,16 @@ impl H2FrameParser { self.unregister_auto_flush(); self.detach_native_socket(); + // A teardown triggered from inside a frame handler (session.destroy() + // while read()/on_native_read is still parsing) must not free the + // buffers the in-flight parse references: a fragmented frame's Payload + // points into read_buffer and the HPACK handle may still be decoding. + // Leave the allocations to a later detach()/deinit() outside the + // dispatch. + if self.read_dispatch_depth.get() > 0 { + return; + } + // Free the allocation, not just the length: `reset()` would only // clear `len`; detach() is reachable from JS without a following `deinit`, so the // capacity must be released here. Drop-and-replace = free. diff --git a/src/runtime/crypto/boringssl_jsc.rs b/src/runtime/crypto/boringssl_jsc.rs index 4b3b969aa632..034cd99b50f1 100644 --- a/src/runtime/crypto/boringssl_jsc.rs +++ b/src/runtime/crypto/boringssl_jsc.rs @@ -1,14 +1,63 @@ //! JSC bridge for BoringSSL error formatting. Keeps `src/boringssl/` free of JSC types. use bun_boringssl_sys as boring; -use bun_jsc::{JSGlobalObject, JSValue}; +use bun_core::{String as BunString, ZigString}; +use bun_jsc::{JSGlobalObject, JSValue, StringJsc as _, ZigStringJsc as _}; -const PREFIX: &[u8] = b"BoringSSL "; +/// Node's `ERR_LIB_*` → macro-prefix map from `crypto_util.cc` +/// (`OSSL_ERROR_CODES_MAP`). Libraries Node does not map get an empty prefix +/// and compose to `ERR_OSSL_`. +fn lib_short_name(lib: u32) -> &'static str { + // The numeric values are BoringSSL's `ERR_LIB_*` enum (err.h). + match lib { + 2 => "SYS_", + 3 => "BN_", + 4 => "RSA_", + 5 => "DH_", + 6 => "EVP_", + 7 => "BUF_", + 8 => "OBJ_", + 9 => "PEM_", + 10 => "DSA_", + 11 => "X509_", + 12 => "ASN1_", + 13 => "CONF_", + 14 => "CRYPTO_", + 15 => "EC_", + 16 => "SSL_", + 17 => "BIO_", + 18 => "PKCS7_", + 20 => "X509V3_", + 21 => "RAND_", + 22 => "ENGINE_", + 23 => "OCSP_", + 24 => "UI_", + 25 => "COMP_", + 26 => "ECDSA_", + 27 => "ECDH_", + 28 => "HMAC_", + 33 => "USER_", + _ => "", + } +} + +/// SAFETY: `ptr` is a NUL-terminated static string returned by BoringSSL's +/// error-string tables (or null). +fn static_cstr<'a>(ptr: *const core::ffi::c_char) -> Option<&'a [u8]> { + if ptr.is_null() { + return None; + } + // SAFETY: see above - the pointer is a 'static NUL-terminated table entry. + let bytes = unsafe { core::ffi::CStr::from_ptr(ptr) }.to_bytes(); + if bytes.is_empty() { None } else { Some(bytes) } +} pub fn err_to_js(global: &JSGlobalObject, err_code: u32) -> JSValue { - let mut outbuf = [0u8; 128 + 1 + PREFIX.len()]; - outbuf[..PREFIX.len()].copy_from_slice(PREFIX); - let message_buf = &mut outbuf[PREFIX.len()..]; + // The message is the raw ERR_error_string output + // ("error:0b000074:X.509 certificate routines:OPENSSL_internal:..."), + // exactly what Node built against BoringSSL produces - no prefix. + let mut outbuf = [0u8; 128 + 1]; + let message_buf = &mut outbuf[..]; // SAFETY: message_buf is a valid writable buffer of message_buf.len() bytes. unsafe { @@ -20,7 +69,7 @@ pub fn err_to_js(global: &JSGlobalObject, err_code: u32) -> JSValue { } let error_message: &[u8] = bun_core::slice_to_nul(&outbuf[..]); - if error_message.len() == PREFIX.len() { + if error_message.is_empty() { return global .err( bun_jsc::ErrorCode::BORINGSSL, @@ -29,10 +78,32 @@ pub fn err_to_js(global: &JSGlobalObject, err_code: u32) -> JSValue { .to_js(); } - global - .err( - bun_jsc::ErrorCode::BORINGSSL, - format_args!("{}", bstr::BStr::new(error_message)), - ) - .to_js() + // A plain Error carrying Node's library/function/reason/code decomposition + // of the OpenSSL error, the way ThrowCryptoError builds it: the code is + // ERR_OSSL__ (or ERR_SSL_ for the SSL library). + // The message must own its bytes - `outbuf` is a stack buffer and the + // error instance outlives this frame. + let err = BunString::clone_utf8(error_message).to_error_instance(global); + + if let Some(library) = static_cstr(boring::ERR_lib_error_string(err_code)) { + err.put(global, b"library", ZigString::init(library).to_js(global)); + } + if let Some(function) = static_cstr(boring::ERR_func_error_string(err_code)) { + err.put(global, b"function", ZigString::init(function).to_js(global)); + } + if let Some(reason) = static_cstr(boring::ERR_reason_error_string(err_code)) { + err.put(global, b"reason", ZigString::init(reason).to_js(global)); + + let lib = lib_short_name((err_code >> 24) & 0xff); + // Don't generate codes like "ERR_OSSL_SSL_". + let prefix = if lib == "SSL_" { "" } else { "OSSL_" }; + let mut code = Vec::with_capacity(4 + prefix.len() + lib.len() + reason.len()); + code.extend_from_slice(b"ERR_"); + code.extend_from_slice(prefix.as_bytes()); + code.extend_from_slice(lib.as_bytes()); + code.extend_from_slice(reason); + err.put(global, b"code", ZigString::init(&code).to_js(global)); + } + + err } diff --git a/src/runtime/node/node_net_binding.rs b/src/runtime/node/node_net_binding.rs index a372cb69151b..87cf0594e3f8 100644 --- a/src/runtime/node/node_net_binding.rs +++ b/src/runtime/node/node_net_binding.rs @@ -21,7 +21,10 @@ pub(crate) static AUTO_SELECT_FAMILY_DEFAULT: AtomicBool = AtomicBool::new(true) // If this becomes used in more places, and especially if it can be read by other threads, we may // need to store it as a field in the VirtualMachine instead of in a `threadlocal`. thread_local! { - pub(crate) static AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_DEFAULT: Cell = const { Cell::new(250) }; + // Node's default is 250ms with a documented floor of 10ms, but the CLI + // default in node_options.h is 500ms; the vendored test/common multiplies + // the default by 5 (upstream) assuming 500. + pub(crate) static AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_DEFAULT: Cell = const { Cell::new(500) }; } pub(crate) fn get_default_auto_select_family(global: &JSGlobalObject) -> JSValue { @@ -138,6 +141,7 @@ pub(crate) fn new_detached_socket(global: &JSGlobalObject, frame: &CallFrame) -> ref_count: bun_ptr::RefCount::init(), protos: JsCell::new(None), handlers: Cell::new(None), + local_binding: JsCell::new(None), // — defaults — owned_ssl_ctx: Cell::new(None), flags: Cell::new(SocketFlags::default()), diff --git a/src/runtime/socket/Handlers.rs b/src/runtime/socket/Handlers.rs index b14a8ea08b92..2cc2462c5c30 100644 --- a/src/runtime/socket/Handlers.rs +++ b/src/runtime/socket/Handlers.rs @@ -35,6 +35,10 @@ pub struct Handlers { pub on_end: JSValue, pub on_error: JSValue, pub on_handshake: JSValue, + pub on_session: JSValue, + pub on_keylog: JSValue, + pub on_server_name: JSValue, + pub on_alpn_callback: JSValue, pub binary_type: BinaryType, @@ -98,6 +102,22 @@ macro_rules! for_each_callback_field { let $f = &mut $self.on_handshake; $body } + { + let $f = &mut $self.on_session; + $body + } + { + let $f = &mut $self.on_keylog; + $body + } + { + let $f = &mut $self.on_server_name; + $body + } + { + let $f = &mut $self.on_alpn_callback; + $body + } }}; } @@ -292,6 +312,10 @@ impl Handlers { on_end: JSValue::ZERO, on_error: JSValue::ZERO, on_handshake: JSValue::ZERO, + on_session: JSValue::ZERO, + on_keylog: JSValue::ZERO, + on_server_name: JSValue::ZERO, + on_alpn_callback: JSValue::ZERO, binary_type: match generated.binary_type { GeneratedBinaryType::Arraybuffer => BinaryType::ArrayBuffer, GeneratedBinaryType::Buffer => BinaryType::Buffer, @@ -336,6 +360,10 @@ impl Handlers { assign_callback!(on_end, "onEnd"); assign_callback!(on_error, "onError"); assign_callback!(on_handshake, "onHandshake"); + assign_callback!(on_session, "onSession"); + assign_callback!(on_keylog, "onKeylog"); + assign_callback!(on_server_name, "onServerName"); + assign_callback!(on_alpn_callback, "onALPNCallback"); if result.on_data.is_empty() && result.on_writable.is_empty() { return Err(global_object.throw_invalid_arguments(format_args!( @@ -366,6 +394,10 @@ impl Handlers { self.on_end.unprotect(); self.on_error.unprotect(); self.on_handshake.unprotect(); + self.on_session.unprotect(); + self.on_keylog.unprotect(); + self.on_server_name.unprotect(); + self.on_alpn_callback.unprotect(); } fn with_async_context_if_needed(&mut self, global_object: &JSGlobalObject) { @@ -392,6 +424,10 @@ impl Handlers { self.on_end.protect(); self.on_error.protect(); self.on_handshake.protect(); + self.on_session.protect(); + self.on_keylog.protect(); + self.on_server_name.protect(); + self.on_alpn_callback.protect(); } } diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index eab91ab25cea..7a9ca5e4f335 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -460,6 +460,13 @@ impl Listener { bstr::BStr::new(hostname_bytes) )); log!("Failed to listen {}", errno); + // libuv reports UV_EINVAL for a pipe path it cannot express in a + // sockaddr_un, which is what Node surfaces for an over-long path. + let errno = if errno == bun_sys::SystemErrno::ENAMETOOLONG as c_int { + bun_sys::SystemErrno::EINVAL as c_int + } else { + errno + }; if errno != 0 { err.put( global, @@ -510,6 +517,22 @@ impl Listener { ); } } + // Register the dynamic SNI dispatch when the JS config provided a + // `serverName` handler - `us_select_cert_cb` invokes it FIRST for + // every ClientHello carrying a servername (the user callback takes + // precedence over the static SNI tree, Node semantics) and + // installs whichever context it returns on the in-flight SSL. A + // null return falls back to the static tree (bind hostname + + // addContext entries), then the default context; an asynchronous + // resolution suspends the handshake until resumeSNI. + // SAFETY: `handlers` is embedded in the live Listener. + if !unsafe { &*this_ref.handlers.as_ptr() } + .on_server_name + .is_empty() + { + // S008: `ListenSocket` is an `opaque_ffi!` ZST - safe deref. + bun_opaque::opaque_deref_mut(listen_socket).on_server_name(us_dispatch_server_name); + } } let this = scopeguard::ScopeGuard::into_inner(cleanup); // ownership transfers to JS wrapper @@ -538,6 +561,7 @@ impl Listener { poll_ref: JsCell::new(KeepAlive::init()), ref_pollref_on_connect: Cell::new(true), connection: JsCell::new(None), + local_binding: JsCell::new(None), server_name: JsCell::new(None), buffered_data_for_node_net: Default::default(), bytes_written: Cell::new(0), @@ -581,6 +605,7 @@ impl Listener { poll_ref: JsCell::new(KeepAlive::init()), ref_pollref_on_connect: Cell::new(true), connection: JsCell::new(None), + local_binding: JsCell::new(None), server_name: JsCell::new(None), buffered_data_for_node_net: Default::default(), bytes_written: Cell::new(0), @@ -967,6 +992,28 @@ impl Listener { }; // `connection` Box drops on error path + // `localAddress`/`localPort`: bind the socket to this address before + // connecting. node:net validates localAddress as a literal IP and + // localPort as a number before they reach us. + let local_binding: Option<(Box<[u8]>, u16)> = 'lb: { + let Some(local_addr_js) = opts.get_truthy(global, "localAddress")? else { + break 'lb None; + }; + if !local_addr_js.is_string() { + break 'lb None; + } + let local_addr_slice = local_addr_js.to_slice(global)?; + let local_addr_bytes = local_addr_slice.slice(); + if local_addr_bytes.is_empty() { + break 'lb None; + } + let local_port: u16 = match opts.get_truthy(global, "localPort")? { + Some(p) if p.is_number() => p.to_int32().clamp(0, 65535) as u16, + _ => 0, + }; + Some((local_addr_bytes.to_vec().into_boxed_slice(), local_port)) + }; + // Resolve the prebuilt SSL_CTX before the platform branches so the Windows // named-pipe path can adopt it. node:tls passes the native SecureContext as // `tls.secureContext` so we share its already-built SSL_CTX. @@ -1088,6 +1135,7 @@ impl Listener { // Free old resources before reassignment to prevent memory leaks // when sockets are reused for reconnection (common with MongoDB driver) prev.connection.set(Some(connection)); + prev.local_binding.set(local_binding.clone()); if prev.flags.get().contains(SocketFlags::OWNED_PROTOS) { prev.protos.set(None); } @@ -1102,6 +1150,7 @@ impl Listener { handlers: Cell::new(NonNull::new(handlers_ptr)), socket: Cell::new(uws::NewSocketHandler::::DETACHED), connection: JsCell::new(Some(connection)), + local_binding: JsCell::new(local_binding.clone()), protos: JsCell::new(ssl_taken.as_mut().and_then(|s| s.take_protos())), server_name: JsCell::new( ssl_taken.as_mut().and_then(|s| s.take_server_name()), @@ -1189,6 +1238,7 @@ impl Listener { // non-pipe arm below. Previously `.connection = null` // dropped the duped pipe-path bytes on the floor. prev.connection.set(Some(connection)); + prev.local_binding.set(local_binding.clone()); debug_assert!(prev.protos.get().is_none()); debug_assert!(prev.server_name.get().is_none()); prev_ptr @@ -1198,6 +1248,7 @@ impl Listener { handlers: Cell::new(NonNull::new(handlers_ptr)), socket: Cell::new(uws::NewSocketHandler::::DETACHED), connection: JsCell::new(Some(connection)), + local_binding: JsCell::new(local_binding.clone()), protos: JsCell::new(None), server_name: JsCell::new(None), owned_ssl_ctx: Cell::new(None), @@ -1314,6 +1365,7 @@ impl Listener { prev_maybe_tls, handlers_ptr, connection, + local_binding, ssl_taken.as_mut(), owned_ssl_ctx, default_data, @@ -1327,6 +1379,7 @@ impl Listener { prev_maybe_tcp, handlers_ptr, connection, + local_binding, ssl_taken.as_mut(), owned_ssl_ctx, default_data, @@ -1404,6 +1457,7 @@ fn connect_finish( maybe_previous: Option<*mut NewSocket>, handlers_ptr: *mut Handlers, connection: UnixOrHost, + local_binding: Option<(Box<[u8]>, u16)>, mut ssl: Option<&mut SSLConfig>, owned_ssl_ctx: Option>, default_data: JSValue, @@ -1441,6 +1495,7 @@ fn connect_finish( // Free old resources before reassignment to prevent memory leaks // when sockets are reused for reconnection (common with MongoDB driver) prev.connection.set(Some(connection)); + prev.local_binding.set(local_binding); if prev.flags.get().contains(SocketFlags::OWNED_PROTOS) { prev.protos.set(None); // drop old Box } @@ -1459,6 +1514,7 @@ fn connect_finish( handlers: Cell::new(NonNull::new(handlers_ptr)), socket: Cell::new(uws::NewSocketHandler::::DETACHED), connection: JsCell::new(Some(connection)), + local_binding: JsCell::new(local_binding), protos: JsCell::new(ssl.as_mut().and_then(|s| s.take_protos())), server_name: JsCell::new(ssl.as_mut().and_then(|s| s.take_server_name())), owned_ssl_ctx: Cell::new(owned_ssl_ctx.map(|p| p.as_ptr())), @@ -1502,9 +1558,35 @@ fn connect_finish( // borrow is needed here. if socket_ref.do_connect().is_err() { let errno = if port.is_none() { - bun_sys::SystemErrno::ENOENT as c_int + // Preserve the real errno from the failed connect(2) on a unix path: + // connecting to an existing non-socket file is ENOTSOCK, a + // permission-denied path is EACCES, a missing one is ENOENT. + let os_errno = bun_sys::last_errno(); + if os_errno == bun_sys::SystemErrno::ENAMETOOLONG as c_int { + // libuv reports UV_EINVAL for a pipe path it cannot express. + bun_sys::SystemErrno::EINVAL as c_int + } else if os_errno != 0 { + os_errno + } else { + bun_sys::SystemErrno::ENOENT as c_int + } } else { - bun_sys::SystemErrno::ECONNREFUSED as c_int + // A synchronous TCP connect failure is almost always the local + // bind() (localAddress/localPort) failing - preserve the errnos a + // bind() meaningfully produces (EADDRINUSE: port busy, + // EADDRNOTAVAIL: address not local, EACCES: privileged port, + // EINVAL: address family mismatch); everything else stays + // ECONNREFUSED. Mirrors handle_connect_error's whitelist. + let os_errno = bun_sys::last_errno(); + if os_errno == bun_sys::SystemErrno::EADDRINUSE as c_int + || os_errno == bun_sys::SystemErrno::EADDRNOTAVAIL as c_int + || os_errno == bun_sys::SystemErrno::EACCES as c_int + || os_errno == bun_sys::SystemErrno::EINVAL as c_int + { + os_errno + } else { + bun_sys::SystemErrno::ECONNREFUSED as c_int + } }; // SAFETY: `socket` is the live heap pointer; `socket_ref`'s `&mut` is no // longer used on this branch. `handle_connect_error` takes `*mut Self` @@ -1767,3 +1849,135 @@ impl WindowsNamedPipeListeningContext { } } } + +/// `openssl.c`'s `us_select_cert_cb` (the early select-certificate callback) +/// calls this FIRST for every ClientHello carrying a servername - the user +/// SNICallback takes precedence over the static SNI tree (Node semantics) - +/// so the JS callback can pick a context for the requested hostname. The +/// returned `SSL_CTX*` applies to the in-flight handshake only - the caller +/// installs it with `SSL_set_SSL_CTX`, which takes its own reference, and +/// nothing is cached in the SNI tree, so the callback runs per-connection the +/// way Node's does. A null return falls back to the static tree (bind +/// hostname + addContext entries), then the default context. An asynchronous +/// SNICallback sets `*abort_handshake = 2` instead: the handshake suspends +/// (select-certificate retry) until the JS resolution calls +/// `handle.resumeSNI(...)` -> `us_socket_sni_resolve()`. +/// +/// # Safety +/// `ls` is a live listen socket whose accept-group ext holds a `*mut Listener` +/// and `hostname` is a NUL-terminated string valid for the call. JS-thread +/// only. +pub(crate) extern "C" fn us_dispatch_server_name( + ls: *mut uws_sys::ListenSocket, + hostname: *const core::ffi::c_char, + abort_handshake: *mut core::ffi::c_int, + socket: *mut c_void, +) -> *mut c_void { + jsc::mark_binding!(); + if ls.is_null() || hostname.is_null() { + return core::ptr::null_mut(); + } + // SAFETY: `ls` is live per the fn contract; the accept group's ext holds + // the owning `*mut Listener` for the lifetime of the listen socket. + let listener_ptr: *mut Listener = unsafe { (*ls).group().owner::() }; + if listener_ptr.is_null() { + return core::ptr::null_mut(); + } + // SAFETY: see above. + let listener: &Listener = unsafe { &*listener_ptr }; + // SAFETY: `handlers` is embedded in the live Listener. + let handlers = unsafe { &*listener.handlers.as_ptr() }; + if handlers.vm.is_shutting_down() { + return core::ptr::null_mut(); + } + let callback = handlers.on_server_name; + if callback.is_empty() { + return core::ptr::null_mut(); + } + // No `Handlers::enter`/`exit` scope here: that protocol tracks the + // accepted-socket callback lifecycle (an exit returning true means "the + // socket died during the callback, free the handlers"), and running it + // against the listener's own handlers from inside the handshake corrupts + // their refcount for every subsequent accept. The listener and its + // embedded handlers are structurally alive for the duration of this + // synchronous dispatch - the listen socket cannot be freed mid-handshake. + let global = handlers.global_object; + // Pass the listener's `data` (the owning net.Server) rather than minting a + // JS wrapper for the Listener itself - `to_js` here would create a second + // cell owning the same Rust struct and whichever is collected first frees + // it out from under the other. + let this_value = listener + .strong_data + .get() + .get() + .unwrap_or(JSValue::UNDEFINED); + // SAFETY: `hostname` is NUL-terminated per the fn contract. + let name = unsafe { core::ffi::CStr::from_ptr(hostname) }; + let js_name = ZigString::init(name.to_bytes()).to_js(&global); + // The accepted socket processing this ClientHello: its JS wrapper is the + // resume handle an asynchronous SNICallback uses (`handle.resumeSNI(...)`) + // to complete the suspended handshake. The wrapper's lifecycle is + // GC-managed, so a resume after the socket died is a safe no-op. + let socket_handle: JSValue = if socket.is_null() { + JSValue::UNDEFINED + } else { + // SAFETY: the C caller passes the live us_socket_t processing this + // ClientHello; for BunSocketTls sockets the ext slot holds the + // TLSSocket wrapper. + let s_ref = uws_sys::us_socket_t::opaque_mut(socket.cast()); + if s_ref.kind() == uws_sys::SocketKind::BunSocketTls { + let tls_ptr: *mut TLSSocket = *s_ref.ext::<*mut TLSSocket>(); + if tls_ptr.is_null() { + JSValue::UNDEFINED + } else { + // SAFETY: ext slot holds a live TLSSocket; single-threaded dispatch. + unsafe { &*tls_ptr }.get_this_value(&global) + } + } else { + JSValue::UNDEFINED + } + }; + let result = match callback.call(&global, this_value, &[this_value, js_name, socket_handle]) { + Ok(v) => v, + Err(err) => global.take_exception(err), + }; + // The JS handler returns: + // - undefined/null -> fall through to the default context + // - a native SecureContext -> install it on the in-flight SSL + // - `true` -> the SNICallback is asynchronous; suspend + // the handshake (select_cert_retry) until handle.resumeSNI(...) fires + // - an Error (SNICallback reported one, returned an invalid context, or + // threw) -> abort the handshake; the connection is dropped without an + // alert and the JS side emits 'tlsClientError' from the + // handshake-failure path with the stashed error. + if result.is_boolean() && result.to_boolean() { + if !abort_handshake.is_null() { + // SAFETY: live out-parameter for the duration of this dispatch. + unsafe { *abort_handshake = 2 }; + } + return core::ptr::null_mut(); + } + if result.to_error().is_some() { + if !abort_handshake.is_null() { + // SAFETY: the C caller passes a live out-parameter for the + // duration of this synchronous dispatch. + unsafe { *abort_handshake = 1 }; + } + return core::ptr::null_mut(); + } + if result.is_undefined_or_null() { + return core::ptr::null_mut(); + } + if let Some(sc) = SecureContext::from_js(result) { + // SAFETY: from_js returned non-null; the SecureContext is live for the + // call and SSL_set_SSL_CTX takes its own reference to the SSL_CTX. + return unsafe { (*sc).borrow() }.cast(); + } + // Anything else is not a SecureContext: Node treats this as an invalid SNI + // context and drops the connection. + if !abort_handshake.is_null() { + // SAFETY: see above. + unsafe { *abort_handshake = 1 }; + } + core::ptr::null_mut() +} diff --git a/src/runtime/socket/SSLConfig.bindv2.ts b/src/runtime/socket/SSLConfig.bindv2.ts index 04a3f0b0f1e1..2309bf9a616f 100644 --- a/src/runtime/socket/SSLConfig.bindv2.ts +++ b/src/runtime/socket/SSLConfig.bindv2.ts @@ -59,6 +59,16 @@ export const SSLConfig = b.dictionary( default: 0, internalName: "secure_options", }, + minVersion: { + type: b.i32, + default: 0, + internalName: "ssl_min_version", + }, + maxVersion: { + type: b.i32, + default: 0, + internalName: "ssl_max_version", + }, keyFile: { type: b.String.nullable, internalName: "key_file", diff --git a/src/runtime/socket/SSLConfig.rs b/src/runtime/socket/SSLConfig.rs index 5eca5b4d3a5b..919a47068e10 100644 --- a/src/runtime/socket/SSLConfig.rs +++ b/src/runtime/socket/SSLConfig.rs @@ -176,11 +176,15 @@ impl SSLConfigFromJs for SSLConfig { as i32; result.request_cert = generated.request_cert as i32; result.secure_options = generated.secure_options; + result.ssl_min_version = generated.ssl_min_version; + result.ssl_max_version = generated.ssl_max_version; any = any || result.low_memory_mode || generated.reject_unauthorized.is_some() || generated.request_cert - || result.secure_options != 0; + || result.secure_options != 0 + || result.ssl_min_version != 0 + || result.ssl_max_version != 0; result.ca = handle_file_for_field(global, "ca", &generated.ca)?; result.cert = handle_file_for_field(global, "cert", &generated.cert)?; diff --git a/src/runtime/socket/SocketConfig.bindv2.ts b/src/runtime/socket/SocketConfig.bindv2.ts index 242366aa29cc..bb320349ee5e 100644 --- a/src/runtime/socket/SocketConfig.bindv2.ts +++ b/src/runtime/socket/SocketConfig.bindv2.ts @@ -23,6 +23,10 @@ export const Handlers = b.dictionary( end: { type: b.RawAny, internalName: "onEnd" }, connectError: { type: b.RawAny, internalName: "onConnectError" }, timeout: { type: b.RawAny, internalName: "onTimeout" }, + session: { type: b.RawAny, internalName: "onSession" }, + keylog: { type: b.RawAny, internalName: "onKeylog" }, + serverName: { type: b.RawAny, internalName: "onServerName" }, + alpnCallback: { type: b.RawAny, internalName: "onALPNCallback" }, binaryType: { type: BinaryType, default: "buffer", diff --git a/src/runtime/socket/UpgradedDuplex.rs b/src/runtime/socket/UpgradedDuplex.rs index 626732544755..98e481c3a971 100644 --- a/src/runtime/socket/UpgradedDuplex.rs +++ b/src/runtime/socket/UpgradedDuplex.rs @@ -67,6 +67,11 @@ pub struct Handlers { pub on_writable: fn(*mut ()), pub on_error: fn(*mut (), JSValue), pub on_timeout: fn(*mut ()), + /// A new resumable TLS session (serialized SSL_SESSION) - node's + /// `'session'` event on the wrapping TLSSocket. + pub on_session: fn(*mut (), &[u8]), + /// An NSS key-log line - node's `'keylog'` event. + pub on_keylog: fn(*mut (), &[u8]), } use crate::jsc_hooks::timer_all_mut as timer_all; @@ -110,6 +115,20 @@ impl UpgradedDuplex { (this.handlers.on_data)(this.handlers.ctx, decoded_data); } + fn on_session(this: *mut Self, session: &[u8]) { + bun_output::scoped_log!(UpgradedDuplex, "onSession ({})", session.len()); + // SAFETY: SSLWrapper handlers ctx is `self as *mut Self`; live for the wrapper's lifetime. + let this = unsafe { &mut *this }; + (this.handlers.on_session)(this.handlers.ctx, session); + } + + fn on_keylog(this: *mut Self, line: &[u8]) { + bun_output::scoped_log!(UpgradedDuplex, "onKeylog ({})", line.len()); + // SAFETY: SSLWrapper handlers ctx is `self as *mut Self`; live for the wrapper's lifetime. + let this = unsafe { &mut *this }; + (this.handlers.on_keylog)(this.handlers.ctx, line); + } + fn on_handshake(this: *mut Self, handshake_success: bool, ssl_error: us_bun_verify_error_t) { bun_output::scoped_log!(UpgradedDuplex, "onHandshake"); // SAFETY: SSLWrapper handlers ctx is `self as *mut Self`; live for the wrapper's lifetime. @@ -314,6 +333,8 @@ impl UpgradedDuplex { on_data: Self::on_data, on_close: Self::on_close, write: Self::internal_write, + on_session: Some(Self::on_session), + on_keylog: Some(Self::on_keylog), }, )?); @@ -347,6 +368,8 @@ impl UpgradedDuplex { on_data: Self::on_data, on_close: Self::on_close, write: Self::internal_write, + on_session: Some(Self::on_session), + on_keylog: Some(Self::on_keylog), }, )?); // Success: disarm the errdefer. diff --git a/src/runtime/socket/WindowsNamedPipe.rs b/src/runtime/socket/WindowsNamedPipe.rs index 59aa37d69bf0..378d536f697b 100644 --- a/src/runtime/socket/WindowsNamedPipe.rs +++ b/src/runtime/socket/WindowsNamedPipe.rs @@ -161,6 +161,11 @@ pub struct Handlers { pub on_writable: fn(*mut c_void), pub on_error: fn(*mut c_void, bun_sys::Error), pub on_timeout: fn(*mut c_void), + /// A new resumable TLS session (serialized SSL_SESSION) - node's + /// `'session'` event on the wrapping TLSSocket. + pub on_session: fn(*mut c_void, &[u8]), + /// An NSS key-log line - node's `'keylog'` event. + pub on_keylog: fn(*mut c_void, &[u8]), } impl WindowsNamedPipe { @@ -370,6 +375,16 @@ impl WindowsNamedPipe { (self.handlers.on_data)(self.handlers.ctx, decoded_data); } + fn on_session(&mut self, session: &[u8]) { + bun_output::scoped_log!(WindowsNamedPipe, "onSession ({})", session.len()); + (self.handlers.on_session)(self.handlers.ctx, session); + } + + fn on_keylog(&mut self, line: &[u8]) { + bun_output::scoped_log!(WindowsNamedPipe, "onKeylog ({})", line.len()); + (self.handlers.on_keylog)(self.handlers.ctx, line); + } + // ── SSLWrapper trampolines ─────────────────────────────────────────────── // `ssl_wrapper::Handlers<*mut Self>` carries `fn(*mut Self, ..)` slots; the // method receivers above are `&mut self`, so adapt at the FFI boundary. @@ -388,6 +403,14 @@ impl WindowsNamedPipe { // SAFETY: see `ssl_on_open`. unsafe { (*this).on_data(d) } } + fn ssl_on_session(this: *mut Self, d: &[u8]) { + // SAFETY: see `ssl_on_open`. + unsafe { (*this).on_session(d) } + } + fn ssl_on_keylog(this: *mut Self, d: &[u8]) { + // SAFETY: see `ssl_on_open`. + unsafe { (*this).on_keylog(d) } + } fn ssl_on_close(this: *mut Self) { // SAFETY: see `ssl_on_open`. unsafe { (*this).on_close() } @@ -714,6 +737,8 @@ impl WindowsNamedPipe { on_data: Self::ssl_on_data, on_close: Self::ssl_on_close, write: Self::ssl_write, + on_session: Some(Self::ssl_on_session), + on_keylog: Some(Self::ssl_on_keylog), }, ) { Ok(w) => Some(w), @@ -932,6 +957,8 @@ impl WindowsNamedPipe { on_data: Self::ssl_on_data, on_close: Self::ssl_on_close, write: Self::ssl_write, + on_session: Some(Self::ssl_on_session), + on_keylog: Some(Self::ssl_on_keylog), }; if let Some(ctx) = owned_ctx { self.flags.set_is_ssl(true); @@ -984,6 +1011,8 @@ impl WindowsNamedPipe { on_data: Self::ssl_on_data, on_close: Self::ssl_on_close, write: Self::ssl_write, + on_session: Some(Self::ssl_on_session), + on_keylog: Some(Self::ssl_on_keylog), }, )?); @@ -1198,6 +1227,14 @@ impl WindowsNamedPipe { unsafe { (*this).wrapper = None }; } } + } else { + // Plain (non-TLS) named pipe: half-close the write side so the peer + // observes EOF. Without this, Socket.prototype.end() over a Windows + // named pipe (endNT → shutdown()) never signals the peer, and an + // allowHalfOpen peer waiting on 'end' hangs. `writer.end()` is + // idempotent and mirrors `close`'s unconditional writer teardown. + // SAFETY: `this` aliases the live `&mut self`; single JS thread. + unsafe { (*this).writer.end() }; } } diff --git a/src/runtime/socket/WindowsNamedPipeContext.rs b/src/runtime/socket/WindowsNamedPipeContext.rs index 547e4b82fbd7..7bbf05aac5d2 100644 --- a/src/runtime/socket/WindowsNamedPipeContext.rs +++ b/src/runtime/socket/WindowsNamedPipeContext.rs @@ -162,6 +162,24 @@ impl WindowsNamedPipeContext { }); } + fn on_session(this: *mut Self, session: &[u8]) { + // Only the TLS wrapper parks sessions; the TCP arm can never get here. + // SAFETY: see `on_open`. + if let SocketType::Tls(s) = unsafe { (*this).socket } { + // SAFETY: see `on_data`; `on_session` takes `*mut Self` + // (noalias re-entrancy) and routes JS errors internally. + let _ = unsafe { TLSSocket::on_session(s, session) }; + } + } + + fn on_keylog(this: *mut Self, line: &[u8]) { + // SAFETY: same as `on_session` above. + if let SocketType::Tls(s) = unsafe { (*this).socket } { + // SAFETY: same as `on_session` above. + let _ = unsafe { TLSSocket::on_keylog(s, line) }; + } + } + fn on_handshake(this: *mut Self, success: bool, ssl_error: us_bun_verify_error_t) { // SAFETY: see `on_open`. let pipe = unsafe { ptr::addr_of_mut!((*this).named_pipe) }; @@ -317,6 +335,8 @@ impl WindowsNamedPipeContext { on_error: |p, e| Self::on_error(p.cast::(), &e), on_timeout: |p| Self::on_timeout(p.cast::()), on_close: |p| Self::on_close(p.cast::()), + on_session: |p, d| Self::on_session(p.cast::(), d), + on_keylog: |p, d| Self::on_keylog(p.cast::(), d), }; #[cfg(not(windows))] { diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index e29bfb98a039..586dc3354929 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -6,6 +6,8 @@ use core::ptr::{self, NonNull}; use bun_io::KeepAlive; use bun_jsc::JsCell; +use bun_jsc::ZigStringJsc as _; +use bun_jsc::zig_string::ZigString; use bun_ptr::IntrusiveRc; // do NOT `use bun_boringssl_sys::SSL` here — it shadows the // `const SSL: bool` generic param in `NewSocket` below, making rustc @@ -90,6 +92,114 @@ extern "C" fn select_alpn_callback( } // SAFETY: ex_data slot 0 holds a `*mut TLSSocket` (set in on_open). let this: &TLSSocket = unsafe { &*this_ptr.cast::() }; + // Same handlers-presence guard as every other dispatch entry point: + // mark_inactive frees the per-connection Handlers, and the ALPN selection + // callback can still fire for a connection JS already detached - + // get_handlers() would panic. NOACK falls through to the static list. + if this.handlers.get().is_none() { + return boringssl_sys::SSL_TLSEXT_ERR_NOACK; + } + // Dynamic per-connection ALPN: when the listener's config carries an + // `alpnCallback` handler, consult it with the client's protocol list (and + // the SNI name) before the static ALPNProtocols list. The JS handler + // returns `false` when the server has no ALPNCallback (fall through to + // the static list), the selected protocol string, or anything else to + // refuse the connection with a fatal no_application_protocol alert - the + // same contract as Node's ALPNCallback. + { + let handlers = this.get_handlers(); + let callback = handlers.on_alpn_callback; + if !callback.is_empty() && !handlers.vm.is_shutting_down() && !in_.is_null() && inlen > 0 { + let scope = Handlers::enter_ref(handlers); + let global = handlers.global_object; + let this_value = this.get_this_value(&global); + let wire_len = inlen as usize; + let buffer = match JSValue::create_buffer_from_length(&global, wire_len) { + Ok(b) => b, + Err(_) => { + if scope.exit() { + this.handlers.set(None); + } + return boringssl_sys::SSL_TLSEXT_ERR_ALERT_FATAL; + } + }; + if let Some(ab) = buffer.as_array_buffer(&global) { + // SAFETY: `ab.ptr` points at a fresh `wire_len`-byte JS buffer + // and `in_` is valid for `inlen` per the callback contract. + unsafe { core::ptr::copy_nonoverlapping(in_, ab.ptr, wire_len) }; + } + // SAFETY: `ssl` is the live SSL handle passed into this ALPN + // callback; SSL_get_servername reads the negotiated SNI name and + // returns NULL or a NUL-terminated string owned by the SSL. + let servername_ptr = unsafe { boringssl_sys::SSL_get_servername(ssl.cast_const(), 0) }; + let servername_js = if servername_ptr.is_null() { + JSValue::UNDEFINED + } else { + // SAFETY: BoringSSL hands back a NUL-terminated name. + let name = unsafe { core::ffi::CStr::from_ptr(servername_ptr) }; + ZigString::init(name.to_bytes()).to_js(&global) + }; + // The user callback (and the error handler below) run from inside + // SSL_do_handshake on this socket: JS that writes to or destroys a + // different TLS socket on the same loop re-points the per-loop BIO + // routing state, and this handshake's next flight would land on + // that other socket's fd. Snapshot and restore it around every + // JS-running region. + let mut saved_loop_state: [*mut c_void; 5] = [core::ptr::null_mut(); 5]; + tls_socket_functions::ffi::us_internal_ssl_loop_state_save( + boringssl_sys::SSL::opaque_ref(ssl), + saved_loop_state.as_mut_ptr(), + ); + let result = + match callback.call(&global, this_value, &[this_value, servername_js, buffer]) { + Ok(v) => v, + Err(err) => global.take_exception(err), + }; + if let Some(err_value) = result.to_error() { + let _ = handlers.call_error_handler(this_value, &[this_value, err_value]); + tls_socket_functions::ffi::us_internal_ssl_loop_state_restore( + saved_loop_state.as_mut_ptr(), + ); + if scope.exit() { + this.handlers.set(None); + } + return boringssl_sys::SSL_TLSEXT_ERR_ALERT_FATAL; + } + tls_socket_functions::ffi::us_internal_ssl_loop_state_restore( + saved_loop_state.as_mut_ptr(), + ); + if scope.exit() { + this.handlers.set(None); + } + if !result.is_boolean() || result.to_boolean() { + // The server has an ALPNCallback and it answered: a string + // selects that protocol for this connection; anything else + // refuses it. + let chosen = match result.to_slice(&global) { + Ok(chosen) => chosen, + Err(err) => { + // The selection's ToString threw (a Symbol or a throwing + // toString): consume the pending exception the same way + // the callback's own throw is handled above, then refuse + // the protocol. + global.take_exception(err); + return boringssl_sys::SSL_TLSEXT_ERR_ALERT_FATAL; + } + }; + let chosen_bytes = chosen.slice(); + if !result.is_string() || chosen_bytes.is_empty() || chosen_bytes.len() > 255 { + return boringssl_sys::SSL_TLSEXT_ERR_ALERT_FATAL; + } + let mut wire = Vec::with_capacity(chosen_bytes.len() + 1); + wire.push(chosen_bytes.len() as u8); + wire.extend_from_slice(chosen_bytes); + this.protos.set(Some(wire.into_boxed_slice())); + // Fall through to the standard selection below, which now + // negotiates against the single chosen protocol (and sends the + // fatal alert if the client did not actually offer it). + } + } + } if let Some(protos) = this.protos.get() { if protos.is_empty() { return boringssl_sys::SSL_TLSEXT_ERR_NOACK; @@ -173,6 +283,9 @@ pub struct NewSocket { pub poll_ref: JsCell, pub ref_pollref_on_connect: Cell, pub connection: JsCell>, + /// `localAddress`/`localPort` from the connect options: the socket is + /// bound to this address before connecting. Always a literal IP. + pub local_binding: JsCell, u16)>>, pub protos: JsCell>>, pub server_name: JsCell>>, pub buffered_data_for_node_net: JsCell>, @@ -389,12 +502,18 @@ impl NewSocket { // `ZBox` guarantees a trailing NUL; host bytes contain no interior NUL. let host_c = hostz.as_zstr().as_cstr(); + // Bind to the requested local address before connecting, if any. + let local = self.local_binding.get(); + let local_z = local + .as_ref() + .map(|(h, p)| (bun_core::ZBox::from_bytes(h), *p)); self.socket.set( match group.connect( kind, ssl_ctx, host_c, c_int::from(port), + local_z.as_ref().map(|(z, p)| (z.as_zstr().as_cstr(), *p)), flags, core::mem::size_of::<*mut c_void>() as c_int, ) { @@ -564,6 +683,73 @@ impl NewSocket { Ok(JSValue::from(this.socket.get().set_no_delay(enabled))) } + /// `_handle.setTypeOfService(tos)` - returns 0 on success or a negative + /// platform errno (Node's TCPWrap::SetTypeOfService convention, so the JS + /// layer can hand it to ErrnoException). + #[bun_jsc::host_fn(method)] + pub fn set_type_of_service( + this: &Self, + _global: &JSGlobalObject, + callframe: &CallFrame, + ) -> JsResult { + jsc::mark_binding!(); + let args = callframe.arguments_old::<1>(); + let tos: i32 = if args.len >= 1 { + args.ptr[0].to_int32() + } else { + 0 + }; + log!("setTypeOfService({})", tos); + Ok(JSValue::from(this.socket.get().set_tos(tos))) + } + + /// `_handle.getTypeOfService()` - returns the value (>= 0) or a negative + /// platform errno. + #[bun_jsc::host_fn(method)] + pub fn get_type_of_service( + this: &Self, + _global: &JSGlobalObject, + _callframe: &CallFrame, + ) -> JsResult { + jsc::mark_binding!(); + log!("getTypeOfService()"); + Ok(JSValue::from(this.socket.get().get_tos())) + } + + /// `handle.resumeSNI(secureContextOrNull, isError)` - resumes a server + /// handshake suspended by an asynchronous SNICallback. A no-op when the + /// socket already closed (the resolution outlived the connection). + #[bun_jsc::host_fn(method)] + pub fn resume_sni( + this: &Self, + _global: &JSGlobalObject, + callframe: &CallFrame, + ) -> JsResult { + jsc::mark_binding!(); + let args = callframe.arguments_old::<2>(); + log!("resumeSNI"); + let socket = this.socket.get(); + if socket.is_detached() { + return Ok(JSValue::UNDEFINED); + } + let is_error = args.len > 1 && args.ptr[1].to_boolean(); + // The selected context: a native SecureContext (borrow() hands back an + // owned SSL_CTX reference that us_socket_sni_resolve consumes) or null + // to fall through to the listener's default context. + let ctx_ptr = if args.len >= 1 && !is_error { + if let Some(sc) = crate::api::bun_secure_context::SecureContext::from_js(args.ptr[0]) { + // SAFETY: from_js returned a live SecureContext. + unsafe { (*sc).borrow() } + } else { + core::ptr::null_mut() + } + } else { + core::ptr::null_mut() + }; + socket.sni_resolve(ctx_ptr.cast(), is_error); + Ok(JSValue::UNDEFINED) + } + pub fn handle_error(&self, err_value: JSValue) { log!("handleError"); let handlers = self.get_handlers(); @@ -601,6 +787,13 @@ impl NewSocket { // `Cell`/`JsCell`, so a single shared reborrow is sufficient and no // borrow spans `callback.call`. let this: &Self = unsafe { &*this }; + // A late event on a socket whose Handlers were already torn down + // (mark_inactive freed them through a path that did not route back + // through this dispatch - e.g. a JS-side destroy on a TLS socket + // driven by an upgraded duplex). There is nothing to dispatch to. + if this.handlers.get().is_none() { + return; + } if this.socket.get().is_detached() { return; } @@ -619,7 +812,15 @@ impl NewSocket { } this.ref_(); // reshaped for borrowck — explicit deref at end instead of a scope guard. - this.internal_flush(); + // NOTE: the drain dispatch deliberately does not depend on whether the + // flush hit a fatal send error. Skipping it on fatal (tried in + // f0325bddf2) made Windows servers reset FIN-terminated responses: + // write_check_error's fatal detection interacts with Windows + // would-block semantics, and a skipped drain stalls the response + // teardown into an RST. Until that detection is verified on Windows, + // keep the legacy contract (the close path still fails the pending + // write callback when the socket is torn down). + let _ = this.internal_flush(); log!( "onWritable buffered_data_for_node_net {}", this.buffered_data_for_node_net.get().len() @@ -653,6 +854,13 @@ impl NewSocket { jsc::mark_binding!(); // SAFETY: per fn contract; R-2 shared reborrow. let this: &Self = unsafe { &*this }; + // A late event on a socket whose Handlers were already torn down + // (mark_inactive freed them through a path that did not route back + // through this dispatch - e.g. a JS-side destroy on a TLS socket + // driven by an upgraded duplex). There is nothing to dispatch to. + if this.handlers.get().is_none() { + return; + } if this.socket.get().is_detached() { return; } @@ -804,13 +1012,36 @@ impl NewSocket { } debug_assert!(errno >= 0); - let errno_: c_int = if errno == sys::SystemErrno::ENOENT as c_int { - sys::SystemErrno::ENOENT as c_int + // Unix-path connect errors keep their real code (a non-socket file is + // ENOTSOCK, a permission-denied path is EACCES, a missing one is + // ENOENT, an inexpressible path is EINVAL); everything else stays + // ECONNREFUSED. + let errno_: c_int = if errno == sys::SystemErrno::ENOENT as c_int + || errno == sys::SystemErrno::ENOTSOCK as c_int + || errno == sys::SystemErrno::EACCES as c_int + || errno == sys::SystemErrno::EINVAL as c_int + || errno == sys::SystemErrno::ECONNRESET as c_int + || errno == sys::SystemErrno::EADDRINUSE as c_int + || errno == sys::SystemErrno::EADDRNOTAVAIL as c_int + { + errno } else { sys::SystemErrno::ECONNREFUSED as c_int }; let code_ = if errno == sys::SystemErrno::ENOENT as c_int { BunString::static_("ENOENT") + } else if errno == sys::SystemErrno::ENOTSOCK as c_int { + BunString::static_("ENOTSOCK") + } else if errno == sys::SystemErrno::EACCES as c_int { + BunString::static_("EACCES") + } else if errno == sys::SystemErrno::EINVAL as c_int { + BunString::static_("EINVAL") + } else if errno == sys::SystemErrno::ECONNRESET as c_int { + BunString::static_("ECONNRESET") + } else if errno == sys::SystemErrno::EADDRINUSE as c_int { + BunString::static_("EADDRINUSE") + } else if errno == sys::SystemErrno::EADDRNOTAVAIL as c_int { + BunString::static_("EADDRNOTAVAIL") } else { BunString::static_("ECONNREFUSED") }; @@ -1050,6 +1281,13 @@ impl NewSocket { // SAFETY: per fn contract; R-2 — shared reborrow, all // mutated fields are `Cell`/`JsCell`. let this: &Self = unsafe { &*this }; + // A late event on a socket whose Handlers were already torn down + // (mark_inactive freed them through a path that did not route back + // through this dispatch - e.g. a JS-side destroy on a TLS socket + // driven by an upgraded duplex). There is nothing to dispatch to. + if this.handlers.get().is_none() { + return; + } log!( "onOpen {} {:p} {} {}", if this.is_server() { "S" } else { "C" }, @@ -1097,26 +1335,36 @@ impl NewSocket { } } } + // A server needs the per-connection ALPN selector when it + // has static ALPNProtocols OR a dynamic ALPNCallback (the + // selector consults the callback first and falls back to + // the static list). The callback reads `this` from the SSL, + // not the CTX-level arg (shared across the listener). + // ffi-safe-fn: opaque-ZST `&SSL`/`&SSL_CTX` redecls; + // `ssl_ptr` non-null in this branch and `SSL_get_SSL_CTX` + // never returns null for a live SSL. + if this.is_server() + && (this.protos.get().is_some() + || !this.get_handlers().on_alpn_callback.is_empty()) + { + let ssl_ref = boringssl_sys::SSL::opaque_ref(ssl_ptr); + tls_socket_functions::ffi::SSL_set_ex_data( + ssl_ref, + 0, + this_ptr.cast::(), + ); + tls_socket_functions::ffi::SSL_CTX_set_alpn_select_cb( + SSL_CTX::opaque_ref(tls_socket_functions::ffi::SSL_get_SSL_CTX( + ssl_ref, + )), + Some(select_alpn_callback), + ptr::null_mut(), + ); + } if let Some(protos) = this.protos.get() { if this.is_server() { - // Per-connection: callback reads `this` from the SSL, - // not the CTX-level arg (shared across the listener). - // ffi-safe-fn: opaque-ZST `&SSL`/`&SSL_CTX` redecls; - // `ssl_ptr` non-null in this branch and - // `SSL_get_SSL_CTX` never returns null for a live SSL. - let ssl_ref = boringssl_sys::SSL::opaque_ref(ssl_ptr); - tls_socket_functions::ffi::SSL_set_ex_data( - ssl_ref, - 0, - this_ptr.cast::(), - ); - tls_socket_functions::ffi::SSL_CTX_set_alpn_select_cb( - SSL_CTX::opaque_ref(tls_socket_functions::ffi::SSL_get_SSL_CTX( - ssl_ref, - )), - Some(select_alpn_callback), - ptr::null_mut(), - ); + // Registered above (selector + ex_data); nothing + // further to do for the static server list here. } else { // SAFETY: `ssl_ptr` non-null in this branch; // `protos.as_ptr()` is readable for `protos.len()` @@ -1189,6 +1437,32 @@ impl NewSocket { } this.mark_inactive(); } + if !SSL + && !this.socket.get().is_detached() + && this.buffered_data_for_node_net.get().len() > 0 + { + // A write issued from inside the open/'connection' callback (a + // server answering the moment a connection arrives) can be + // deferred into `buffered_data_for_node_net` before the socket has + // any usockets-level backpressure, so no writable event would ever + // flush it and its JS write callback would never run - the socket + // then never finishes and holds the event loop (the FIN-terminated + // http response tests hung on every Linux target). Deliver it now + // that the open dispatch is done; if it fully drains, complete the + // pending JS write the same way on_writable's tail does, otherwise + // the do_socket_write backpressure arms the normal writable + // subscription. + let _ = this.internal_flush(); + if this.buffered_data_for_node_net.get().len() == 0 { + let drain_callback = handlers.on_writable; + if !drain_callback.is_empty() { + if let Err(err) = drain_callback.call(&global, this_value, &[this_value]) { + let _ = handlers + .call_error_handler(this_value, &[this_value, global.take_error(err)]); + } + } + } + } if scope.exit() { this.handlers.set(None); } @@ -1219,6 +1493,13 @@ impl NewSocket { jsc::mark_binding!(); // SAFETY: per fn contract; R-2 shared reborrow. let this: &Self = unsafe { &*this }; + // A late event on a socket whose Handlers were already torn down + // (mark_inactive freed them through a path that did not route back + // through this dispatch - e.g. a JS-side destroy on a TLS socket + // driven by an upgraded duplex). There is nothing to dispatch to. + if this.handlers.get().is_none() { + return; + } if this.socket.get().is_detached() { return; } @@ -1273,6 +1554,13 @@ impl NewSocket { jsc::mark_binding!(); // SAFETY: per fn contract; R-2 shared reborrow. let this: &Self = unsafe { &*this }; + // A late event on a socket whose Handlers were already torn down + // (mark_inactive freed them through a path that did not route back + // through this dispatch - e.g. a JS-side destroy on a TLS socket + // driven by an upgraded duplex). There is nothing to dispatch to. + if this.handlers.get().is_none() { + return Ok(()); + } this.update_flags(|f| f.insert(Flags::HANDSHAKE_COMPLETE)); this.socket.set(s); if this.socket.get().is_detached() { @@ -1401,6 +1689,122 @@ impl NewSocket { Ok(()) } + /// A new resumable TLS session arrived (the peer's NewSessionTicket was + /// processed during an earlier `SSL_read`). Hands the serialized session + /// to the JS `session` handler, mirroring Node's `onnewsession` callback. + /// Dispatched from `ssl_flush_pending_session()` after the SSL stack has + /// unwound, so the JS handler may safely destroy the socket. + /// + /// # Safety + /// `this` points at a live `NewSocket`; JS-thread only. + pub unsafe fn on_session(this: *mut Self, session: &[u8]) -> JsResult<()> { + jsc::mark_binding!(); + // SAFETY: per fn contract; shared reborrow only. + let this: &Self = unsafe { &*this }; + if this.socket.get().is_detached() { + return Ok(()); + } + // Same late-event guard as the other dispatch entry points: the + // Handlers may already have been freed by mark_inactive. + if this.handlers.get().is_none() { + return Ok(()); + } + let handlers = this.get_handlers(); + if handlers.vm.is_shutting_down() { + return Ok(()); + } + let callback = handlers.on_session; + if callback.is_empty() { + return Ok(()); + } + let scope = Handlers::enter_ref(handlers); + let global = handlers.global_object; + let this_value = this.get_this_value(&global); + let buffer = match JSValue::create_buffer_from_length(&global, session.len()) { + Ok(b) => b, + Err(e) => { + if scope.exit() { + this.handlers.set(None); + } + return Err(e); + } + }; + if let Some(ab) = buffer.as_array_buffer(&global) { + // SAFETY: `ab.ptr` points to a freshly-created `session.len()`-byte + // JS buffer kept alive on the stack; `session` is valid for its length. + unsafe { + core::ptr::copy_nonoverlapping(session.as_ptr(), ab.ptr, session.len()); + } + } + let result = match callback.call(&global, this_value, &[this_value, buffer]) { + Ok(v) => v, + Err(err) => global.take_exception(err), + }; + if let Some(err_value) = result.to_error() { + let _ = handlers.call_error_handler(this_value, &[this_value, err_value]); + } + if scope.exit() { + this.handlers.set(None); + } + Ok(()) + } + + /// `*mut Self` for the same noalias-reentry reason as `on_session`. + /// + /// # Safety + /// `this` points at a live `NewSocket`; JS-thread only. + pub unsafe fn on_keylog(this: *mut Self, line: &[u8]) -> JsResult<()> { + jsc::mark_binding!(); + // SAFETY: per fn contract; shared reborrow only. + let this: &Self = unsafe { &*this }; + if this.socket.get().is_detached() { + return Ok(()); + } + // Same late-event guard as the other dispatch entry points: the + // Handlers may already have been freed by mark_inactive. + if this.handlers.get().is_none() { + return Ok(()); + } + let handlers = this.get_handlers(); + if handlers.vm.is_shutting_down() { + return Ok(()); + } + let callback = handlers.on_keylog; + if callback.is_empty() { + return Ok(()); + } + let scope = Handlers::enter_ref(handlers); + let global = handlers.global_object; + let this_value = this.get_this_value(&global); + let buffer = match JSValue::create_buffer_from_length(&global, line.len()) { + Ok(b) => b, + Err(e) => { + if scope.exit() { + this.handlers.set(None); + } + return Err(e); + } + }; + if let Some(ab) = buffer.as_array_buffer(&global) { + // SAFETY: `ab.ptr` points to a freshly-created `line.len()`-byte + // JS buffer kept alive on the stack; `line` is valid for its length. + unsafe { + core::ptr::copy_nonoverlapping(line.as_ptr(), ab.ptr, line.len()); + } + } + let result = match callback.call(&global, this_value, &[this_value, buffer]) { + Ok(v) => v, + Err(err) => global.take_exception(err), + }; + if let Some(err_value) = result.to_error() { + let _ = handlers.call_error_handler(this_value, &[this_value, err_value]); + } + if scope.exit() { + this.handlers.set(None); + } + Ok(()) + } + /// `*mut Self` for the same noalias-reentry reason as `on_writable`. /// /// # Safety @@ -1414,6 +1818,20 @@ impl NewSocket { jsc::mark_binding!(); // SAFETY: per fn contract; R-2 shared reborrow. let this: &Self = unsafe { &*this }; + // A late close on a socket whose Handlers were already torn down + // (mark_inactive freed them through a path that did not route back + // through this dispatch - e.g. a JS-side destroy on a TLS socket + // driven by an upgraded duplex). There is nothing to dispatch to, + // but the caller transferred its +1 (the ext-slot/owner pin) - + // release it and detach so nothing further dispatches either. + // mark_inactive is not needed: handlers being null means the + // previous teardown already ran it (it is what nulls the field). + if this.handlers.get().is_none() { + this.detach_native_callback(); + this.socket.set(SocketHandler::::DETACHED); + this.deref(); + return Ok(()); + } let handlers = this.get_handlers(); log!( "onClose {}", @@ -1545,6 +1963,13 @@ impl NewSocket { jsc::mark_binding!(); // SAFETY: per fn contract; R-2 shared reborrow. let this: &Self = unsafe { &*this }; + // A late event on a socket whose Handlers were already torn down + // (mark_inactive freed them through a path that did not route back + // through this dispatch - e.g. a JS-side destroy on a TLS socket + // driven by an upgraded duplex). There is nothing to dispatch to. + if this.handlers.get().is_none() { + return; + } this.socket.set(s); if this.socket.get().is_detached() { return; @@ -1882,7 +2307,36 @@ impl NewSocket { return -1; } - let res = self.do_socket_write(buffer); + // The raw [raw, tls] upgrade twin shares the TLS half's us_socket_t + // (`s->ssl` is set) but must write raw bytes: write_check_error would + // route it through the SSL-encrypting us_socket_write, and its fatal + // signal is never set for TLS sockets anyway. + if self.flags.get().contains(Flags::BYPASS_TLS) { + let res = self.do_socket_write(buffer); + let uwrote: usize = usize::try_from(res.max(0)).expect("int cast"); + self.bytes_written + .set(self.bytes_written.get() + uwrote as u64); + log!("write({}) = {}", buffer.len(), res); + return res; + } + + let (res, fatal) = socket.write_check_error(buffer); + if fatal { + // The kernel rejected the write outright (EPIPE/ECONNRESET after + // the peer vanished): fail the write. Do NOT close the socket from + // inside the write call - a synchronous close dispatches the whole + // JS teardown ('close' -> http2 session destroy -> ...) underneath + // the caller that issued this write, which is how the x64-asan + // lane caught a stale read. Returning -1 makes the JS write fail, + // and node:net destroys the handle on a clean stack; the read side + // surfaces the reset for anyone who is only waiting. + // + // The undeliverable buffered data (if the input aliases it) is + // dropped by the caller: clearing it here would create a `&mut` of + // `buffered_data_for_node_net` while `buffer` may still borrow its + // heap allocation. + return -1; + } let uwrote: usize = usize::try_from(res.max(0)).expect("int cast"); self.bytes_written .set(self.bytes_written.get() + uwrote as u64); @@ -1937,7 +2391,7 @@ impl NewSocket { WriteResult::Fail => JSValue::ZERO, WriteResult::Success { wrote, total } => { if wrote >= 0 && usize::try_from(wrote).expect("int cast") == total { - this.internal_flush(); + let _ = this.internal_flush(); } JSValue::from(usize::try_from(wrote.max(0)).expect("int cast") == total) @@ -2085,7 +2539,13 @@ impl NewSocket { // `buffered_data_for_node_net`, so a `JsCell::get()` projection // is valid for the duration of the call. let rc = self.write_maybe_corked(self.buffered_data_for_node_net.get().slice()); - if rc > 0 { + if rc < 0 { + // Fatal write error (or the socket is already shut down/closed): + // the buffered bytes can never be delivered - drop them now that + // the borrow of their slice has ended. + self.buffered_data_for_node_net + .with_mut(|b| b.clear_and_free()); + } else if rc > 0 { let wrote_u: usize = usize::try_from(rc.max(0)).expect("int cast"); self.buffered_data_for_node_net.with_mut(|b| { // did we write everything? @@ -2336,20 +2796,51 @@ impl NewSocket { && self.buffered_data_for_node_net.get().len() == 0 } - fn internal_flush(&self) { + /// Returns `false` when a fatal send error dropped the buffered data. + /// NOTE: callers currently ignore this (the drain callback is dispatched + /// regardless) - skipping the drain on fatal made Windows servers reset + /// FIN-terminated responses (see a5e7ba5905). The return value stays so + /// the contract can be re-landed once the Windows fatal-write detection + /// is verified. + fn internal_flush(&self) -> bool { // R-2: every mutated field is `Cell`/`JsCell`, so `&self` carries no // `noalias` for them and the previous `black_box` launder (which // mitigated ASM-verified PROVEN_CACHED stale loads of // `bytes_written`/`flags`/`buffered_data_for_node_net` across the // re-entrant `do_socket_write`) is no longer needed. if self.buffered_data_for_node_net.get().len() > 0 { - // `do_socket_write` does not touch `buffered_data_for_node_net`, so a + // Neither write call touches `buffered_data_for_node_net`, so a // `JsCell::get()` projection is valid for the duration of the call. - let written: usize = usize::try_from( + // + // The drain-driven retry must detect a fatal send error the same way + // the initial write does: once the peer is gone the kernel rejects + // every retry (EPIPE/ECONNRESET), and treating that as would-block + // kept this buffer parked forever (the FIN-terminated-response hang). + // BYPASS_TLS twins keep the raw write path; TLS errors propagate + // through the SSL layer. + let res: i32 = if self.flags.get().contains(Flags::BYPASS_TLS) { self.do_socket_write(self.buffered_data_for_node_net.get().slice()) - .max(0), - ) - .unwrap(); + } else { + let (res, fatal) = self + .socket + .get() + .write_check_error(self.buffered_data_for_node_net.get().slice()); + if fatal { + // Same rule as write_maybe_corked: drop the undeliverable + // buffer and stop re-arming the writable retry, but do not + // close from inside the drain dispatch - the peer reset is + // delivered on the read side and tears the socket down on + // a clean stack. Report the failure so callers do not + // dispatch the JS drain callback (the write did NOT + // complete; Node fails the callback instead of succeeding + // it). + self.buffered_data_for_node_net + .with_mut(|b| b.clear_and_free()); + return false; + } + res + }; + let written: usize = usize::try_from(res.max(0)).unwrap(); self.bytes_written .set(self.bytes_written.get() + written as u64); if written > 0 { @@ -2372,6 +2863,7 @@ impl NewSocket { if self.can_end_after_flush() { self.mark_inactive(); } + true } #[bun_jsc::host_fn(method)] @@ -2388,7 +2880,7 @@ impl NewSocket { if this.socket.get().is_detached() { return Ok(JSValue::UNDEFINED); } - this.internal_flush(); + let _ = this.internal_flush(); Ok(JSValue::UNDEFINED) } @@ -2399,7 +2891,26 @@ impl NewSocket { _frame: &CallFrame, ) -> JsResult { jsc::mark_binding!(); + // Capture the in-flight-connect state before close_and_detach() sets + // DETACHED. Resetting a SEMI_SOCKET (Connected arm, handshake not yet + // established) dispatches no terminal callback in us_socket_close, so + // on_close/mark_inactive never runs — balance connect_finish's ref_(), + // downgrade the Strong this_value, and release the event-loop ref here, + // exactly as close() does. Without it those refs leak (LSan-caught). + let socket = this.socket.get(); + let is_semi_connect = socket.socket.get().is_some() && !socket.is_established(); this.close_and_detach(uws::CloseCode::Failure); + if is_semi_connect { + this.poll_ref.with_mut(|p| { + p.unref(bun_io::posix_event_loop::get_vm_ctx( + bun_io::AllocatorType::Js, + )) + }); + if !matches!(this.this_value.get(), JsRef::Finalized) { + this.this_value.with_mut(|r| r.downgrade()); + } + this.deref(); + } Ok(JSValue::UNDEFINED) } @@ -2488,7 +2999,7 @@ impl NewSocket { WriteResult::Fail => JSValue::ZERO, WriteResult::Success { wrote, total } => { if wrote >= 0 && usize::try_from(wrote).expect("int cast") == total { - this.internal_flush(); + let _ = this.internal_flush(); } JSValue::js_number(wrote as f64) } @@ -2698,10 +3209,6 @@ impl NewSocket { "upgradeTLS requires an established socket" ))); }; - if this.is_server() { - return Err(global.throw(format_args!("Server-side upgradeTLS is not supported. Use upgradeDuplexToTLS with isServer: true instead."))); - } - let args = callframe.arguments_old::<1>(); if args.len < 1 { return Err(global.throw(format_args!("Expected 1 arguments"))); @@ -2711,12 +3218,35 @@ impl NewSocket { return Err(global.throw(format_args!("Expected options object"))); } + // Server-side upgrade (`new tls.TLSSocket(socket, { isServer: true })`): + // adopt the fd into an accept-state SSL so the native read path drives the + // handshake — same code path as the client upgrade, only `is_client` flips. + // An explicit `isServer` option wins over the underlying socket's mode so + // an outgoing connection can still be wrapped as the server side, the way + // Node honors the option regardless of how the socket was created. + let is_server = match opts.get_truthy(global, "isServer")? { + Some(value) => value.to_boolean(), + None => this.is_server(), + }; + let socket_obj = opts .get(global, "socket")? .ok_or_else(|| global.throw(format_args!("Expected \"socket\" option")))?; if global.has_exception() { return Ok(JSValue::ZERO); } + // Bytes already consumed from the wire before the upgrade (e.g. the + // ClientHello sitting in the readable buffer of the socket being + // wrapped); fed into the TLS engine once the upgrade is wired up. + let initial_data: StringOrBuffer = match opts.get_truthy(global, "initialData")? { + Some(v) => StringOrBuffer::from_js(global, v)?.unwrap_or(StringOrBuffer::EMPTY), + None => StringOrBuffer::EMPTY, + }; + // Handlers lifecycle is always client-mode (heap-per-connection) here: a + // standalone `new TLSSocket(socket, { isServer })` is NOT a SocketListener, + // and server-mode Handlers::mark_inactive assumes its `this` is a Listener's + // embedded `handlers` field. The server-ness lives in the SSL accept state + // (adopt_tls is_client=!is_server) + the ServerHandlers JS table, not here. let handlers = Handlers::from_js(global, socket_obj, false)?; if global.has_exception() { return Ok(JSValue::ZERO); @@ -2864,6 +3394,7 @@ impl NewSocket { socket: Cell::new(SocketHandler::::DETACHED), owned_ssl_ctx: Cell::new(owned_ctx_taken), connection: JsCell::new(this.connection.get().clone()), + local_binding: JsCell::new(None), protos: JsCell::new(cfg.and_then(|c| c.protos_bytes().map(Box::<[u8]>::from))), server_name: JsCell::new( cfg.and_then(|c| c.server_name_bytes().map(Box::<[u8]>::from)), @@ -2900,6 +3431,7 @@ impl NewSocket { uws::SocketKind::BunSocketTls, &mut *((*tls_ptr).owned_ssl_ctx.get().unwrap()), sni, + !is_server, core::mem::size_of::<*mut c_void>() as i32, core::mem::size_of::<*mut c_void>() as i32, ) @@ -2995,14 +3527,25 @@ impl NewSocket { socket: Cell::new(SocketHandler::::from(new_raw.as_ptr())), owned_ssl_ctx: Cell::new(None), connection: JsCell::new(None), + local_binding: JsCell::new(None), protos: JsCell::new(None), server_name: JsCell::new(None), // is_active so the chained `raw.onClose` → `markInactive` path // tears down `raw_handlers` (client-mode handlers free // themselves there). No poll_ref — `tls` keeps the loop alive. // active_connections=1 was already on raw_handlers from `this`. + // OWNS_HANDLERS transfers from the retired wrapper rather than + // being asserted: a client socket's Handlers are its own + // heap::alloc root and the twin must free them, but an accepted + // server socket only borrows an interior pointer into its + // listener's embedded Handlers - claiming ownership of that + // would bad-free the listener's allocation when the twin is + // finalized. flags: Cell::new( - Flags::BYPASS_TLS | Flags::IS_ACTIVE | Flags::OWNED_PROTOS | Flags::OWNS_HANDLERS, + Flags::BYPASS_TLS + | Flags::IS_ACTIVE + | Flags::OWNED_PROTOS + | (this.flags.get() & Flags::OWNS_HANDLERS), ), this_value: JsCell::new(JsRef::empty()), poll_ref: JsCell::new(KeepAlive::init()), @@ -3055,6 +3598,21 @@ impl NewSocket { }; // SAFETY: `new_raw` is the live adopted `us_socket_t`. unsafe { (*new_raw.as_ptr()).start_tls_handshake() }; + // The socket being wrapped may have had its readable interest off (an + // accepted socket nobody was reading yet — its ClientHello is still in + // the kernel buffer); make sure the adopted TLS socket is reading so + // the handshake can be driven. A no-op when it was already reading. + // SAFETY: `new_raw` is the live adopted `us_socket_t`. + unsafe { (*new_raw.as_ptr()).resume() }; + // Feed bytes that arrived before the upgrade (already pulled off the fd + // by the plain-TCP layer) into the TLS engine exactly as if they had + // just been received — for a server-side wrap this is the ClientHello. + let initial_slice = initial_data.slice(); + if !initial_slice.is_empty() { + // SAFETY: `new_raw` is live; the slice borrows a JS-owned buffer kept + // alive by the options object for the duration of this call. + unsafe { (*new_raw.as_ptr()).tls_feed(initial_slice) }; + } let array = JSValue::create_empty_array(global, 2)?; array.put_index(global, 0, raw_js_value)?; @@ -3151,6 +3709,14 @@ impl NewSocket { } } #[bun_jsc::host_fn(method)] + pub fn set_key_cert(this: &Self, g: &JSGlobalObject, f: &CallFrame) -> JsResult { + if SSL { + tls_socket_functions::set_key_cert(Self::as_tls(this), g, f) + } else { + Ok(JSValue::UNDEFINED) + } + } + #[bun_jsc::host_fn(method)] pub fn export_keying_material( this: &Self, g: &JSGlobalObject, @@ -3493,6 +4059,22 @@ impl DuplexUpgradeContext { } } + fn on_session(&mut self, session: &[u8]) { + if let Some(tls) = &mut self.tls { + // SAFETY: intrusive refcount; single-threaded dispatch. `on_session` + // takes `*mut Self` (noalias re-entrancy); JS errors land on the + // socket's error handler inside. + let _ = unsafe { TLSSocket::on_session(tls.as_ptr(), session) }; + } + } + + fn on_keylog(&mut self, line: &[u8]) { + if let Some(tls) = &mut self.tls { + // SAFETY: same as `on_session` above. + let _ = unsafe { TLSSocket::on_keylog(tls.as_ptr(), line) }; + } + } + fn on_handshake(&mut self, success: bool, ssl_error: uws::us_bun_verify_error_t) { let socket = self.duplex_socket(); @@ -3848,6 +4430,7 @@ pub fn js_upgrade_duplex_to_tls( socket: Cell::new(SocketHandler::::DETACHED), owned_ssl_ctx: Cell::new(None), connection: JsCell::new(None), + local_binding: JsCell::new(None), protos: JsCell::new( socket_config.and_then(|cfg| cfg.protos_bytes().map(Box::<[u8]>::from)), ), @@ -3955,6 +4538,14 @@ pub fn js_upgrade_duplex_to_tls( on_timeout: |c: *mut ()| { bun_ptr::callback_ctx::(c.cast()).on_timeout() }, + // SAFETY: `c` is `ctx` below — the live `DuplexUpgradeContext` heap allocation. + on_session: |c: *mut (), s| { + bun_ptr::callback_ctx::(c.cast()).on_session(s) + }, + // SAFETY: `c` is `ctx` below — the live `DuplexUpgradeContext` heap allocation. + on_keylog: |c: *mut (), l| { + bun_ptr::callback_ctx::(c.cast()).on_keylog(l) + }, ctx: duplex_context.cast::<()>(), }, )); @@ -3974,11 +4565,12 @@ pub fn js_upgrade_duplex_to_tls( tls_ref.socket.set(from_duplex::(&mut dc.upgrade)); tls_ref.mark_active(); - tls_ref.poll_ref.with_mut(|p| { - p.ref_(bun_io::posix_event_loop::get_vm_ctx( - bun_io::posix_event_loop::AllocatorType::Js, - )) - }); + // Unlike a real socket, a TLS engine over a JS stream has no I/O of its + // own to wait for - it is driven entirely by the stream's events - so it + // must not hold the event loop open. Node's TLSWrap over a JS stream + // behaves the same way: a script that leaves a duplexPair-backed TLS pair + // dangling still exits. If the underlying stream is a real socket, that + // socket's own handle keeps the loop alive. dc.start_tls(); diff --git a/src/runtime/socket/sockets.classes.ts b/src/runtime/socket/sockets.classes.ts index 2b2980ffb0e5..9b2696bee3be 100644 --- a/src/runtime/socket/sockets.classes.ts +++ b/src/runtime/socket/sockets.classes.ts @@ -65,6 +65,10 @@ function generate(ssl) { fn: "getTLSTicket", length: 0, }, + setKeyCert: { + fn: "setKeyCert", + length: 1, + }, exportKeyingMaterial: { fn: "exportKeyingMaterial", length: 3, @@ -102,6 +106,18 @@ function generate(ssl) { fn: "setNoDelay", length: 1, }, + setTypeOfService: { + fn: "setTypeOfService", + length: 1, + }, + getTypeOfService: { + fn: "getTypeOfService", + length: 0, + }, + resumeSNI: { + fn: "resumeSNI", + length: 2, + }, setKeepAlive: { fn: "setKeepAlive", length: 2, diff --git a/src/runtime/socket/tls_socket_functions.rs b/src/runtime/socket/tls_socket_functions.rs index 72065cffb71a..bad552304938 100644 --- a/src/runtime/socket/tls_socket_functions.rs +++ b/src/runtime/socket/tls_socket_functions.rs @@ -1,7 +1,9 @@ use core::ffi::{c_char, c_int, c_long, c_void}; +use crate::api::bun_secure_context::SecureContext; use bun_boringssl_sys as boringssl; use bun_core::{String as BunString, ZigString, strings}; +use bun_jsc::JsClass as _; use bun_jsc::{ self as jsc, CallFrame, JSGlobalObject, JSValue, JsResult, StringJsc as _, ZigStringJsc as _, }; @@ -15,7 +17,7 @@ use crate::api::bun_x509 as X509; // ────────────────────────────────────────────────────────────────────────── #[allow(non_camel_case_types, non_upper_case_globals)] pub(super) mod ffi { - use super::boringssl::{SSL, SSL_CTX, X509, struct_stack_st_X509}; + use super::boringssl::{SSL, SSL_CTX, X509, X509_STORE, X509_STORE_CTX, struct_stack_st_X509}; use core::ffi::{c_char, c_int, c_long, c_uint, c_void}; // Re-export the one decl whose `*const c_char` NUL-terminated arg keeps a @@ -175,6 +177,10 @@ pub(super) mod ffi { out_len: &mut c_uint, ); pub(crate) safe fn SSL_get_ex_data(ssl: &SSL, idx: c_int) -> *mut c_void; + /// Save/restore the per-loop BIO routing state around in-handshake JS + /// callbacks (defined in usockets' openssl.c). + pub(crate) safe fn us_internal_ssl_loop_state_save(ssl: &SSL, out5: *mut *mut c_void); + pub(crate) safe fn us_internal_ssl_loop_state_restore(saved5: *mut *mut c_void); pub(crate) safe fn SSL_renegotiate(ssl: &SSL) -> c_int; pub(crate) safe fn SSL_set_renegotiate_mode( ssl: &SSL, @@ -190,6 +196,30 @@ pub(super) mod ffi { pub(crate) safe fn SSL_set_ex_data(ssl: &SSL, idx: c_int, data: *mut c_void) -> c_int; // Returns the borrowed parent CTX (always non-null for a live `SSL*`). pub(crate) safe fn SSL_get_SSL_CTX(ssl: &SSL) -> *mut SSL_CTX; + // Swaps the cert/key/chain (and session-related state) this connection + // serves to those of `ctx`; takes its own reference to `ctx`. + pub(crate) fn SSL_set_SSL_CTX(ssl: *mut SSL, ctx: *mut SSL_CTX) -> *mut SSL_CTX; + // Apply `ctx`'s leaf certificate / private key / extra chain directly + // to the connection - SSL_set_SSL_CTX alone does not retarget the + // certificate once ClientHello processing has reached ALPN selection. + pub(crate) fn SSL_CTX_get0_certificate(ctx: *const SSL_CTX) -> *mut core::ffi::c_void; + pub(crate) fn SSL_CTX_get0_privatekey(ctx: *const SSL_CTX) -> *mut core::ffi::c_void; + pub(crate) fn SSL_use_certificate( + ssl: *mut SSL, + x509: *mut core::ffi::c_void, + ) -> core::ffi::c_int; + pub(crate) fn SSL_use_PrivateKey( + ssl: *mut SSL, + pkey: *mut core::ffi::c_void, + ) -> core::ffi::c_int; + pub(crate) fn SSL_CTX_get0_chain_certs( + ctx: *const SSL_CTX, + out_chain: *mut *mut core::ffi::c_void, + ) -> core::ffi::c_int; + pub(crate) fn SSL_set1_chain( + ssl: *mut SSL, + chain: *mut core::ffi::c_void, + ) -> core::ffi::c_int; // Stores `cb`/`arg` opaquely on the CTX (BoringSSL never derefs `arg` // outside the callback). Opaque-ZST `&SSL_CTX` + by-value fn-ptr + // opaque `*mut c_void` ⇒ no caller-side precondition. @@ -207,6 +237,35 @@ pub(super) mod ffi { >, arg: *mut c_void, ); + // Returns the borrowed cert store of a live `SSL_CTX*`. + pub(crate) safe fn SSL_CTX_get_cert_store(ctx: &SSL_CTX) -> *mut X509_STORE; + // Emptiness probe for a cert store: `get0_objects` borrows the + // object stack and `OPENSSL_sk_num(NULL)` returns 0. + pub(crate) fn X509_STORE_get0_objects(store: *mut X509_STORE) -> *mut c_void; + pub(crate) fn OPENSSL_sk_num(sk: *const c_void) -> usize; + // The process-wide default root store; up-refs before returning, so + // the caller owns a reference it must release with X509_STORE_free. + pub(crate) fn us_get_shared_default_ca_store() -> *mut X509_STORE; + pub(crate) fn X509_STORE_free(store: *mut X509_STORE); + // X509_STORE_CTX lifecycle for issuer lookups; `new` allocates, + // `init` borrows the store, `free` releases. Used to extend the peer + // certificate chain through the local trust store. + pub(crate) fn X509_STORE_CTX_new() -> *mut X509_STORE_CTX; + pub(crate) fn X509_STORE_CTX_init( + ctx: *mut X509_STORE_CTX, + store: *mut X509_STORE, + x509: *mut X509, + chain: *mut struct_stack_st_X509, + ) -> c_int; + pub(crate) fn X509_STORE_CTX_free(ctx: *mut X509_STORE_CTX); + // Writes a +1 X509 reference to `*issuer` on success (> 0). + pub(crate) fn X509_STORE_CTX_get1_issuer( + issuer: *mut *mut X509, + ctx: *mut X509_STORE_CTX, + x: *mut X509, + ) -> c_int; + // Returns X509_V_OK (0) when `issuer` could have issued `subject`. + pub(crate) fn X509_check_issued(issuer: *mut X509, subject: *mut X509) -> c_int; } } use crate::node::StringOrBuffer; @@ -446,8 +505,117 @@ pub(super) fn get_peer_certificate( return Ok(JSValue::UNDEFINED); } - // TODO: we need to support the non abbreviated version of this - Ok(JSValue::UNDEFINED) + // The detailed form returns the whole chain the peer presented, each + // certificate linking to its issuer through `issuerCertificate`, the way + // Node's getPeerCertificate(true) does. SSL_get_peer_cert_chain includes + // the leaf on the client side but not on the server side, where the +1 + // peer certificate above is the leaf instead. + let first_obj = X509::to_js(boringssl::X509::opaque_mut(first_cert), global)?; + // Link each certificate to its predecessor immediately so every object in + // the chain is reachable from the stack-rooted `first_obj` before the next + // `X509::to_js` allocation can trigger a GC - a heap-backed Vec + // is not stack-scanned. + let mut prev_obj: JSValue = first_obj; + let mut last_cert: *mut boringssl::X509 = first_cert; + if !cert_chain.is_null() { + let mut i: usize = if cert.is_null() { 1 } else { 0 }; + loop { + let next = + ffi::sk_X509_value(boringssl::struct_stack_st_X509::opaque_ref(cert_chain), i); + if next.is_null() { + break; + } + let obj = X509::to_js(boringssl::X509::opaque_mut(next), global)?; + prev_obj.put(global, b"issuerCertificate", obj); + prev_obj = obj; + last_cert = next; + i += 1; + } + } + + // Extend the chain through the local trust store until a self-issued + // certificate is reached, the way Node's getPeerCertificate(true) walks + // X509_STORE_CTX_get1_issuer to surface the root that completed + // verification even though the peer never sent it. + let mut last_is_self_issued = false; + // SAFETY: the store ctx is created, initialized against the live SSL_CTX's + // store, used only within this scope and freed before returning; every + // issuer returned by get1_issuer is a +1 reference collected in `extras` + // and released after its fields have been copied into JS values and the + // terminal self-issued check has run. + unsafe { + let mut store = ffi::SSL_CTX_get_cert_store(boringssl::SSL_CTX::opaque_ref( + ffi::SSL_get_SSL_CTX(boringssl::SSL::opaque_ref(ssl_ptr)), + )); + // A context built without an explicit `ca` (and without requestCert, + // which installs the shared roots) carries an empty store and the + // issuer walk would stop at whatever the peer sent. Fall back to the + // process-wide default roots the way Node's per-context store always + // contains the bundled roots. The getter up-refs, so the temporary + // reference is released after the walk. + let mut shared_store: *mut boringssl::X509_STORE = core::ptr::null_mut(); + if store.is_null() || ffi::OPENSSL_sk_num(ffi::X509_STORE_get0_objects(store)) == 0 { + shared_store = ffi::us_get_shared_default_ca_store(); + if !shared_store.is_null() { + store = shared_store; + } + } + let store_ctx = ffi::X509_STORE_CTX_new(); + if !store_ctx.is_null() { + if !store.is_null() + && ffi::X509_STORE_CTX_init( + store_ctx, + store, + core::ptr::null_mut(), + core::ptr::null_mut(), + ) == 1 + { + let mut extras: Vec<*mut boringssl::X509> = Vec::new(); + // Cap the walk so a cyclic store cannot loop forever. + while extras.len() < 16 && ffi::X509_check_issued(last_cert, last_cert) != 0 { + let mut issuer: *mut boringssl::X509 = core::ptr::null_mut(); + if ffi::X509_STORE_CTX_get1_issuer(&raw mut issuer, store_ctx, last_cert) <= 0 + || issuer.is_null() + { + break; + } + match X509::to_js(boringssl::X509::opaque_mut(issuer), global) { + Ok(obj) => { + prev_obj.put(global, b"issuerCertificate", obj); + prev_obj = obj; + } + Err(e) => { + boringssl::X509_free(issuer); + for extra in extras { + boringssl::X509_free(extra); + } + ffi::X509_STORE_CTX_free(store_ctx); + if !shared_store.is_null() { + ffi::X509_STORE_free(shared_store); + } + return Err(e); + } + } + extras.push(issuer); + last_cert = issuer; + } + last_is_self_issued = ffi::X509_check_issued(last_cert, last_cert) == 0; + for extra in extras { + boringssl::X509_free(extra); + } + } + ffi::X509_STORE_CTX_free(store_ctx); + } + if !shared_store.is_null() { + ffi::X509_STORE_free(shared_store); + } + } + + // A self-issued terminal certificate references itself, like Node. + if last_is_self_issued { + prev_obj.put(global, b"issuerCertificate", prev_obj); + } + Ok(first_obj) } pub(super) fn get_certificate( @@ -694,7 +862,56 @@ pub(super) fn get_tls_peer_finished_message( Ok(buffer) } -pub(super) fn export_keying_material( +/// `tlsSocket.setKeyCert(secureContext)` - serve this connection's identity +/// from the given context: SSL_set_SSL_CTX swaps the cert/key/chain used for +/// the rest of the handshake (Node calls it from ALPNCallback / SNICallback). +pub(crate) fn set_key_cert( + this: &This, + global: &JSGlobalObject, + frame: &CallFrame, +) -> JsResult { + if this.socket.get().is_detached() { + return Ok(JSValue::UNDEFINED); + } + let args = frame.arguments_old::<1>(); + if args.len < 1 { + return Err(global.throw(format_args!("setKeyCert requires a SecureContext"))); + } + let Some(sc) = SecureContext::from_js(args.ptr[0]) else { + return Err(global.throw(format_args!("setKeyCert requires a SecureContext"))); + }; + let Some(ssl_ptr) = this.socket.get().ssl() else { + return Ok(JSValue::UNDEFINED); + }; + // SAFETY: `sc` is a live SecureContext; borrow() hands back an owned + // reference and SSL_set_SSL_CTX takes its own, so release the temporary. + unsafe { + let ctx = (*sc).borrow(); + ffi::SSL_set_SSL_CTX(ssl_ptr.cast(), ctx.cast()); + // SSL_set_SSL_CTX stops retargeting the certificate once ClientHello + // processing has reached ALPN selection, and Node supports calling + // setKeyCert from ALPNCallback - apply the identity directly. + let leaf = ffi::SSL_CTX_get0_certificate(ctx.cast()); + let pkey = ffi::SSL_CTX_get0_privatekey(ctx.cast()); + if !leaf.is_null() && !pkey.is_null() { + let ok_cert = ffi::SSL_use_certificate(ssl_ptr.cast(), leaf); + let ok_key = ffi::SSL_use_PrivateKey(ssl_ptr.cast(), pkey); + let mut ok_chain = 1; + let mut chain: *mut core::ffi::c_void = core::ptr::null_mut(); + if ffi::SSL_CTX_get0_chain_certs(ctx.cast(), &raw mut chain) == 1 && !chain.is_null() { + ok_chain = ffi::SSL_set1_chain(ssl_ptr.cast(), chain); + } + if ok_cert != 1 || ok_key != 1 || ok_chain != 1 { + boringssl::SSL_CTX_free(ctx.cast()); + return Err(global.throw(format_args!("setKeyCert failed to apply the context"))); + } + } + boringssl::SSL_CTX_free(ctx.cast()); + } + Ok(JSValue::UNDEFINED) +} + +pub(crate) fn export_keying_material( this: &This, global: &JSGlobalObject, frame: &CallFrame, diff --git a/src/runtime/socket/uws_dispatch.rs b/src/runtime/socket/uws_dispatch.rs index 34da2883bbad..c1d4c17499ef 100644 --- a/src/runtime/socket/uws_dispatch.rs +++ b/src/runtime/socket/uws_dispatch.rs @@ -200,10 +200,14 @@ pub(crate) unsafe extern "C" fn us_dispatch_ssl_raw_tap( // `twin` is `IntrusiveRc` (intrusive ref-counted heap pointer); // grab the raw `*mut` without consuming the ref so the +1 stays put. let raw: *mut TLSSocket = raw.as_ptr(); + // A negative length from the C side means there is nothing to deliver; + // never panic across the `extern "C"` boundary. + let Ok(len) = usize::try_from(len) else { + return s; + }; // SAFETY: `data` points to `len` readable bytes from the TLS BIO; loop.c // guarantees the buffer outlives this call. - let slice = - unsafe { core::slice::from_raw_parts(data, usize::try_from(len).expect("len >= 0")) }; + let slice = unsafe { core::slice::from_raw_parts(data, len) }; // SAFETY: `twin` holds a live +1 // ref to the `[raw, _]` half; dispatch is single-threaded so no aliasing // `&mut` exists. `on_data` takes `*mut Self` (noalias re-entrancy fix). @@ -211,3 +215,66 @@ pub(crate) unsafe extern "C" fn us_dispatch_ssl_raw_tap( } s } + +/// A new (resumable) TLS session is ready. BoringSSL's new-session callback +/// parks the serialized session while `SSL_read`/`SSL_do_handshake` runs; +/// `ssl_flush_pending_session()` dispatches it here once that stack has +/// unwound. Mirrors Node's `NewSessionCallback` → `onnewsession` flow. Only +/// `bun_socket_tls` sockets reach this. +/// +/// # Safety +/// `openssl.c` must pass a live, non-null `s` whose ext slot holds a valid +/// `*mut TLSSocket`, and `data` must point to `len` readable bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn us_dispatch_session(s: *mut us_socket_t, data: *const u8, len: c_int) { + let s_ref = us_socket_t::opaque_mut(s); + if s_ref.kind() != SocketKind::BunSocketTls { + return; + } + type TLSSocket = super::NewSocket; + let tls_ptr: *mut TLSSocket = *s_ref.ext::<*mut TLSSocket>(); + if tls_ptr.is_null() { + return; + } + // A negative length from the C side means there is nothing to deliver; + // never panic across the `extern "C"` boundary. + let Ok(len) = usize::try_from(len) else { + return; + }; + // SAFETY: `data` points to `len` readable bytes owned by the caller for the + // duration of this call. + let slice = unsafe { core::slice::from_raw_parts(data, len) }; + // SAFETY: ext slot for BunSocketTls holds a live *mut TLSSocket; dispatch is + // single-threaded. `on_session` takes `*mut Self` (noalias re-entrancy). + let _ = unsafe { TLSSocket::on_session(tls_ptr, slice) }; +} + +/// Hands an NSS key-log line parked by the keylog callback to the JS +/// `keylog` handler. +/// +/// # Safety +/// `openssl.c` must pass a live, non-null `s` whose ext slot holds a valid +/// `*mut TLSSocket`, and `data` must point to `len` readable bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn us_dispatch_keylog(s: *mut us_socket_t, data: *const u8, len: c_int) { + let s_ref = us_socket_t::opaque_mut(s); + if s_ref.kind() != SocketKind::BunSocketTls { + return; + } + type TLSSocket = super::NewSocket; + let tls_ptr: *mut TLSSocket = *s_ref.ext::<*mut TLSSocket>(); + if tls_ptr.is_null() { + return; + } + // A negative length from the C side means there is nothing to deliver; + // never panic across the `extern "C"` boundary. + let Ok(len) = usize::try_from(len) else { + return; + }; + // SAFETY: `data` points to `len` readable bytes owned by the caller for the + // duration of this call. + let slice = unsafe { core::slice::from_raw_parts(data, len) }; + // SAFETY: ext slot for BunSocketTls holds a live *mut TLSSocket; dispatch is + // single-threaded. `on_keylog` takes `*mut Self` (noalias re-entrancy). + let _ = unsafe { TLSSocket::on_keylog(tls_ptr, slice) }; +} diff --git a/src/sql_jsc/mysql/MySQLConnection.rs b/src/sql_jsc/mysql/MySQLConnection.rs index cf8e88453a6c..8bba3068c9fd 100644 --- a/src/sql_jsc/mysql/MySQLConnection.rs +++ b/src/sql_jsc/mysql/MySQLConnection.rs @@ -360,6 +360,7 @@ impl MySQLConnection { bun_uws::SocketKind::MysqlTls, ssl_ctx, sni, + true, // is_client ext_size, ext_size, ) else { diff --git a/src/sql_jsc/postgres/PostgresSQLConnection.rs b/src/sql_jsc/postgres/PostgresSQLConnection.rs index feab93fac895..04fe6851b5da 100644 --- a/src/sql_jsc/postgres/PostgresSQLConnection.rs +++ b/src/sql_jsc/postgres/PostgresSQLConnection.rs @@ -473,6 +473,7 @@ impl PostgresSQLConnection { bun_uws::SocketKind::PostgresTls, ssl_ctx, sni, + true, // is_client ext_size, ext_size, ) else { diff --git a/src/uws/lib.rs b/src/uws/lib.rs index 37be10ccad81..d0173c203134 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -348,6 +348,13 @@ pub mod ssl_wrapper { pub write: fn(T, &[u8]), pub on_data: fn(T, &[u8]), pub on_close: fn(T), + /// A new resumable TLS session arrived (serialized SSL_SESSION bytes) + /// - node's `'session'` event. `None` opts the SSL out of session + /// parking entirely (fetch / WebSocket tunnels have no consumer). + pub on_session: Option, + /// An NSS key-log line (with the trailing newline node appends) - + /// node's `'keylog'` event. Same opt-in rules as `on_session`. + pub on_keylog: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, strum::IntoStaticStr)] @@ -478,6 +485,14 @@ pub mod ssl_wrapper { let _ = scopeguard::ScopeGuard::into_inner(input_guard); let ssl = scopeguard::ScopeGuard::into_inner(ssl_guard); + // Opt into the parked new-session/keylog queues only when a + // handler will drain them (see `flush_pending_events`); the C + // callbacks skip un-opted SSLs entirely. + if handlers.on_session.is_some() || handlers.on_keylog.is_some() { + // SAFETY: `ssl` is the live SSL* created above. + unsafe { us_ssl_enable_pending_events(ssl.as_ptr()) }; + } + let flags = Flags::default(); flags.set_is_client(is_client); @@ -565,6 +580,26 @@ pub mod ssl_wrapper { }; // we already sent the ssl shutdown if Self::r(this).flags.sent_ssl_shutdown() || Self::r(this).flags.fatal_error() { + if fast_shutdown && !Self::r(this).flags.received_ssl_shutdown() { + // The peer went away (raw EOF / destroy) after we had + // already sent our shutdown, and its close_notify will + // never arrive. A fast shutdown means we are done for + // sure: mark received and run the close callback so the + // owner's teardown (UpgradedDuplex::on_close -> + // DuplexUpgradeContext::on_close -> deinit) actually + // happens. Without this, a TLS-over-duplex socket whose + // peer half-closes at the TCP level never tears down and + // leaks its whole context graph (LeakSanitizer caught + // this in test-tls-js-stream / test-tls-inception). + // trigger_close_callback is idempotent (closed_notified). + Self::r(this).flags.set_received_ssl_shutdown(true); + Self::r(this).trigger_close_callback(); + // Do not read self after the close callback: the owner's + // teardown chain has started (deinit is deferred to the + // next tick today, but nothing here should rely on that). + // The answer is known - we just set it. + return true; + } return Self::r(this).flags.received_ssl_shutdown(); } @@ -1121,6 +1156,64 @@ pub mod ssl_wrapper { // read data can trigger writing so we need to handle it Self::r(this).handle_writing(&mut buffer); } + + // The SSL_do_handshake/SSL_read calls above may have parked + // new-session tickets / keylog lines (BoringSSL surfaces them + // mid-read, where dispatching JS could free the SSL out from + // under the caller). The stack has unwound here, so hand them + // to the owner - same ordering as the C path's + // ssl_flush_pending_session: handshake/data callbacks first, + // then sessions. + Self::flush_pending_events(this, &mut buffer); + } + } + + /// Drain the parked new-session / keylog queues into the owner's + /// callbacks. Only SSLs whose handlers opted in ever park (see + /// `init_with_ctx`), so this is a no-op FFI probe otherwise. The + /// callbacks run JS which may close the wrapper; `self.ssl` is + /// re-checked between pops and nothing else of `self` is borrowed + /// across a dispatch. + fn flush_pending_events(this: *mut Self, buffer: &mut [u8; BUFFER_SIZE]) { + if Self::r(this).handlers.on_session.is_some() { + loop { + let Some(ssl) = Self::r(this).ssl else { return }; + // SAFETY: ssl is live (checked above); buffer is writable + // for BUFFER_SIZE bytes, which covers the 64 KB parking cap. + let len = unsafe { + us_ssl_pop_pending_session( + ssl.as_ptr(), + buffer.as_mut_ptr(), + c_int::try_from(BUFFER_SIZE).expect("int cast"), + ) + }; + if len <= 0 { + break; + } + if let Some(on_session) = Self::r(this).handlers.on_session { + on_session(Self::r(this).handlers.ctx, &buffer[..len as usize]); + } + } + } + if Self::r(this).handlers.on_keylog.is_some() { + loop { + let Some(ssl) = Self::r(this).ssl else { return }; + // SAFETY: same as the session pop above; keylog entries + // are capped at 4 KB+1, well within BUFFER_SIZE. + let len = unsafe { + us_ssl_pop_pending_keylog( + ssl.as_ptr(), + buffer.as_mut_ptr(), + c_int::try_from(BUFFER_SIZE).expect("int cast"), + ) + }; + if len <= 0 { + break; + } + if let Some(on_keylog) = Self::r(this).handlers.on_keylog { + on_keylog(Self::r(this).handlers.ctx, &buffer[..len as usize]); + } + } } } } @@ -1151,6 +1244,25 @@ pub mod ssl_wrapper { /// Implemented in uSockets C; reads /// `SSL_get_verify_result` and maps it onto the C `us_bun_verify_error_t`. fn us_ssl_socket_verify_error_from_ssl(ssl: *mut boring_sys::SSL) -> us_bun_verify_error_t; + /// Opt this SSL into the parked new-session/keylog queues + /// (openssl.c's `us_ssl_new_session_cb` / `us_ssl_keylog_cb` skip + /// SSLs without the marker). + // SAFETY (unsafe fn): `ssl` must be a live `SSL*`. + fn us_ssl_enable_pending_events(ssl: *mut boring_sys::SSL); + /// Pop the oldest parked session/keylog entry into `out`; returns the + /// entry length or 0 when the queue is empty. + // SAFETY (unsafe fn): `ssl` live; `out` writable for `out_cap` bytes. + fn us_ssl_pop_pending_session( + ssl: *mut boring_sys::SSL, + out: *mut u8, + out_cap: c_int, + ) -> c_int; + // SAFETY (unsafe fn): `ssl` live; `out` writable for `out_cap` bytes. + fn us_ssl_pop_pending_keylog( + ssl: *mut boring_sys::SSL, + out: *mut u8, + out_cap: c_int, + ) -> c_int; } } diff --git a/src/uws_sys/ListenSocket.rs b/src/uws_sys/ListenSocket.rs index a3beacd122ef..db68a98429ae 100644 --- a/src/uws_sys/ListenSocket.rs +++ b/src/uws_sys/ListenSocket.rs @@ -110,7 +110,10 @@ impl ListenSocket { NonNull::new(p.cast::()) } - pub fn on_server_name(&mut self, cb: extern "C" fn(*mut ListenSocket, *const c_char)) { + pub fn on_server_name( + &mut self, + cb: extern "C" fn(*mut ListenSocket, *const c_char, *mut c_int, *mut c_void) -> *mut c_void, + ) { us_listen_socket_on_server_name(self, cb) } } @@ -137,6 +140,6 @@ unsafe extern "C" { ) -> *mut c_void; safe fn us_listen_socket_on_server_name( ls: &mut ListenSocket, - cb: extern "C" fn(*mut ListenSocket, *const c_char), + cb: extern "C" fn(*mut ListenSocket, *const c_char, *mut c_int, *mut c_void) -> *mut c_void, ); } diff --git a/src/uws_sys/SocketContext.rs b/src/uws_sys/SocketContext.rs index 6da8e2b317c6..01c214b0b098 100644 --- a/src/uws_sys/SocketContext.rs +++ b/src/uws_sys/SocketContext.rs @@ -112,6 +112,8 @@ pub struct BunSocketContextOptions { pub ca: *const *const c_char, pub ca_count: u32, pub secure_options: u32, + pub ssl_min_version: i32, + pub ssl_max_version: i32, pub reject_unauthorized: i32, pub request_cert: i32, pub client_renegotiation_limit: u32, @@ -135,6 +137,8 @@ impl Default for BunSocketContextOptions { ca: ptr::null(), ca_count: 0, secure_options: 0, + ssl_min_version: 0, + ssl_max_version: 0, reject_unauthorized: 0, request_cert: 0, client_renegotiation_limit: 3, @@ -233,6 +237,8 @@ impl BunSocketContextOptions { feed_arr(&mut h, self.cert, self.cert_count); feed_arr(&mut h, self.ca, self.ca_count); h.update(bun_core::bytes_of(&self.secure_options)); + h.update(bun_core::bytes_of(&self.ssl_min_version)); + h.update(bun_core::bytes_of(&self.ssl_max_version)); h.update(bun_core::bytes_of(&self.reject_unauthorized)); h.update(bun_core::bytes_of(&self.request_cert)); h.update(bun_core::bytes_of(&self.client_renegotiation_limit)); @@ -304,5 +310,26 @@ pub mod c { ) -> *mut SSL_CTX; // safe: no args; reads a process-global counter — no preconditions. pub safe fn us_ssl_ctx_live_count() -> c_long; + /// Appends the certificates in the NUL-terminated PEM `content` to + /// `ctx`'s trust store; returns 0 when nothing could be added. + pub fn us_ssl_ctx_add_ca_cert( + ctx: *mut SSL_CTX, + content: *const core::ffi::c_char, + ) -> core::ffi::c_int; + /// Parses a PKCS#12 blob into malloc'd PEM key/cert/ca strings (the + /// caller frees them with libc free); returns 0 with a static + /// `err_reason` tag on failure. + pub fn us_ssl_parse_pkcs12( + data: *const core::ffi::c_char, + len: usize, + pass: *const core::ffi::c_char, + out_key: *mut *mut core::ffi::c_char, + out_key_len: *mut usize, + out_cert: *mut *mut core::ffi::c_char, + out_cert_len: *mut usize, + out_ca: *mut *mut core::ffi::c_char, + out_ca_len: *mut usize, + err_reason: *mut *const core::ffi::c_char, + ) -> core::ffi::c_int; } } diff --git a/src/uws_sys/SocketGroup.rs b/src/uws_sys/SocketGroup.rs index d1c4841aafa7..5f79040e190c 100644 --- a/src/uws_sys/SocketGroup.rs +++ b/src/uws_sys/SocketGroup.rs @@ -216,6 +216,7 @@ impl SocketGroup { ssl_ctx: Option<*mut SslCtx>, host: &core::ffi::CStr, port: c_int, + local_binding: Option<(&core::ffi::CStr, u16)>, options: c_int, socket_ext_size: c_int, ) -> ConnectResult { @@ -232,6 +233,8 @@ impl SocketGroup { ssl_ctx.unwrap_or(ptr::null_mut()), host.as_ptr(), port, + local_binding.map_or(ptr::null(), |(h, _)| h.as_ptr()), + local_binding.map_or(0, |(_, p)| c_int::from(p)), options, socket_ext_size, &raw mut has_dns_resolved, @@ -349,6 +352,8 @@ unsafe extern "C" { ssl_ctx: *mut SslCtx, host: *const c_char, port: c_int, + local_host: *const c_char, + local_port: c_int, options: c_int, socket_ext_size: c_int, is_connecting: *mut c_int, diff --git a/src/uws_sys/SocketKind.rs b/src/uws_sys/SocketKind.rs index fa2babc2061f..d52e2f745719 100644 --- a/src/uws_sys/SocketKind.rs +++ b/src/uws_sys/SocketKind.rs @@ -134,3 +134,7 @@ pub(crate) static BUN_SOCKET_KIND_UWS_HTTP_TLS: u8 = SocketKind::UwsHttpTls as u pub(crate) static BUN_SOCKET_KIND_UWS_WS: u8 = SocketKind::UwsWs as u8; #[unsafe(no_mangle)] pub(crate) static BUN_SOCKET_KIND_UWS_WS_TLS: u8 = SocketKind::UwsWsTls as u8; +/// Referenced from `openssl.c` so the new-session callback's per-SSL marker is +/// only set for the sockets that actually surface the `'session'` event. +#[unsafe(no_mangle)] +pub(crate) static BUN_SOCKET_KIND_BUN_SOCKET_TLS: u8 = SocketKind::BunSocketTls as u8; diff --git a/src/uws_sys/socket.rs b/src/uws_sys/socket.rs index 356b8f322713..0ed637ab2435 100644 --- a/src/uws_sys/socket.rs +++ b/src/uws_sys/socket.rs @@ -258,6 +258,17 @@ impl NewSocketHandler { // ── state queries ─────────────────────────────────────────────────────── + /// Raw-TCP write that also reports a fatal send error; non-Connected and + /// TLS-wrapped sockets fall back to the plain write (no fatal signal). + pub fn write_check_error(&self, data: &[u8]) -> (i32, bool) { + on_socket!(self.socket; + connected s => s.write_check_error(data), + duplex d => (d.encode_and_write(data), false), + pipe p => (p.encode_and_write(data), false), + else => (0, false), + ) + } + pub fn is_closed(&self) -> bool { on_socket!(self.socket; connected s => s.is_closed(), @@ -477,6 +488,40 @@ impl NewSocketHandler { } } + /// Set the IP type-of-service. Returns 0 on success or a negative errno; + /// non-TCP sockets (pipes, duplexes, not-yet-connected) report -EBADF (-9) + /// the way Node's no-handle fallback does. + pub fn set_tos(&self, tos: i32) -> i32 { + match self.socket { + InternalSocket::Connected(s) => sock(s).set_tos(tos), + _ => -9, + } + } + + /// Get the IP type-of-service (>= 0) or a negative errno. + pub fn get_tos(&self) -> i32 { + match self.socket { + InternalSocket::Connected(s) => sock(s).get_tos(), + _ => -9, + } + } + + /// Resume a handshake suspended by an asynchronous SNICallback. The ctx + /// reference is consumed (freed here when the socket is no longer a real + /// connected socket). + pub fn sni_resolve(&self, ctx: *mut crate::SslCtx, error: bool) { + match self.socket { + InternalSocket::Connected(s) => sock(s).sni_resolve(ctx, error), + _ => { + // The socket is gone; release the reference the caller handed us. + if !ctx.is_null() { + // SAFETY: the caller passed an owned SSL_CTX reference. + unsafe { bun_boringssl_sys::SSL_CTX_free(ctx) }; + } + } + } + } + // ── TLS ───────────────────────────────────────────────────────────────── /// Kick TLS open (ClientHello / accept) on an already-connected socket. @@ -710,7 +755,7 @@ impl NewSocketHandler { // layout — NOT `Option<*mut Owner>` (16 bytes, discriminant-first), // which would hand the trampoline `1` instead of the owner pointer. let ext_size = size_of::>>() as c_int; - match g.connect(kind, ssl_ctx, host_z, port, opts, ext_size) { + match g.connect(kind, ssl_ctx, host_z, port, None, opts, ext_size) { ConnectResult::Failed => Err(ConnectError::FailedToOpenSocket), ConnectResult::Socket(s) => { *sock(s).ext::>>() = NonNull::new(owner); diff --git a/src/uws_sys/us_socket_t.rs b/src/uws_sys/us_socket_t.rs index edb5fff344e5..25d245205ace 100644 --- a/src/uws_sys/us_socket_t.rs +++ b/src/uws_sys/us_socket_t.rs @@ -90,6 +90,23 @@ impl us_socket_t { c::us_socket_is_closed(self) > 0 } + /// Write that also reports a fatal (non-would-block) send error so the + /// node:net path can fail the pending write instead of waiting forever. + pub fn write_check_error(&self, data: &[u8]) -> (i32, bool) { + let mut fatal: i32 = 0; + // SAFETY: `self` is a live `us_socket_t`; `data` is valid for its length + // (clamped to i32) and `fatal` outlives the call as the out-parameter. + let written = unsafe { + c::us_socket_write_check_error( + self, + data.as_ptr().cast(), + i32::try_from(data.len().min(MAX_I32)).expect("int cast"), + &raw mut fatal, + ) + }; + (written, fatal != 0) + } + pub fn is_shutdown(&self) -> bool { c::us_socket_is_shut_down(self) > 0 } @@ -154,6 +171,24 @@ impl us_socket_t { c::us_socket_keepalive(self, enabled as c_int, delay) } + /// Set the IP type-of-service / traffic class. Returns 0 on success or a + /// negative platform errno. + pub fn set_tos(&mut self, tos: i32) -> i32 { + c::us_socket_set_tos(self, tos) + } + + /// Get the IP type-of-service / traffic class (>= 0) or a negative errno. + pub fn get_tos(&mut self) -> i32 { + c::us_socket_get_tos(self) + } + + /// Resume a handshake suspended by an asynchronous SNICallback. `ctx` + /// carries an owned SSL_CTX reference that the call consumes (may be + /// null = fall through to the default context); `error` aborts instead. + pub fn sni_resolve(&mut self, ctx: *mut SslCtx, error: bool) { + c::us_socket_sni_resolve(self, ctx, error as c_int); + } + /// `SSL*` if TLS, else null. Use `get_fd()` for the descriptor. pub fn ssl(&mut self) -> Option<&mut bun_boringssl_sys::SSL> { if !self.is_tls() { @@ -237,6 +272,7 @@ impl us_socket_t { k: SocketKind, ssl_ctx: &mut SslCtx, sni: Option<&core::ffi::CStr>, + is_client: bool, old_ext: i32, new_ext: i32, ) -> Option> { @@ -249,6 +285,7 @@ impl us_socket_t { k as u8, ssl_ctx, sni.map_or(ptr::null(), |s| s.as_ptr()), + is_client as i32, old_ext, new_ext, )) @@ -261,6 +298,29 @@ impl us_socket_t { c::us_socket_start_tls_handshake(self); } + /// Feed bytes that were already read off the wire (e.g. a ClientHello the + /// plain-TCP layer consumed before the upgrade) through the same decrypt + /// path as bytes arriving from the kernel. + pub fn tls_feed(&mut self, data: &[u8]) { + if data.is_empty() { + return; + } + // The C side takes an `int` length: feed in i32-sized chunks instead of + // truncating the cast (a clamp would silently drop the tail and there is + // no return value to report a partial feed). Each chunk can re-enter the + // data dispatch, which may close the socket — stop feeding once it does. + for chunk in data.chunks(MAX_I32) { + if self.is_closed() { + return; + } + // SAFETY: `self` is a live TLS `us_socket_t`; `chunk` is valid for its + // length, which fits in an i32 by construction. + unsafe { + c::us_socket_tls_feed(self, chunk.as_ptr().cast(), chunk.len() as i32); + } + } + } + /// Tee inbound ciphertext to `us_dispatch_ssl_raw_tap` before `SSL_read` /// consumes it, so the `[raw, tls]` pair from `upgradeTLS` can surface /// encrypted bytes to the original net.Socket `data` listener. @@ -412,6 +472,13 @@ mod c { pub(super) safe fn us_socket_timeout(s: &mut us_socket_t, seconds: c_uint); pub(super) safe fn us_socket_long_timeout(s: &mut us_socket_t, minutes: c_uint); pub(super) safe fn us_socket_nodelay(s: &mut us_socket_t, enable: c_int); + pub(super) safe fn us_socket_set_tos(s: &mut us_socket_t, tos: c_int) -> c_int; + pub(super) safe fn us_socket_get_tos(s: &mut us_socket_t) -> c_int; + pub(super) safe fn us_socket_sni_resolve( + s: &mut us_socket_t, + ctx: *mut SslCtx, + error: c_int, + ); pub(super) safe fn us_socket_keepalive( s: &mut us_socket_t, enable: c_int, @@ -459,6 +526,12 @@ mod c { ) -> *mut us_socket_t; pub(super) safe fn us_socket_shutdown(s: &mut us_socket_t); pub(super) safe fn us_socket_is_closed(s: &us_socket_t) -> i32; + pub(super) fn us_socket_write_check_error( + s: &us_socket_t, + data: *const core::ffi::c_char, + length: i32, + fatal_write_error: *mut i32, + ) -> i32; pub(super) safe fn us_socket_shutdown_read(s: &mut us_socket_t); pub(super) safe fn us_socket_is_shut_down(s: &us_socket_t) -> i32; pub(super) safe fn us_socket_sendfile_needs_more(socket: &mut us_socket_t); @@ -481,9 +554,16 @@ mod c { kind: u8, ssl_ctx: *mut SslCtx, sni: *const c_char, + is_client: i32, old_ext_size: i32, ext_size: i32, ) -> *mut us_socket_t; + /// Feed already-read bytes through the TLS decrypt path. + pub(super) fn us_socket_tls_feed( + s: *mut us_socket_t, + data: *const c_char, + length: i32, + ) -> *mut us_socket_t; pub(super) safe fn us_socket_start_tls_handshake(s: &mut us_socket_t); } } diff --git a/test/cli/init/init.test.ts b/test/cli/init/init.test.ts index 7bbb87a81ea4..8345cd1dcad0 100644 --- a/test/cli/init/init.test.ts +++ b/test/cli/init/init.test.ts @@ -3,6 +3,11 @@ import fs, { readdirSync } from "fs"; import { bunEnv, bunExe, isWindows, tempDirWithFiles } from "harness"; import path from "path"; +// Whether `bun init` emits CLAUDE.md depends on a `claude` binary being on +// PATH, which varies by CI machine — disable the detection so the directory +// snapshots are stable everywhere. +const initEnv = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "1" }; + (isWindows ? describe : describe.concurrent)("bun init", () => { test("bun init works", async () => { const temp = tempDirWithFiles("bun-init-works", {}); @@ -11,7 +16,7 @@ import path from "path"; cmd: [bunExe(), "init", "-y"], cwd: temp, stdio: ["ignore", "inherit", "inherit"], - env: bunEnv, + env: initEnv, }); expect(await exited).toBe(0); @@ -47,7 +52,7 @@ import path from "path"; cmd: [bunExe(), "init"], cwd: temp, stdio: [new Blob(["\n\n\n\n\n\n\n\n\n\n\n\n"]), "inherit", "inherit"], - env: bunEnv, + env: initEnv, }); expect(await exited).toBe(0); @@ -90,7 +95,7 @@ import path from "path"; cmd: [bunExe(), "init", "-y", "mydir"], cwd: temp, stdio: ["ignore", "inherit", "inherit"], - env: bunEnv, + env: initEnv, }); expect(await exited).toBe(0); expect(readdirSync(temp).sort()).toEqual(["mydir"]); @@ -115,7 +120,7 @@ import path from "path"; cmd: [bunExe(), "init", "-y", "mydir"], cwd: temp, stdio: ["ignore", "pipe", "pipe"], - env: bunEnv, + env: initEnv, }); expect(await exited).not.toBe(0); expect(readdirSync(temp).sort()).toEqual(["mydir"]); @@ -128,7 +133,7 @@ import path from "path"; cmd: [bunExe(), "init", "-y", "u t f ∞™/subpath"], cwd: temp, stdio: ["ignore", "inherit", "inherit"], - env: bunEnv, + env: initEnv, }); expect(await exited).toBe(0); expect(readdirSync(temp).sort()).toEqual(["u t f ∞™"]); @@ -152,7 +157,7 @@ import path from "path"; cmd: [bunExe(), "init", "-y", "mydir"], cwd: temp, stdio: ["ignore", "inherit", "inherit"], - env: bunEnv, + env: initEnv, }); expect(await exited).toBe(0); expect(readdirSync(temp).sort()).toEqual(["mydir"]); @@ -182,7 +187,7 @@ import path from "path"; cmd: [bunExe(), "init", "mydir"], cwd: temp, stdio: ["ignore", "pipe", "pipe"], - env: bunEnv, + env: initEnv, }); expect(await exited2).toBe(0); expect(await stderr.text()).toMatchInlineSnapshot(` @@ -231,7 +236,7 @@ import path from "path"; cmd: [bunExe(), "init", "--react"], cwd: temp, stdio: ["ignore", "inherit", "inherit"], - env: bunEnv, + env: initEnv, }); expect(await exited).toBe(0); @@ -254,7 +259,7 @@ import path from "path"; cmd: [bunExe(), "init", "--react=tailwind"], cwd: temp, stdio: ["ignore", "inherit", "inherit"], - env: bunEnv, + env: initEnv, }); expect(await exited).toBe(0); @@ -277,7 +282,7 @@ import path from "path"; cmd: [bunExe(), "init", "--react=shadcn"], cwd: temp, stdio: ["ignore", "inherit", "inherit"], - env: bunEnv, + env: initEnv, }); expect(await exited).toBe(0); @@ -306,7 +311,7 @@ import path from "path"; await using proc = Bun.spawn({ cmd: [bunExe(), "init", "-y"], cwd: temp, - env: bunEnv, + env: initEnv, stdin: "ignore", stdout: "pipe", stderr: "pipe", diff --git a/test/expectations.txt b/test/expectations.txt index 8dcb0baf21fe..deb042f26da6 100644 --- a/test/expectations.txt +++ b/test/expectations.txt @@ -54,3 +54,45 @@ test/js/bun/spawn/spawn-maxbuf.test.ts [ FLAKY ] [ ASAN ] test/cli/run/require-cache.test.ts [ LEAK ] # files transpiled and loaded don't leak file paths > via require() [ ASAN ] test/js/bun/http/req-url-leak.test.ts [ LEAK ] # req.url doesn't leak memory [ ASAN ] test/js/bun/io/bun-write-leak.test.ts [ LEAK ] # Bun.write should not leak the output data + +# Windows-only gaps in named-pipe / socket teardown for ported Node net tests +# (these pass on Linux and macOS): half-close (FIN) handling on named pipes, +# RST delivery surfacing as ECONNRESET, and EADDRINUSE on a second listen on +# the same pipe path. +[ WINDOWS ] test/js/node/test/parallel/test-net-pingpong.js [ FAIL ] # named-pipe half-close (FIN) handling +[ WINDOWS ] test/js/node/test/parallel/test-net-socket-reset-send.js [ FAIL ] # reset not surfaced as ECONNRESET on Windows +[ WINDOWS ] test/js/node/test/parallel/test-net-connect-reset-after-destroy.js [ FAIL ] # reset not surfaced as ECONNRESET on Windows +[ WINDOWS ] test/js/node/test/parallel/test-net-connect-reset-until-connected.js [ FAIL ] # reset not surfaced as ECONNRESET on Windows +[ WINDOWS ] test/js/node/test/parallel/test-net-pipe-connect-errors.js [ FAIL ] # named-pipe connect errors are not mapped to ENOENT/EACCES on Windows yet +[ WINDOWS ] test/js/node/test/parallel/test-net-server-listen-path.js [ FAIL ] # EADDRINUSE not reported for a second listen on the same pipe path + +# Same Windows half-close + client-RST gap over a node:http server: the server +# half-closes (res.socket.end()) mid-upload, the fetch client RSTs the cut-short +# 10 MB POST body, and that RST is not surfaced to the server's still-open +# readable side on Windows, so the accepted connection never ends and +# server.close() (via `await using`) waits forever. The assertion itself passes; +# only the teardown hangs. Passes on Linux and macOS. +[ WINDOWS ] test/js/bun/test/parallel/test-http-should-not-emit-or-throw-error-when-writing-after-socket.end.ts [ FAIL ] # half-close + client RST not surfaced on Windows; server.close() waits forever + +# The 10 MB socket write in this test is an order of magnitude slower under +# AddressSanitizer and exceeds the per-test timeout; it passes on regular builds. +[ ASAN ] test/js/node/test/parallel/test-net-error-twice.js [ SKIP ] # ASAN-instrumented 10 MB write exceeds the timeout + +# The localAddress/localPort bind-before-connect and the SO_ERROR read on a +# connecting socket that was reset during establishment are implemented in the +# POSIX (kqueue/epoll + BSD socket) connect path; Windows connects through +# libuv and needs its own implementation of both. +[ WINDOWS ] test/js/node/test/parallel/test-net-client-bind-twice.js [ FAIL ] # localAddress/localPort binding not implemented for the libuv connect path +[ WINDOWS ] test/js/node/test/parallel/test-net-server-reset.js [ FAIL ] # connect-time RST reports ECONNREFUSED instead of ECONNRESET on the libuv path +# Cluster workers sharing a listen port behave differently on Linux, where +# SO_REUSEPORT load-balances across the workers' own listeners instead of the +# primary distributing accepted connections; the upstream test's expectations +# only hold on the distributing model. Passes on macOS, Windows and FreeBSD. +[ LINUX ] test/js/node/test/sequential/test-net-listen-shared-ports.js [ FAIL ] # SO_REUSEPORT shared-listener semantics on Linux + +# The fetch/h2 client teardown on Windows surfaces read ECONNRESET (WSAECONNRESET) +# between tests when the pooled connection is dropped without a FIN; every test in +# the file passes, the stray teardown error makes the run exit 1. Tracked as a +# known follow-up in the PR description; quarantined here until the Windows +# teardown path is reworked. +[ WINDOWS ] test/js/web/fetch/fetch-http2-client.test.ts [ FAIL ] # pooled-h2 teardown surfaces WSAECONNRESET between tests diff --git a/test/integration/bun-types/fixture/serve-types.test.ts b/test/integration/bun-types/fixture/serve-types.test.ts index 90af82d39291..358eeeaa1e5c 100644 --- a/test/integration/bun-types/fixture/serve-types.test.ts +++ b/test/integration/bun-types/fixture/serve-types.test.ts @@ -101,7 +101,7 @@ test( }, { onConstructorFailure: error => { - expect(error.message).toContain("BoringSSL error:0900006e:PEM routines:OPENSSL_internal:NO_START_LINE"); + expect(error.message).toContain("error:0900006e:PEM routines:OPENSSL_internal:NO_START_LINE"); }, }, ); diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index ead9e8d856e9..6181b9cea90b 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -637,7 +637,7 @@ describe.concurrent("socket", () => { }), ).toThrow( expect.objectContaining({ - code: "ERR_BORINGSSL", + code: "ERR_OSSL_PEM_NO_START_LINE", }), ); @@ -651,7 +651,7 @@ describe.concurrent("socket", () => { }), ).toThrow( expect.objectContaining({ - code: "ERR_BORINGSSL", + code: "ERR_OSSL_PEM_NO_START_LINE", }), ); @@ -666,7 +666,7 @@ describe.concurrent("socket", () => { }), ).toThrow( expect.objectContaining({ - code: "ERR_BORINGSSL", + code: "ERR_OSSL_PEM_NO_START_LINE", }), ); diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index 3281f44dfed5..980e8884e7b2 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -1,4 +1,4 @@ -import { bunEnv, bunExe, isASAN, isCI, nodeExe } from "harness"; +import { bunEnv, bunExe, isASAN, isCI, isDebug, nodeExe } from "harness"; import { createTest } from "node-harness"; import fs from "node:fs"; import http2 from "node:http2"; @@ -10,7 +10,10 @@ import { Duplex } from "stream"; import http2utils from "./helpers"; import { nodeEchoServer, TLS_CERT, TLS_OPTIONS } from "./http2-helpers"; const { describe, expect, it, beforeAll, afterAll, createCallCheckCtx } = createTest(import.meta.path); -const ASAN_MULTIPLIER = isASAN ? 3 : 1; +// bun-debug ships with ASAN but isn't named bun-asan, so isASAN is false +// there; the 10k-request maxSessionMemory stress test takes ~90s under +// debug+ASAN vs ~2s release, so scale for either. +const ASAN_MULTIPLIER = isDebug ? 10 : isASAN ? 3 : 1; function invalidArgTypeHelper(input) { if (input === null) return " Received null"; @@ -1618,7 +1621,7 @@ it("http2 session.goaway() validates input types", async done => { // Test opaqueData argument expect(() => session.goaway(0, 0, input)).toThrow( - 'The "opaqueData" argument must be of type Buffer, ' + `TypedArray, or DataView.${received}`, + 'The "opaqueData" argument must be an instance of Buffer, ' + `TypedArray, or DataView.${received}`, ); } diff --git a/test/js/node/net/node-net.test.ts b/test/js/node/net/node-net.test.ts index 73ed0dffd452..c670d7f5b725 100644 --- a/test/js/node/net/node-net.test.ts +++ b/test/js/node/net/node-net.test.ts @@ -742,6 +742,9 @@ it("should not hang after destroy", async () => { const net = require("node:net"); const { promise: listening, resolve: resolveListening, reject } = Promise.withResolvers(); const server = net.createServer(c => { + // The client destroys without reading; the resulting RST surfaces as + // ECONNRESET here (Node behaves identically) — handle it. + c.on("error", () => {}); c.write("Hello client"); }); try { diff --git a/test/js/node/test/common/boringssl.js b/test/js/node/test/common/boringssl.js new file mode 100644 index 000000000000..46e0738d596b --- /dev/null +++ b/test/js/node/test/common/boringssl.js @@ -0,0 +1,346 @@ +/* eslint-disable node-core/crypto-check */ + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const fixtures = require('../common/fixtures'); +const tls = require('tls'); + +// This module is for BoringSSL-specific branches in tests whose original +// OpenSSL coverage cannot run unchanged. Each helper should assert the +// observable BoringSSL behavior that explains why the OpenSSL-specific +// assertions are bypassed. + +/** + * BoringSSL exposes many removed or disabled TLS cipher suites as "no match" + * at secure-context creation time. This is used for suites such as + * finite-field DHE and anonymous ECDH that OpenSSL builds may still negotiate + * in tests. + * @param {Function} fn + */ +function assertNoCipherMatch(fn) { + // Only the code is asserted: the OpenSSL-style decomposition (library/ + // function/reason casing) differs between the native handshake path and the + // JS cipher validation path that produces this error. + assert.throws(fn, { + code: 'ERR_SSL_NO_CIPHER_MATCH', + }); +} + +/** + * BoringSSL does not parse OpenSSL cipher-string commands such as `@SECLEVEL`. + * Those are OpenSSL policy directives, not cipher names. + * @param {Function} fn + */ +function assertInvalidCommand(fn) { + assert.throws(fn, { + code: 'ERR_SSL_INVALID_COMMAND', + library: 'SSL routines', + function: 'OPENSSL_internal', + reason: 'INVALID_COMMAND', + }); +} + +/** + * Node's DHE tests exercise OpenSSL's finite-field DHE cipher support and DH + * parameter-size policy. BoringSSL does not offer these DHE cipher suites on + * this surface, so creating a server context with a DHE-only cipher list fails + * before a handshake can test DH parameter behavior. + */ +function assertFiniteFieldDheUnsupported() { + assertNoCipherMatch(() => { + tls.createServer({ + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem'), + ciphers: 'DHE-RSA-AES128-GCM-SHA256', + }); + }); +} + +/** + * OpenSSL security levels reject small keys by policy and can be adjusted with + * `@SECLEVEL` in the cipher string. BoringSSL does not implement those security + * levels: the small-key server context is accepted, while the OpenSSL-specific + * `@SECLEVEL` command is rejected as invalid cipher-string syntax. + */ +function assertOpenSSLSecurityLevelsUnsupported() { + const options = { + key: fixtures.readKey('agent11-key.pem'), + cert: fixtures.readKey('agent11-cert.pem'), + ciphers: 'DEFAULT', + }; + + tls.createServer(options).close(); + + options.ciphers = 'DEFAULT:@SECLEVEL=0'; + assertInvalidCommand(() => tls.createServer(options)); +} + +/** + * Node's multi-key tests rely on OpenSSL accepting an array of private keys and + * matching them with an array of certificates. BoringSSL rejects this mixed + * EC/RSA identity configuration while configuring the certificate chain, before + * a client can negotiate either identity. + */ +function assertMultiKeyUnsupported() { + assert.throws(() => { + tls.createServer({ + key: [ + fixtures.readKey('ec10-key.pem'), + fixtures.readKey('agent1-key.pem'), + ], + cert: [ + fixtures.readKey('agent1-cert.pem'), + fixtures.readKey('ec10-cert.pem'), + ], + }); + }, { + code: 'ERR_OSSL_X509_KEY_TYPE_MISMATCH', + library: 'X.509 certificate routines', + function: 'OPENSSL_internal', + reason: 'KEY_TYPE_MISMATCH', + }); +} + +/** + * BoringSSL does not support caller-initiated renegotiation. Even on a TLS 1.2 + * connection, TLSSocket#renegotiate() returns false and the callback receives + * Node's BoringSSL-specific unsupported-renegotiation error instead of + * entering the native binding or exercising Node's renegotiation-limit logic. + */ +function testRenegotiationUnsupported() { + const server = tls.createServer({ + key: fixtures.readKey('rsa_private.pem'), + cert: fixtures.readKey('rsa_cert.crt'), + maxVersion: 'TLSv1.2', + }, (socket) => socket.resume()); + + server.listen(0, common.mustCall(() => { + const client = tls.connect({ + port: server.address().port, + rejectUnauthorized: false, + maxVersion: 'TLSv1.2', + }, common.mustCall(() => { + const ok = client.renegotiate({}, common.mustCall((err) => { + assert.throws(() => { throw err; }, { + code: 'ERR_TLS_RENEGOTIATION_UNSUPPORTED', + message: 'TLS session renegotiation is unsupported by this TLS ' + + 'implementation', + }); + client.destroy(); + server.close(); + })); + assert.strictEqual(ok, false); + })); + client.on('error', common.mustNotCall()); + })); +} + +/** + * OpenSSL exposes the negotiated ephemeral key type, name, and size for TLS + * clients. With BoringSSL the same ECDHE TLS 1.2 handshake succeeds, but + * getEphemeralKeyInfo() returns null on the server side and an object whose + * fields are undefined on the client side. + */ +function testEphemeralKeyInfoUnsupported() { + const server = tls.createServer({ + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem'), + ciphers: 'ECDHE-RSA-AES256-GCM-SHA384', + ecdhCurve: 'prime256v1', + maxVersion: 'TLSv1.2', + }, common.mustCall((socket) => { + assert.strictEqual(socket.getEphemeralKeyInfo(), null); + socket.end(); + })); + + server.listen(0, common.mustCall(() => { + const client = tls.connect({ + port: server.address().port, + rejectUnauthorized: false, + maxVersion: 'TLSv1.2', + }, common.mustCall(() => { + assert.deepStrictEqual(client.getEphemeralKeyInfo(), { + type: undefined, + name: undefined, + size: undefined, + }); + server.close(); + })); + })); +} + +/** + * The protocol matrix tests cover OpenSSL behavior for legacy TLS protocols. + * For BoringSSL we only need to exhibit that a TLSv1-only client cannot connect + * to a server whose minimum protocol is TLS 1.2; the client receives the + * protocol-version alert instead of the OpenSSL version-specific matrix. + */ +function testLegacyProtocolUnsupported() { + const server = tls.createServer({ + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem'), + minVersion: 'TLSv1.2', + }, common.mustNotCall()); + + server.on('tlsClientError', common.mustCall()); + server.listen(0, common.mustCall(() => { + const client = tls.connect({ + port: server.address().port, + rejectUnauthorized: false, + secureProtocol: 'TLSv1_method', + }, common.mustNotCall()); + client.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION'); + server.close(); + })); + })); +} + +/** + * BoringSSL can load a multi-PFX option well enough to serve the ECDSA + * identity, but it does not provide the same OpenSSL multi-identity selection + * behavior. After the ECDSA handshake succeeds, an RSA-only client fails with + * no shared cipher instead of selecting the RSA identity from the same PFX list. + */ +function testMultiPfxSelectionDifference() { + const server = tls.createServer({ + pfx: [ + { + buf: fixtures.readKey('agent1.pfx'), + passphrase: 'sample', + }, + fixtures.readKey('ec.pfx'), + ], + }, common.mustCallAtLeast((socket) => socket.end(), 1)); + + server.listen(0, common.mustCall(() => { + const ecdsa = tls.connect(server.address().port, { + ciphers: 'ECDHE-ECDSA-AES256-GCM-SHA384', + maxVersion: 'TLSv1.2', + rejectUnauthorized: false, + }, common.mustCall(() => { + assert.strictEqual(ecdsa.getCipher().name, + 'ECDHE-ECDSA-AES256-GCM-SHA384'); + ecdsa.end(); + + server.once('tlsClientError', common.mustCall((err) => { + assert.strictEqual(err.code, 'ERR_SSL_NO_SHARED_CIPHER'); + })); + const rsa = tls.connect(server.address().port, { + ciphers: 'ECDHE-RSA-AES256-GCM-SHA384', + maxVersion: 'TLSv1.2', + rejectUnauthorized: false, + }, common.mustNotCall()); + rsa.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE'); + server.close(); + })); + })); + })); +} + +/** + * PSK works for TLS 1.2 in BoringSSL, but Node's PSK tests also cover the + * default TLS 1.3 path. In that path BoringSSL does not complete a certificate- + * less PSK-only handshake through Node's current server setup: the server + * reports NO_CERTIFICATE_SET and the client receives an internal-error alert. + */ +function testPskTls13Unsupported() { + const key = Buffer.from('d731ef57be09e5204f0b205b60627028', 'hex'); + let gotClientError = false; + let gotServerError = false; + function maybeClose(server) { + if (gotClientError && gotServerError) + server.close(); + } + + const server = tls.createServer({ + ciphers: 'PSK+HIGH', + pskCallback() { return key; }, + }, common.mustNotCall()); + + server.once('tlsClientError', common.mustCall((err) => { + assert.strictEqual(err.code, 'ERR_SSL_NO_CERTIFICATE_SET'); + gotServerError = true; + maybeClose(server); + })); + + server.listen(0, common.mustCall(() => { + const client = tls.connect({ + port: server.address().port, + ciphers: 'PSK+HIGH', + checkServerIdentity() {}, + pskCallback() { + return { psk: key, identity: 'TestUser' }; + }, + }, common.mustNotCall()); + client.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'ERR_SSL_TLSV1_ALERT_INTERNAL_ERROR'); + gotClientError = true; + maybeClose(server); + })); + })); +} + +/** + * The OpenSSL ticket tests assume that once a TLS 1.3 session is reused, the + * client will not necessarily receive a replacement session event before close. + * BoringSSL emits new session tickets on both the initial and resumed TLS 1.3 + * connections, so the resumed connection still emits at least one 'session' + * event while isSessionReused() is true. + */ +function testTls13SessionTicketSemanticsDiffer() { + const server = tls.createServer({ + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem'), + }, (socket) => socket.end()); + + let session; + let secondSessionEvents = 0; + + server.listen(0, common.mustCall(() => { + const first = tls.connect({ + port: server.address().port, + rejectUnauthorized: false, + }, common.mustCall(() => { + assert.strictEqual(first.isSessionReused(), false); + })); + first.on('session', common.mustCallAtLeast((sess) => { + session = sess; + }, 1)); + first.on('close', common.mustCall(() => { + assert(Buffer.isBuffer(session)); + + const second = tls.connect({ + port: server.address().port, + rejectUnauthorized: false, + session, + }, common.mustCall(() => { + assert.strictEqual(second.isSessionReused(), true); + })); + second.on('session', common.mustCallAtLeast(() => { + secondSessionEvents++; + }, 1)); + second.on('close', common.mustCall(() => { + assert(secondSessionEvents > 0); + server.close(); + })); + second.resume(); + })); + first.resume(); + })); +} + +module.exports = { + assertFiniteFieldDheUnsupported, + assertMultiKeyUnsupported, + assertNoCipherMatch, + assertOpenSSLSecurityLevelsUnsupported, + testEphemeralKeyInfoUnsupported, + testLegacyProtocolUnsupported, + testMultiPfxSelectionDifference, + testPskTls13Unsupported, + testRenegotiationUnsupported, + testTls13SessionTicketSemanticsDiffer, +}; diff --git a/test/js/node/test/common/index.js b/test/js/node/test/common/index.js index 0106c03453a1..213f8a802f7b 100644 --- a/test/js/node/test/common/index.js +++ b/test/js/node/test/common/index.js @@ -185,7 +185,7 @@ const isPi = (() => { const isDumbTerminal = process.env.TERM === 'dumb'; // When using high concurrency or in the CI we need much more time for each connection attempt -net.setDefaultAutoSelectFamilyAttemptTimeout(platformTimeout(net.getDefaultAutoSelectFamilyAttemptTimeout() * 10)); +net.setDefaultAutoSelectFamilyAttemptTimeout(platformTimeout(net.getDefaultAutoSelectFamilyAttemptTimeout() * 5)); const defaultAutoSelectFamilyAttemptTimeout = net.getDefaultAutoSelectFamilyAttemptTimeout(); const buildType = process.config.target_defaults ? diff --git a/test/js/node/test/common/tls.js b/test/js/node/test/common/tls.js index bbc37df19c55..8568659d13cc 100644 --- a/test/js/node/test/common/tls.js +++ b/test/js/node/test/common/tls.js @@ -186,4 +186,46 @@ exports.assertIsCAArray = function assertIsCAArray(certs) { } }; +function extractMetadata(cert) { + const x509 = new crypto.X509Certificate(cert); + return { + serialNumber: x509.serialNumber, + issuer: x509.issuer, + subject: x509.subject, + }; +} +exports.extractMetadata = extractMetadata; + +// To compare two certificates, we can just compare serialNumber, issuer, +// and subject like X509_comp(). We can't just compare two strings because +// the line endings or order of the fields may differ after PEM serdes by +// OpenSSL. +exports.assertEqualCerts = function assertEqualCerts(a, b) { + const setA = new Set(a.map(extractMetadata)); + const setB = new Set(b.map(extractMetadata)); + assert.deepStrictEqual(setA, setB); +}; + +exports.includesCert = function includesCert(certs, cert) { + const metadata = extractMetadata(cert); + for (const c of certs) { + const cMetadata = extractMetadata(c); + if (cMetadata.serialNumber === metadata.serialNumber && + cMetadata.issuer === metadata.issuer && + cMetadata.subject === metadata.subject) { + return true; + } + } + return false; +}; + exports.TestTLSSocket = TestTLSSocket; + +// Dumps certs into a file to pass safely into test/fixtures/list-certs.js +exports.writeCerts = function writeCerts(certs, filename) { + const fs = require('fs'); + for (const cert of certs) { + const x509 = new crypto.X509Certificate(cert); + fs.appendFileSync(filename, x509.toString()); + } +}; diff --git a/test/js/node/test/fixtures/list-certs.js b/test/js/node/test/fixtures/list-certs.js new file mode 100644 index 000000000000..cebb9eb5627d --- /dev/null +++ b/test/js/node/test/fixtures/list-certs.js @@ -0,0 +1,19 @@ +const assert = require('assert'); +const EXPECTED_CERTS_PATH = process.env.EXPECTED_CERTS_PATH; +let expectedCerts = []; +if (EXPECTED_CERTS_PATH) { + const fs = require('fs'); + const file = fs.readFileSync(EXPECTED_CERTS_PATH, 'utf-8'); + expectedCerts = file.split('-----END CERTIFICATE-----\n') + .filter(line => line.trim() !== '') + .map(line => line + '-----END CERTIFICATE-----\n'); +} + +const tls = require('tls'); +const { includesCert, extractMetadata } = require('../common/tls'); + +const CERTS_TYPE = process.env.CERTS_TYPE || 'default'; +const actualCerts = tls.getCACertificates(CERTS_TYPE); +for (const cert of expectedCerts) { + assert(includesCert(actualCerts, cert), 'Expected certificate not found: ' + JSON.stringify(extractMetadata(cert))); +} diff --git a/test/js/node/test/fixtures/tls-extra-ca-override.js b/test/js/node/test/fixtures/tls-extra-ca-override.js new file mode 100644 index 000000000000..9d7065ba4f24 --- /dev/null +++ b/test/js/node/test/fixtures/tls-extra-ca-override.js @@ -0,0 +1,50 @@ +'use strict'; + +// Test script for overidding NODE_EXTRA_CA_CERTS with tls.setDefaultCACertificates(). + +const tls = require('tls'); +const assert = require('assert'); +const { assertEqualCerts, includesCert } = require('../common/tls'); + +// Assert that NODE_EXTRA_CA_CERTS is set +assert(process.env.NODE_EXTRA_CA_CERTS, 'NODE_EXTRA_CA_CERTS environment variable should be set'); + +// Get initial state with extra CA +const initialDefaults = tls.getCACertificates('default'); +const systemCerts = tls.getCACertificates('system'); +const bundledCerts = tls.getCACertificates('bundled'); +const extraCerts = tls.getCACertificates('extra'); + +// For this test to work the extra certs must not be in bundled certs +assert.notStrictEqual(bundledCerts.length, 0); +for (const cert of extraCerts) { + assert(!includesCert(bundledCerts, cert)); +} + +// Test setting it to initial defaults. +tls.setDefaultCACertificates(initialDefaults); +assertEqualCerts(tls.getCACertificates('default'), initialDefaults); +assertEqualCerts(tls.getCACertificates('default'), initialDefaults); + +// Test setting it to the bundled certificates. +tls.setDefaultCACertificates(bundledCerts); +assertEqualCerts(tls.getCACertificates('default'), bundledCerts); +assertEqualCerts(tls.getCACertificates('default'), bundledCerts); + +// Test setting it to just the extra certificates. +tls.setDefaultCACertificates(extraCerts); +assertEqualCerts(tls.getCACertificates('default'), extraCerts); +assertEqualCerts(tls.getCACertificates('default'), extraCerts); + +// Test setting it to an empty array. +tls.setDefaultCACertificates([]); +assert.deepStrictEqual(tls.getCACertificates('default'), []); + +// Test bundled and extra certs are unaffected +assertEqualCerts(tls.getCACertificates('bundled'), bundledCerts); +assertEqualCerts(tls.getCACertificates('extra'), extraCerts); + +if (systemCerts.length > 0) { + // Test system certs are unaffected. + assertEqualCerts(tls.getCACertificates('system'), systemCerts); +} diff --git a/test/js/node/test/fixtures/tls-get-ca-certificates-worker.js b/test/js/node/test/fixtures/tls-get-ca-certificates-worker.js new file mode 100644 index 000000000000..1d05fcacac54 --- /dev/null +++ b/test/js/node/test/fixtures/tls-get-ca-certificates-worker.js @@ -0,0 +1,10 @@ +'use strict'; + +const tls = require('tls'); +const { parentPort } = require('worker_threads'); + +parentPort.postMessage({ + bundledLen: tls.getCACertificates('bundled').length, + systemLen: tls.getCACertificates('system').length, + defaultLen: tls.getCACertificates('default').length, +}); diff --git a/test/js/node/test/parallel/test-http-server-reject-chunked-with-content-length.js b/test/js/node/test/parallel/test-http-server-reject-chunked-with-content-length.js deleted file mode 100644 index d7e2e7df88ee..000000000000 --- a/test/js/node/test/parallel/test-http-server-reject-chunked-with-content-length.js +++ /dev/null @@ -1,30 +0,0 @@ -'use strict'; - -const common = require('../common'); -const http = require('http'); -const net = require('net'); -const assert = require('assert'); - -const reqstr = 'POST / HTTP/1.1\r\n' + - 'Host: localhost\r\n' + - 'Content-Length: 1\r\n' + - 'Transfer-Encoding: chunked\r\n\r\n'; - -const server = http.createServer(common.mustNotCall()); -server.on('clientError', common.mustCall((err) => { - assert.match(err.message, /^Parse Error/); - assert.strictEqual(err.code, 'HPE_INVALID_TRANSFER_ENCODING'); - server.close(); -})); -server.listen(0, () => { - const client = net.connect({ port: server.address().port }, () => { - client.write(reqstr); - client.end(); - }); - client.on('data', (data) => { - // Should not get to this point because the server should simply - // close the connection without returning any data. - assert.fail('no data should be returned by the server'); - }); - client.on('end', common.mustCall()); -}); diff --git a/test/js/node/test/parallel/test-http2-server-shutdown-options-errors.js b/test/js/node/test/parallel/test-http2-server-shutdown-options-errors.js index 364b43b36b1e..5a2ca62a6c8e 100644 --- a/test/js/node/test/parallel/test-http2-server-shutdown-options-errors.js +++ b/test/js/node/test/parallel/test-http2-server-shutdown-options-errors.js @@ -44,11 +44,12 @@ server.on('stream', common.mustCall((stream) => { { code: 'ERR_INVALID_ARG_TYPE', name: 'TypeError', - message: 'The "opaqueData" argument must be of type Buffer, ' + + message: 'The "opaqueData" argument must be an instance of Buffer, ' + `TypedArray, or DataView.${received}` } ); } + stream.session.destroy(); })); diff --git a/test/js/node/test/parallel/test-net-allow-half-open.js b/test/js/node/test/parallel/test-net-allow-half-open.js new file mode 100644 index 000000000000..c7f829a986e4 --- /dev/null +++ b/test/js/node/test/parallel/test-net-allow-half-open.js @@ -0,0 +1,47 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +{ + const server = net.createServer(common.mustCall((socket) => { + socket.end(Buffer.alloc(1024)); + })).listen(0, common.mustCall(() => { + const socket = net.connect(server.address().port); + assert.strictEqual(socket.allowHalfOpen, false); + socket.resume(); + socket.on('end', common.mustCall(() => { + process.nextTick(() => { + // Ensure socket is not destroyed straight away + // without proper shutdown. + assert(!socket.destroyed); + server.close(); + }); + })); + socket.on('finish', common.mustCall(() => { + assert(!socket.destroyed); + })); + socket.on('close', common.mustCall()); + })); +} + +{ + const server = net.createServer(common.mustCall((socket) => { + socket.end(Buffer.alloc(1024)); + })).listen(0, common.mustCall(() => { + const socket = net.connect(server.address().port); + assert.strictEqual(socket.allowHalfOpen, false); + socket.resume(); + socket.on('end', common.mustCall(() => { + assert(!socket.destroyed); + })); + socket.end('asd'); + socket.on('finish', common.mustCall(() => { + assert(!socket.destroyed); + })); + socket.on('close', common.mustCall(() => { + server.close(); + })); + })); +} diff --git a/test/js/node/test/parallel/test-net-autoselectfamily-attempt-timeout-cli-option.js b/test/js/node/test/parallel/test-net-autoselectfamily-attempt-timeout-cli-option.js new file mode 100644 index 000000000000..cf177e968ce7 --- /dev/null +++ b/test/js/node/test/parallel/test-net-autoselectfamily-attempt-timeout-cli-option.js @@ -0,0 +1,10 @@ +'use strict'; + +// Flags: --network-family-autoselection-attempt-timeout=123 + +const { platformTimeout } = require('../common'); + +const assert = require('assert'); +const { getDefaultAutoSelectFamilyAttemptTimeout } = require('net'); + +assert.strictEqual(getDefaultAutoSelectFamilyAttemptTimeout(), platformTimeout(123 * 5)); diff --git a/test/js/node/test/parallel/test-net-autoselectfamily-commandline-option.js b/test/js/node/test/parallel/test-net-autoselectfamily-commandline-option.js new file mode 100644 index 000000000000..54627a1266ed --- /dev/null +++ b/test/js/node/test/parallel/test-net-autoselectfamily-commandline-option.js @@ -0,0 +1,48 @@ +'use strict'; + +// Flags: --no-network-family-autoselection + +const common = require('../common'); +const { createMockedLookup } = require('../common/dns'); + +const assert = require('assert'); +const { createConnection, createServer } = require('net'); + +// Test that IPV4 is NOT reached if IPV6 is not reachable and the option has been disabled via command line +{ + const ipv4Server = createServer(common.mustCallAtLeast((socket) => { + socket.on('data', common.mustCall(() => { + socket.write('response-ipv4'); + socket.end(); + })); + }, 0)); + + ipv4Server.listen(0, '127.0.0.1', common.mustCall(() => { + const port = ipv4Server.address().port; + + const connection = createConnection({ + host: 'example.org', + port, + lookup: createMockedLookup('::1', '127.0.0.1'), + }); + + connection.on('ready', common.mustNotCall()); + connection.on('error', common.mustCall((error) => { + assert.strictEqual(connection.autoSelectFamilyAttemptedAddresses, undefined); + + if (common.hasIPv6) { + assert.strictEqual(error.code, 'ECONNREFUSED'); + assert.strictEqual(error.message, `connect ECONNREFUSED ::1:${port}`); + } else if (error.code === 'EAFNOSUPPORT') { + assert.strictEqual(error.message, `connect EAFNOSUPPORT ::1:${port} - Local (undefined:undefined)`); + } else if (error.code === 'EUNATCH') { + assert.strictEqual(error.message, `connect EUNATCH ::1:${port} - Local (:::0)`); + } else { + assert.strictEqual(error.code, 'EADDRNOTAVAIL'); + assert.strictEqual(error.message, `connect EADDRNOTAVAIL ::1:${port} - Local (:::0)`); + } + + ipv4Server.close(); + })); + })); +} diff --git a/test/js/node/test/parallel/test-net-autoselectfamily.js b/test/js/node/test/parallel/test-net-autoselectfamily.js new file mode 100644 index 000000000000..0fdac23a6f6e --- /dev/null +++ b/test/js/node/test/parallel/test-net-autoselectfamily.js @@ -0,0 +1,223 @@ +'use strict'; + +const common = require('../common'); +const { createMockedLookup } = require('../common/dns'); + +const assert = require('assert'); +const { createConnection, createServer } = require('net'); + +// Test that happy eyeballs algorithm is properly implemented. + +// Purposely not using setDefaultAutoSelectFamilyAttemptTimeout here to test the +// parameter is correctly used in options. + +// Some of the machines in the CI need more time to establish connection +const autoSelectFamilyAttemptTimeout = common.defaultAutoSelectFamilyAttemptTimeout; + +// Test that IPV4 is reached if IPV6 is not reachable +{ + const ipv4Server = createServer(common.mustCall((socket) => { + socket.on('data', common.mustCall(() => { + socket.write('response-ipv4'); + socket.end(); + })); + })); + + ipv4Server.listen(0, '127.0.0.1', common.mustCall(() => { + const port = ipv4Server.address().port; + + const connection = createConnection({ + host: 'example.org', + port: port, + lookup: createMockedLookup('::1', '127.0.0.1'), + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout, + }); + + let response = ''; + connection.setEncoding('utf-8'); + + connection.on('ready', common.mustCall(() => { + assert.deepStrictEqual(connection.autoSelectFamilyAttemptedAddresses, [`::1:${port}`, `127.0.0.1:${port}`]); + })); + + connection.on('data', (chunk) => { + response += chunk; + }); + + connection.on('end', common.mustCall(() => { + assert.strictEqual(response, 'response-ipv4'); + ipv4Server.close(); + })); + + connection.write('request'); + })); +} + +// Test that only the last successful connection is established. +{ + const ipv4Server = createServer(common.mustCall((socket) => { + socket.on('data', common.mustCall(() => { + socket.write('response-ipv4'); + socket.end(); + })); + })); + + ipv4Server.listen(0, '127.0.0.1', common.mustCall(() => { + const port = ipv4Server.address().port; + + const connection = createConnection({ + host: 'example.org', + port: port, + lookup: createMockedLookup( + '2606:4700::6810:85e5', '2606:4700::6810:84e5', '::1', + '104.20.22.46', '104.20.23.46', '127.0.0.1', + ), + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout, + }); + + let response = ''; + connection.setEncoding('utf-8'); + + connection.on('ready', common.mustCall(() => { + assert.deepStrictEqual( + connection.autoSelectFamilyAttemptedAddresses, + [ + `2606:4700::6810:85e5:${port}`, + `104.20.22.46:${port}`, + `2606:4700::6810:84e5:${port}`, + `104.20.23.46:${port}`, + `::1:${port}`, + `127.0.0.1:${port}`, + ] + ); + })); + + connection.on('data', (chunk) => { + response += chunk; + }); + + connection.on('end', common.mustCall(() => { + assert.strictEqual(response, 'response-ipv4'); + ipv4Server.close(); + })); + + connection.write('request'); + })); +} + +// Test that IPV4 is NOT reached if IPV6 is reachable +if (common.hasIPv6) { + const ipv4Server = createServer((socket) => { + socket.on('data', common.mustNotCall(() => { + socket.write('response-ipv4'); + socket.end(); + })); + }); + + const ipv6Server = createServer(common.mustCall((socket) => { + socket.on('data', common.mustCall(() => { + socket.write('response-ipv6'); + socket.end(); + })); + })); + + ipv4Server.listen(0, '127.0.0.1', common.mustCall(() => { + const port = ipv4Server.address().port; + + ipv6Server.listen(port, '::1', common.mustCall(() => { + const connection = createConnection({ + host: 'example.org', + port, + lookup: createMockedLookup('::1', '127.0.0.1'), + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout, + }); + + let response = ''; + connection.setEncoding('utf-8'); + + connection.on('ready', common.mustCall(() => { + assert.deepStrictEqual(connection.autoSelectFamilyAttemptedAddresses, [`::1:${port}`]); + })); + + connection.on('data', (chunk) => { + response += chunk; + }); + + connection.on('end', common.mustCall(() => { + assert.strictEqual(response, 'response-ipv6'); + ipv4Server.close(); + ipv6Server.close(); + })); + + connection.write('request'); + })); + })); +} + +// Test that when all errors are returned when no connections succeeded +{ + const connection = createConnection({ + host: 'example.org', + port: 10, + lookup: createMockedLookup('::1', '127.0.0.1'), + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout, + }); + + connection.on('ready', common.mustNotCall()); + connection.on('error', common.mustCall((error) => { + assert.deepStrictEqual(connection.autoSelectFamilyAttemptedAddresses, ['::1:10', '127.0.0.1:10']); + assert.strictEqual(error.constructor.name, 'AggregateError'); + assert.strictEqual(error.errors.length, 2); + + const errors = error.errors.map((e) => e.message); + assert.ok(errors.includes('connect ECONNREFUSED 127.0.0.1:10')); + + if (common.hasIPv6) { + assert.ok(errors.includes('connect ECONNREFUSED ::1:10')); + } + })); +} + +// Test that the option can be disabled +{ + const ipv4Server = createServer(common.mustCallAtLeast((socket) => { + socket.on('data', common.mustCall(() => { + socket.write('response-ipv4'); + socket.end(); + })); + }, 0)); + + ipv4Server.listen(0, '127.0.0.1', common.mustCall(() => { + const port = ipv4Server.address().port; + + const connection = createConnection({ + host: 'example.org', + port, + lookup: createMockedLookup('::1', '127.0.0.1'), + autoSelectFamily: false, + }); + + connection.on('ready', common.mustNotCall()); + connection.on('error', common.mustCall((error) => { + assert.strictEqual(connection.autoSelectFamilyAttemptedAddresses, undefined); + + if (common.hasIPv6) { + assert.strictEqual(error.code, 'ECONNREFUSED'); + assert.strictEqual(error.message, `connect ECONNREFUSED ::1:${port}`); + } else if (error.code === 'EAFNOSUPPORT') { + assert.strictEqual(error.message, `connect EAFNOSUPPORT ::1:${port} - Local (undefined:undefined)`); + } else if (error.code === 'EUNATCH') { + assert.strictEqual(error.message, `connect EUNATCH ::1:${port} - Local (:::0)`); + } else { + assert.strictEqual(error.code, 'EADDRNOTAVAIL'); + assert.strictEqual(error.message, `connect EADDRNOTAVAIL ::1:${port} - Local (:::0)`); + } + + ipv4Server.close(); + })); + })); +} diff --git a/test/js/node/test/parallel/test-net-binary.js b/test/js/node/test/parallel/test-net-binary.js new file mode 100644 index 000000000000..cf8715411d0f --- /dev/null +++ b/test/js/node/test/parallel/test-net-binary.js @@ -0,0 +1,88 @@ +// 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. + +/* eslint-disable strict */ +require('../common'); +const assert = require('assert'); +const net = require('net'); + +let binaryString = ''; +for (let i = 255; i >= 0; i--) { + const s = `'\\${i.toString(8)}'`; + const S = eval(s); + assert.strictEqual(S.charCodeAt(0), i); + assert.strictEqual(S, String.fromCharCode(i)); + binaryString += S; +} + +// safe constructor +const echoServer = net.Server(function(connection) { + connection.setEncoding('latin1'); + connection.on('data', function(chunk) { + connection.write(chunk, 'latin1'); + }); + connection.on('end', function() { + connection.end(); + }); +}); +echoServer.listen(0); + +let recv = ''; + +echoServer.on('listening', function() { + let j = 0; + const c = net.createConnection({ + port: this.address().port + }); + + c.setEncoding('latin1'); + c.on('data', function(chunk) { + const n = j + chunk.length; + while (j < n && j < 256) { + c.write(String.fromCharCode(j), 'latin1'); + j++; + } + if (j === 256) { + c.end(); + } + recv += chunk; + }); + + c.on('connect', function() { + c.write(binaryString, 'binary'); + }); + + c.on('close', function() { + echoServer.close(); + }); +}); + +process.on('exit', function() { + assert.strictEqual(recv.length, 2 * 256); + + const a = recv.split(''); + + const first = a.slice(0, 256).reverse().join(''); + + const second = a.slice(256, 2 * 256).join(''); + + assert.strictEqual(first, second); +}); diff --git a/test/js/node/test/parallel/test-net-bytes-read.js b/test/js/node/test/parallel/test-net-bytes-read.js new file mode 100644 index 000000000000..d569d78403e1 --- /dev/null +++ b/test/js/node/test/parallel/test-net-bytes-read.js @@ -0,0 +1,47 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +const big = Buffer.alloc(1024 * 1024); + +const handler = common.mustCall((socket) => { + socket.end(big); + server.close(); +}); + +const onListen = common.mustCall(() => { + let prev = 0; + + function checkRaise(value) { + assert(value > prev); + prev = value; + } + + const onData = common.mustCallAtLeast((chunk) => { + checkRaise(socket.bytesRead); + }); + + const onEnd = common.mustCall(() => { + assert.strictEqual(socket.bytesRead, prev); + assert.strictEqual(big.length, prev); + }); + + const onClose = common.mustCall(() => { + assert(!socket._handle); + assert.strictEqual(socket.bytesRead, prev); + assert.strictEqual(big.length, prev); + }); + + const onConnect = common.mustCall(() => { + socket.on('data', onData); + socket.on('end', onEnd); + socket.on('close', onClose); + socket.end(); + }); + + const socket = net.connect(server.address().port, onConnect); +}); + +const server = net.createServer(handler).listen(0, onListen); diff --git a/test/js/node/test/parallel/test-net-bytes-stats.js b/test/js/node/test/parallel/test-net-bytes-stats.js new file mode 100644 index 000000000000..40fa13d415fe --- /dev/null +++ b/test/js/node/test/parallel/test-net-bytes-stats.js @@ -0,0 +1,78 @@ +// 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. + +'use strict'; +require('../common'); +const assert = require('assert'); +const net = require('net'); + +let bytesRead = 0; +let bytesWritten = 0; +let count = 0; + +const tcp = net.Server(function(s) { + console.log('tcp server connection'); + + // trigger old mode. + s.resume(); + + s.on('end', function() { + bytesRead += s.bytesRead; + console.log(`tcp socket disconnect #${count}`); + }); +}); + +tcp.listen(0, function doTest() { + console.error('listening'); + const socket = net.createConnection(this.address().port); + + socket.on('connect', function() { + count++; + console.error('CLIENT connect #%d', count); + + socket.write('foo', function() { + console.error('CLIENT: write cb'); + socket.end('bar'); + }); + }); + + socket.on('finish', function() { + bytesWritten += socket.bytesWritten; + console.error('CLIENT end event #%d', count); + }); + + socket.on('close', function() { + console.error('CLIENT close event #%d', count); + console.log(`Bytes read: ${bytesRead}`); + console.log(`Bytes written: ${bytesWritten}`); + if (count < 2) { + console.error('RECONNECTING'); + socket.connect(tcp.address().port); + } else { + tcp.close(); + } + }); +}); + +process.on('exit', function() { + assert.strictEqual(bytesRead, 12); + assert.strictEqual(bytesWritten, 12); +}); diff --git a/test/js/node/test/parallel/test-net-client-bind-twice.js b/test/js/node/test/parallel/test-net-client-bind-twice.js new file mode 100644 index 000000000000..ca7eb502d85b --- /dev/null +++ b/test/js/node/test/parallel/test-net-client-bind-twice.js @@ -0,0 +1,26 @@ +'use strict'; + +// This tests that net.connect() from a used local port throws EADDRINUSE. + +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +const server1 = net.createServer(common.mustNotCall()); +server1.listen(0, common.localhostIPv4, common.mustCall(() => { + const server2 = net.createServer(common.mustNotCall()); + server2.listen(0, common.localhostIPv4, common.mustCall(() => { + const client = net.connect({ + host: common.localhostIPv4, + port: server1.address().port, + localAddress: common.localhostIPv4, + localPort: server2.address().port + }, common.mustNotCall()); + + client.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'EADDRINUSE'); + server1.close(); + server2.close(); + })); + })); +})); diff --git a/test/js/node/test/parallel/test-net-connect-memleak.js b/test/js/node/test/parallel/test-net-connect-memleak.js new file mode 100644 index 000000000000..de925f5d08c4 --- /dev/null +++ b/test/js/node/test/parallel/test-net-connect-memleak.js @@ -0,0 +1,58 @@ +// 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. + +'use strict'; +// Flags: --expose-gc + +const common = require('../common'); +const { onGC } = require('../common/gc'); +const assert = require('assert'); +const net = require('net'); + +// Test that the implicit listener for an 'connect' event on net.Sockets is +// added using `once()`, i.e. can be gc'ed once that event has occurred. + +const server = net.createServer(common.mustCall()).listen(0); + +let collected = false; +const gcListener = { ongc() { collected = true; } }; + +{ + const gcObject = {}; + onGC(gcObject, gcListener); + + const sock = net.createConnection( + server.address().port, + common.mustCall(() => { + assert.strictEqual(gcObject, gcObject); // Keep reference alive + assert.strictEqual(collected, false); + setImmediate(done, sock); + })); +} + +function done(sock) { + globalThis.gc(); + setImmediate(common.mustCall(() => { + assert.strictEqual(collected, true); + sock.end(); + server.close(); + })); +} diff --git a/test/js/node/test/parallel/test-net-connect-options-allowhalfopen.js b/test/js/node/test/parallel/test-net-connect-options-allowhalfopen.js new file mode 100644 index 000000000000..26f1ace52e09 --- /dev/null +++ b/test/js/node/test/parallel/test-net-connect-options-allowhalfopen.js @@ -0,0 +1,118 @@ +// 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. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +// Test allowHalfOpen +{ + let clientReceivedFIN = 0; + let serverConnections = 0; + let clientSentFIN = 0; + let serverReceivedFIN = 0; + const host = common.localhostIPv4; + + function serverOnConnection(socket) { + console.log(`'connection' ${++serverConnections} emitted on server`); + const srvConn = serverConnections; + socket.resume(); + socket.on('data', common.mustCall(function socketOnData(data) { + this.clientId = data.toString(); + console.log( + `server connection ${srvConn} is started by client ${this.clientId}`); + })); + // 'end' on each socket must not be emitted twice + socket.on('end', common.mustCall(function socketOnEnd() { + console.log(`Server received FIN sent by client ${this.clientId}`); + if (++serverReceivedFIN < CLIENT_VARIANTS) return; + setTimeout(() => { + server.close(); + console.log(`connection ${this.clientId} is closing the server: + FIN ${serverReceivedFIN} received by server, + FIN ${clientReceivedFIN} received by client + FIN ${clientSentFIN} sent by client, + FIN ${serverConnections} sent by server`.replace(/ {3,}/g, '')); + }, 50); + }, 1)); + socket.end(); + console.log(`Server has sent ${serverConnections} FIN`); + } + + // These two levels of functions (and not arrows) are necessary in order to + // bind the `index`, and the calling socket (`this`) + function clientOnConnect(index) { + return common.mustCall(function clientOnConnectInner() { + const client = this; + console.log(`'connect' emitted on Client ${index}`); + client.resume(); + client.on('end', common.mustCall(function clientOnEnd() { + setTimeout(common.mustCall(() => { + // When allowHalfOpen is true, client must still be writable + // after the server closes the connections, but not readable + console.log(`client ${index} received FIN`); + assert(!client.readable); + assert(client.writable); + assert(client.write(String(index))); + client.end(); + clientSentFIN++; + console.log( + `client ${index} sent FIN, ${clientSentFIN} have been sent`); + }), 50); + })); + client.on('close', common.mustCall(function clientOnClose() { + clientReceivedFIN++; + console.log(`connection ${index} has been closed by both sides,` + + ` ${clientReceivedFIN} clients have closed`); + })); + }); + } + + function serverOnClose() { + console.log(`Server has been closed: + FIN ${serverReceivedFIN} received by server + FIN ${clientReceivedFIN} received by client + FIN ${clientSentFIN} sent by client + FIN ${serverConnections} sent by server`.replace(/ {3,}/g, '')); + } + + function serverOnListen() { + const port = server.address().port; + console.log(`Server started listening at ${host}:${port}`); + const opts = { allowHalfOpen: true, host, port }; + // 6 variations === CLIENT_VARIANTS + net.connect(opts, clientOnConnect(1)); + net.connect(opts).on('connect', clientOnConnect(2)); + net.createConnection(opts, clientOnConnect(3)); + net.createConnection(opts).on('connect', clientOnConnect(4)); + new net.Socket(opts).connect(opts, clientOnConnect(5)); + new net.Socket(opts).connect(opts).on('connect', clientOnConnect(6)); + } + + const CLIENT_VARIANTS = 6; + + // The trigger + const server = net.createServer({ allowHalfOpen: true }) + .on('connection', common.mustCall(serverOnConnection, CLIENT_VARIANTS)) + .on('close', common.mustCall(serverOnClose)) + .listen(0, host, common.mustCall(serverOnListen)); +} diff --git a/test/js/node/test/parallel/test-net-connect-paused-connection.js b/test/js/node/test/parallel/test-net-connect-paused-connection.js new file mode 100644 index 000000000000..801bba1cf5b2 --- /dev/null +++ b/test/js/node/test/parallel/test-net-connect-paused-connection.js @@ -0,0 +1,33 @@ +// 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. + +'use strict'; +const common = require('../common'); + +const net = require('net'); + +net.createServer(function(conn) { + conn.unref(); +}).listen(0, common.mustCall(function() { + net.connect(this.address().port, 'localhost').pause(); + + setTimeout(common.mustNotCall('expected to exit'), 1000).unref(); +})).unref(); diff --git a/test/js/node/test/parallel/test-net-connect-reset-after-destroy.js b/test/js/node/test/parallel/test-net-connect-reset-after-destroy.js new file mode 100644 index 000000000000..89e459229ab1 --- /dev/null +++ b/test/js/node/test/parallel/test-net-connect-reset-after-destroy.js @@ -0,0 +1,29 @@ +'use strict'; +const common = require('../common'); +const net = require('net'); +const assert = require('assert'); + +const server = net.createServer(); +server.listen(0, common.mustCall(function() { + const port = server.address().port; + const conn = net.createConnection(port); + server.on('connection', (socket) => { + socket.on('error', common.expectsError({ + code: 'ECONNRESET', + message: 'read ECONNRESET', + name: 'Error' + })); + }); + + conn.on('connect', common.mustCall(function() { + assert.strictEqual(conn, conn.resetAndDestroy().destroy()); + conn.on('error', common.mustNotCall()); + + conn.write(Buffer.from('fzfzfzfzfz'), common.expectsError({ + code: 'ERR_STREAM_DESTROYED', + message: 'Cannot call write after a stream was destroyed', + name: 'Error' + })); + server.close(); + })); +})); diff --git a/test/js/node/test/parallel/test-net-connect-reset-until-connected.js b/test/js/node/test/parallel/test-net-connect-reset-until-connected.js new file mode 100644 index 000000000000..9c2493eaaf05 --- /dev/null +++ b/test/js/node/test/parallel/test-net-connect-reset-until-connected.js @@ -0,0 +1,29 @@ +'use strict'; + +const common = require('../common'); +const net = require('net'); + +function barrier(count, cb) { + return function() { + if (--count === 0) + cb(); + }; +} + +const server = net.createServer(); +server.listen(0, common.mustCall(function() { + const port = server.address().port; + const conn = net.createConnection(port); + const connok = barrier(2, () => conn.resetAndDestroy()); + conn.on('close', common.mustCall()); + server.on('connection', (socket) => { + connok(); + socket.on('error', common.expectsError({ + code: 'ECONNRESET', + message: 'read ECONNRESET', + name: 'Error' + })); + server.close(); + }); + conn.on('connect', connok); +})); diff --git a/test/js/node/test/parallel/test-net-end-destroyed.js b/test/js/node/test/parallel/test-net-end-destroyed.js new file mode 100644 index 000000000000..1670c1b92b21 --- /dev/null +++ b/test/js/node/test/parallel/test-net-end-destroyed.js @@ -0,0 +1,26 @@ +'use strict'; + +const common = require('../common'); +const net = require('net'); +const assert = require('assert'); + +const server = net.createServer(); + +server.on('connection', common.mustCall()); + +// Ensure that the socket is not destroyed when the 'end' event is emitted. + +server.listen(common.mustCall(function() { + const socket = net.createConnection({ + port: server.address().port + }); + + socket.on('connect', common.mustCall(function() { + socket.on('end', common.mustCall(function() { + assert.strictEqual(socket.destroyed, false); + server.close(); + })); + + socket.end(); + })); +})); diff --git a/test/js/node/test/parallel/test-net-error-twice.js b/test/js/node/test/parallel/test-net-error-twice.js new file mode 100644 index 000000000000..b26b825d16c1 --- /dev/null +++ b/test/js/node/test/parallel/test-net-error-twice.js @@ -0,0 +1,63 @@ +// 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. + +'use strict'; +require('../common'); +const assert = require('assert'); +const net = require('net'); + +const buf = Buffer.alloc(10 * 1024 * 1024, 0x62); + +const errs = []; +let clientSocket; +let serverSocket; + +function ready() { + if (clientSocket && serverSocket) { + clientSocket.destroy(); + serverSocket.write(buf); + } +} + +const server = net.createServer(function onConnection(conn) { + conn.on('error', function(err) { + errs.push(err); + if (errs.length > 1 && errs[0] === errs[1]) + assert.fail('Should not emit the same error twice'); + }); + conn.on('close', function() { + server.unref(); + }); + serverSocket = conn; + ready(); +}).listen(0, function() { + const client = net.connect({ port: this.address().port }); + + client.on('connect', function() { + clientSocket = client; + ready(); + }); +}); + +process.on('exit', function() { + console.log(errs); + assert.strictEqual(errs.length, 1); +}); diff --git a/test/js/node/test/parallel/test-net-large-string.js b/test/js/node/test/parallel/test-net-large-string.js new file mode 100644 index 000000000000..93c0d41612a1 --- /dev/null +++ b/test/js/node/test/parallel/test-net-large-string.js @@ -0,0 +1,51 @@ +// 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. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +const kPoolSize = 40 * 1024; +const data = 'あ'.repeat(kPoolSize); +const encoding = 'UTF-8'; + +const server = net.createServer(common.mustCall(function(socket) { + let receivedSize = 0; + + socket.setEncoding(encoding); + socket.on('data', function(data) { + receivedSize += data.length; + }); + socket.on('end', common.mustCall(function() { + assert.strictEqual(receivedSize, kPoolSize); + socket.end(); + })); +})); + +server.listen(0, function() { + const client = net.createConnection(this.address().port); + client.on('end', function() { + server.close(); + }); + client.write(data, encoding); + client.end(); +}); diff --git a/test/js/node/test/parallel/test-net-pause-resume-connecting.js b/test/js/node/test/parallel/test-net-pause-resume-connecting.js new file mode 100644 index 000000000000..920522b76045 --- /dev/null +++ b/test/js/node/test/parallel/test-net-pause-resume-connecting.js @@ -0,0 +1,95 @@ +// 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. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +let connections = 0; +let dataEvents = 0; +let conn; + + +// Server +const server = net.createServer(function(conn) { + connections++; + conn.end('This was the year he fell to pieces.'); + + if (connections === 5) + server.close(); +}); + +server.listen(0, function() { + // Client 1 + conn = net.createConnection(this.address().port, 'localhost'); + conn.resume(); + conn.on('data', onDataOk); + + + // Client 2 + conn = net.createConnection(this.address().port, 'localhost'); + conn.pause(); + conn.resume(); + conn.on('data', onDataOk); + + + // Client 3 + conn = net.createConnection(this.address().port, 'localhost'); + conn.pause(); + conn.on('data', common.mustNotCall()); + scheduleTearDown(conn); + + + // Client 4 + conn = net.createConnection(this.address().port, 'localhost'); + conn.resume(); + conn.pause(); + conn.resume(); + conn.on('data', onDataOk); + + + // Client 5 + conn = net.createConnection(this.address().port, 'localhost'); + conn.resume(); + conn.resume(); + conn.pause(); + conn.on('data', common.mustNotCall()); + scheduleTearDown(conn); + + function onDataOk() { + dataEvents++; + } + + function scheduleTearDown(conn) { + setTimeout(function() { + conn.removeAllListeners('data'); + conn.resume(); + }, 100); + } +}); + + +// Exit sanity checks +process.on('exit', function() { + assert.strictEqual(connections, 5); + assert.strictEqual(dataEvents, 3); +}); diff --git a/test/js/node/test/parallel/test-net-perf_hooks.js b/test/js/node/test/parallel/test-net-perf_hooks.js new file mode 100644 index 000000000000..06b88ed7e7e7 --- /dev/null +++ b/test/js/node/test/parallel/test-net-perf_hooks.js @@ -0,0 +1,60 @@ +'use strict'; + +const common = require('../common'); +const tmpdir = require('../common/tmpdir'); +const assert = require('assert'); +const net = require('net'); + +tmpdir.refresh(); + +const { PerformanceObserver } = require('perf_hooks'); + +const entries = []; + +const obs = new PerformanceObserver(common.mustCallAtLeast((items) => { + entries.push(...items.getEntries()); +})); + +obs.observe({ type: 'net' }); + +{ + const server = net.createServer(common.mustCall((socket) => { + socket.destroy(); + })); + + server.listen(0, common.mustCall(async () => { + await new Promise((resolve, reject) => { + const socket = net.connect(server.address().port); + socket.on('end', resolve); + socket.on('error', reject); + }); + server.close(); + })); +} + +{ + const server = net.createServer(common.mustCall((socket) => { + socket.destroy(); + })); + + server.listen(common.PIPE, common.mustCall(async () => { + await new Promise((resolve, reject) => { + const socket = net.connect(common.PIPE); + socket.on('end', resolve); + socket.on('error', reject); + }); + server.close(); + })); +} + +process.on('exit', () => { + assert.strictEqual(entries.length, 1); + for (const entry of entries) { + assert.strictEqual(entry.name, 'connect'); + assert.strictEqual(entry.entryType, 'net'); + assert.strictEqual(typeof entry.startTime, 'number'); + assert.strictEqual(typeof entry.duration, 'number'); + assert.strictEqual(!!entry.detail.host, true); + assert.strictEqual(!!entry.detail.port, true); + } +}); diff --git a/test/js/node/test/parallel/test-net-pingpong.js b/test/js/node/test/parallel/test-net-pingpong.js new file mode 100644 index 000000000000..3bbe076b4b16 --- /dev/null +++ b/test/js/node/test/parallel/test-net-pingpong.js @@ -0,0 +1,133 @@ +// 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. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +function pingPongTest(port, host) { + const N = 1000; + let count = 0; + let sentPongs = 0; + let sent_final_ping = false; + + const server = net.createServer( + { allowHalfOpen: true }, + common.mustCall(onSocket) + ); + + function onSocket(socket) { + assert.strictEqual(socket.server, server); + assert.strictEqual( + server, + server.getConnections(common.mustSucceed((connections) => { + assert.strictEqual(connections, 1); + })) + ); + + socket.setNoDelay(); + socket.timeout = 0; + + socket.setEncoding('utf8'); + socket.on('data', common.mustCall(function(data) { + // Since we never queue data (we're always waiting for the PING + // before sending a pong) the writeQueueSize should always be less + // than one message. + assert.ok(socket.bufferSize >= 0 && socket.bufferSize <= 4); + + assert.strictEqual(socket.writable, true); + assert.strictEqual(socket.readable, true); + assert.ok(count <= N); + assert.strictEqual(data, 'PING'); + + socket.write('PONG', common.mustCall(function() { + sentPongs++; + })); + }, N + 1)); + + socket.on('end', common.mustCall(function() { + assert.strictEqual(socket.allowHalfOpen, true); + assert.strictEqual(socket.writable, true); // Because allowHalfOpen + assert.strictEqual(socket.readable, false); + socket.end(); + })); + + socket.on('error', common.mustNotCall()); + + socket.on('close', common.mustCall(function() { + assert.strictEqual(socket.writable, false); + assert.strictEqual(socket.readable, false); + socket.server.close(); + })); + } + + + server.listen(port, host, common.mustCall(function() { + if (this.address().port) + port = this.address().port; + + const client = net.createConnection(port, host); + + client.setEncoding('ascii'); + client.on('connect', common.mustCall(function() { + assert.strictEqual(client.readable, true); + assert.strictEqual(client.writable, true); + client.write('PING'); + })); + + client.on('data', common.mustCall(function(data) { + assert.strictEqual(data, 'PONG'); + count += 1; + + if (sent_final_ping) { + assert.strictEqual(client.writable, false); + assert.strictEqual(client.readable, true); + return; + } + assert.strictEqual(client.writable, true); + assert.strictEqual(client.readable, true); + + if (count < N) { + client.write('PING'); + } else { + sent_final_ping = true; + client.write('PING'); + client.end(); + } + }, N + 1)); + + client.on('close', common.mustCall(function() { + assert.strictEqual(count, N + 1); + assert.strictEqual(sentPongs, N + 1); + assert.strictEqual(sent_final_ping, true); + })); + + client.on('error', common.mustNotCall()); + })); +} + +/* All are run at once, so run on different ports */ +const tmpdir = require('../common/tmpdir'); +tmpdir.refresh(); +pingPongTest(common.PIPE); +pingPongTest(0); +if (common.hasIPv6) pingPongTest(0, '::1'); else pingPongTest(0, '127.0.0.1'); diff --git a/test/js/node/test/parallel/test-net-pipe-connect-errors.js b/test/js/node/test/parallel/test-net-pipe-connect-errors.js new file mode 100644 index 000000000000..fec4259b348d --- /dev/null +++ b/test/js/node/test/parallel/test-net-pipe-connect-errors.js @@ -0,0 +1,97 @@ +// 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. + +'use strict'; +const common = require('../common'); +const fixtures = require('../common/fixtures'); +const fs = require('fs'); +const net = require('net'); +const assert = require('assert'); + +// Test if ENOTSOCK is fired when trying to connect to a file which is not +// a socket. + +let emptyTxt; + +if (common.isWindows) { + // On Win, common.PIPE will be a named pipe, so we use an existing empty + // file instead + emptyTxt = fixtures.path('empty.txt'); +} else { + const tmpdir = require('../common/tmpdir'); + tmpdir.refresh(); + // Keep the file name very short so that we don't exceed the 108 char limit + // on CI for a POSIX socket. Even though this isn't actually a socket file, + // the error will be different from the one we are expecting if we exceed the + // limit. + emptyTxt = `${tmpdir.path}0.txt`; + + function cleanup() { + try { + fs.unlinkSync(emptyTxt); + } catch (e) { + assert.strictEqual(e.code, 'ENOENT'); + } + } + process.on('exit', cleanup); + cleanup(); + fs.writeFileSync(emptyTxt, ''); +} + +const notSocketClient = net.createConnection(emptyTxt, function() { + assert.fail('connection callback should not run'); +}); + +notSocketClient.on('error', common.mustCall(function(err) { + assert(err.code === 'ENOTSOCK' || err.code === 'ECONNREFUSED', + `received ${err.code} instead of ENOTSOCK or ECONNREFUSED`); +})); + + +// Trying to connect to not-existing socket should result in ENOENT error +const noEntSocketClient = net.createConnection('no-ent-file', function() { + assert.fail('connection to non-existent socket, callback should not run'); +}); + +noEntSocketClient.on('error', common.mustCall(function(err) { + assert.strictEqual(err.code, 'ENOENT'); +})); + + +// On Windows or IBMi or when running as root, +// a chmod has no effect on named pipes +if (!common.isWindows && !common.isIBMi && process.getuid() !== 0) { + // Trying to connect to a socket one has no access to should result in EACCES + const accessServer = net.createServer( + common.mustNotCall('server callback should not run')); + accessServer.listen(common.PIPE, common.mustCall(function() { + fs.chmodSync(common.PIPE, 0); + + const accessClient = net.createConnection(common.PIPE, function() { + assert.fail('connection should get EACCES, callback should not run'); + }); + + accessClient.on('error', common.mustCall(function(err) { + assert.strictEqual(err.code, 'EACCES'); + accessServer.close(); + })); + })); +} diff --git a/test/js/node/test/parallel/test-net-pipe-with-long-path.js b/test/js/node/test/parallel/test-net-pipe-with-long-path.js new file mode 100644 index 000000000000..e35fd4a1e0ca --- /dev/null +++ b/test/js/node/test/parallel/test-net-pipe-with-long-path.js @@ -0,0 +1,36 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); +const fs = require('fs'); +const tmpdir = require('../common/tmpdir'); +tmpdir.refresh(); + +// Test UV_PIPE_NO_TRUNCATE + +// See pipe_overlong_path in https://github.com/libuv/libuv/blob/master/test/test-pipe-bind-error.c +if (common.isWindows) { + common.skip('UV_PIPE_NO_TRUNCATE is not supported on window'); +} + +// See https://github.com/libuv/libuv/issues/4231 +const pipePath = `${tmpdir.path}/${'x'.repeat(10000)}.sock`; + +const server = net.createServer() + .listen(pipePath) + // It may work on some operating systems + .on('listening', common.mustCallAtLeast(() => { + // The socket file must exist + assert.ok(fs.existsSync(pipePath)); + const socket = net.connect(pipePath, common.mustCall(() => { + socket.destroy(); + server.close(); + })); + }, 0)) + .on('error', common.mustCall((error) => { + assert.ok(error.code === 'EINVAL', error.message); + net.connect(pipePath) + .on('error', common.mustCall((error) => { + assert.ok(error.code === 'EINVAL', error.message); + })); + })); diff --git a/test/js/node/test/parallel/test-net-server-keepalive.js b/test/js/node/test/parallel/test-net-server-keepalive.js new file mode 100644 index 000000000000..6f3db6468f66 --- /dev/null +++ b/test/js/node/test/parallel/test-net-server-keepalive.js @@ -0,0 +1,35 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +const server = net.createServer({ + keepAlive: true, + keepAliveInitialDelay: 1000 +}, common.mustCall((socket) => { + const setKeepAlive = socket._handle.setKeepAlive; + socket._handle.setKeepAlive = common.mustCall((enable, initialDelay) => { + assert.strictEqual(enable, true); + assert.match(String(initialDelay), /^2|3$/); + return setKeepAlive.call(socket._handle, enable, initialDelay); + }, 2); + socket.setKeepAlive(true, 1000); + socket.setKeepAlive(true, 2000); + socket.setKeepAlive(true, 3000); + socket.destroy(); + server.close(); +})).listen(0, common.mustCall(() => { + net.connect(server.address().port); +})); + +const onconnection = server._handle.onconnection; +server._handle.onconnection = common.mustCall((err, clientHandle) => { + const setKeepAlive = clientHandle.setKeepAlive; + clientHandle.setKeepAlive = common.mustCall((enable, initialDelayMsecs) => { + assert.strictEqual(enable, server.keepAlive); + assert.strictEqual(initialDelayMsecs, server.keepAliveInitialDelay); + setKeepAlive.call(clientHandle, enable, initialDelayMsecs); + clientHandle.setKeepAlive = setKeepAlive; + }); + onconnection.call(server._handle, err, clientHandle); +}); diff --git a/test/js/node/test/parallel/test-net-server-listen-options.js b/test/js/node/test/parallel/test-net-server-listen-options.js new file mode 100644 index 000000000000..7e306af8ab08 --- /dev/null +++ b/test/js/node/test/parallel/test-net-server-listen-options.js @@ -0,0 +1,94 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +function close() { this.close(); } + +{ + // Test listen() + net.createServer().listen().on('listening', common.mustCall(close)); + // Test listen(cb) + net.createServer().listen(common.mustCall(close)); + // Test listen(port) + net.createServer().listen(0).on('listening', common.mustCall(close)); + // Test listen({port}) + net.createServer().listen({ port: 0 }) + .on('listening', common.mustCall(close)); +} + +// Test listen(port, cb) and listen({ port }, cb) combinations +const listenOnPort = [ + (port, cb) => net.createServer().listen({ port }, cb), + (port, cb) => net.createServer().listen(port, cb), +]; + +{ + const assertPort = () => { + return common.expectsError({ + code: 'ERR_SOCKET_BAD_PORT', + name: 'RangeError' + }); + }; + + for (const listen of listenOnPort) { + // Arbitrary unused ports + listen('0', common.mustCall(close)); + listen(0, common.mustCall(close)); + listen(undefined, common.mustCall(close)); + listen(null, common.mustCall(close)); + // Test invalid ports + assert.throws(() => listen(-1, common.mustNotCall()), assertPort()); + assert.throws(() => listen(NaN, common.mustNotCall()), assertPort()); + assert.throws(() => listen(123.456, common.mustNotCall()), assertPort()); + assert.throws(() => listen(65536, common.mustNotCall()), assertPort()); + assert.throws(() => listen(1 / 0, common.mustNotCall()), assertPort()); + assert.throws(() => listen(-1 / 0, common.mustNotCall()), assertPort()); + } + // In listen(options, cb), port takes precedence over path + assert.throws(() => { + net.createServer().listen({ port: -1, path: common.PIPE }, + common.mustNotCall()); + }, assertPort()); +} + +{ + function shouldFailToListen(options) { + const fn = () => { + net.createServer().listen(options, common.mustNotCall()); + }; + + if (typeof options === 'object' && + !(('port' in options) || ('path' in options))) { + assert.throws(fn, + { + code: 'ERR_INVALID_ARG_VALUE', + name: 'TypeError', + message: /^The argument 'options' must have the property "port" or "path"\. Received .+$/, + }); + } else { + assert.throws(fn, + { + code: 'ERR_INVALID_ARG_VALUE', + name: 'TypeError', + message: /^The argument 'options' is invalid\. Received .+$/, + }); + } + } + + shouldFailToListen(false, { port: false }); + shouldFailToListen({ port: false }); + shouldFailToListen(true); + shouldFailToListen({ port: true }); + // Invalid fd as listen(handle) + shouldFailToListen({ fd: -1 }); + // Invalid path in listen(options) + shouldFailToListen({ path: -1 }); + + // Neither port or path are specified in options + shouldFailToListen({}); + shouldFailToListen({ host: 'localhost' }); + shouldFailToListen({ host: 'localhost:3000' }); + shouldFailToListen({ host: { port: 3000 } }); + shouldFailToListen({ exclusive: true }); +} diff --git a/test/js/node/test/parallel/test-net-server-listen-path.js b/test/js/node/test/parallel/test-net-server-listen-path.js new file mode 100644 index 000000000000..8c9d209b3d4a --- /dev/null +++ b/test/js/node/test/parallel/test-net-server-listen-path.js @@ -0,0 +1,91 @@ +'use strict'; + +const common = require('../common'); +const net = require('net'); +const assert = require('assert'); +const fs = require('fs'); + +const tmpdir = require('../common/tmpdir'); +tmpdir.refresh(); + +function closeServer() { + return common.mustCall(function() { + this.close(); + }); +} + +let counter = 0; + +// Avoid conflict with listen-handle +function randomPipePath() { + return `${common.PIPE}-listen-path-${counter++}`; +} + +// Test listen(path) +{ + const handlePath = randomPipePath(); + net.createServer() + .listen(handlePath) + .on('listening', closeServer()); +} + +// Test listen({path}) +{ + const handlePath = randomPipePath(); + net.createServer() + .listen({ path: handlePath }) + .on('listening', closeServer()); +} + +// Test listen(path, cb) +{ + const handlePath = randomPipePath(); + net.createServer() + .listen(handlePath, closeServer()); +} + +// Test listen(path, cb) +{ + const handlePath = randomPipePath(); + net.createServer() + .listen({ path: handlePath }, closeServer()); +} + +// Test pipe chmod +{ + const handlePath = randomPipePath(); + + const server = net.createServer() + .listen({ + path: handlePath, + readableAll: true, + writableAll: true + }, common.mustCall(() => { + if (process.platform !== 'win32') { + const mode = fs.statSync(handlePath).mode; + assert.notStrictEqual(mode & fs.constants.S_IROTH, 0); + assert.notStrictEqual(mode & fs.constants.S_IWOTH, 0); + } + server.close(); + })); +} + +// Test should emit "error" events when listening fails. +{ + const handlePath = randomPipePath(); + const server1 = net.createServer().listen({ path: handlePath }, common.mustCall(() => { + // As the handlePath is in use, binding to the same address again should + // make the server emit an 'EADDRINUSE' error. + const server2 = net.createServer() + .listen({ + path: handlePath, + writableAll: true, + }, common.mustNotCall()); + + server2.on('error', common.mustCall((err) => { + server1.close(); + assert.strictEqual(err.code, 'EADDRINUSE'); + assert.match(err.message, /^listen EADDRINUSE: address already in use/); + })); + })); +} diff --git a/test/js/node/test/parallel/test-net-server-nodelay.js b/test/js/node/test/parallel/test-net-server-nodelay.js new file mode 100644 index 000000000000..a7f11475abe2 --- /dev/null +++ b/test/js/node/test/parallel/test-net-server-nodelay.js @@ -0,0 +1,26 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +const server = net.createServer({ + noDelay: true +}, common.mustCall((socket) => { + socket._handle.setNoDelay = common.mustNotCall(); + socket.setNoDelay(true); + socket.destroy(); + server.close(); +})).listen(0, common.mustCall(() => { + net.connect(server.address().port); +})); + +const onconnection = server._handle.onconnection; +server._handle.onconnection = common.mustCall((err, clientHandle) => { + const setNoDelay = clientHandle.setNoDelay; + clientHandle.setNoDelay = common.mustCall((enable) => { + assert.strictEqual(enable, server.noDelay); + setNoDelay.call(clientHandle, enable); + clientHandle.setNoDelay = setNoDelay; + }); + onconnection.call(server._handle, err, clientHandle); +}); diff --git a/test/js/node/test/parallel/test-net-server-reset.js b/test/js/node/test/parallel/test-net-server-reset.js new file mode 100644 index 000000000000..9e6e9c45fd40 --- /dev/null +++ b/test/js/node/test/parallel/test-net-server-reset.js @@ -0,0 +1,30 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +const sockets = []; + +const server = net.createServer(common.mustCallAtLeast((c) => { + c.on('close', common.mustCall()); + + sockets.push(c); + + if (sockets.length === 2) { + assert.strictEqual(server.close(), server); + sockets.forEach((c) => c.resetAndDestroy()); + } +})); + +server.on('close', common.mustCall()); + +assert.strictEqual(server, server.listen(0, common.mustCall(() => { + net.createConnection(server.address().port) + .on('error', common.mustCall((error) => { + assert.strictEqual(error.code, 'ECONNRESET'); + })); + net.createConnection(server.address().port) + .on('error', common.mustCall((error) => { + assert.strictEqual(error.code, 'ECONNRESET'); + })); +}))); diff --git a/test/js/node/test/parallel/test-net-socket-reset-send.js b/test/js/node/test/parallel/test-net-socket-reset-send.js new file mode 100644 index 000000000000..b7b9f66cb93d --- /dev/null +++ b/test/js/node/test/parallel/test-net-socket-reset-send.js @@ -0,0 +1,30 @@ +'use strict'; + +const common = require('../common'); +const net = require('net'); +const assert = require('assert'); + +const server = net.createServer(); +server.listen(0, common.mustCall(() => { + const port = server.address().port; + const conn = net.createConnection(port); + server.on('connection', (socket) => { + socket.on('error', common.expectsError({ + code: 'ECONNRESET', + message: 'read ECONNRESET', + name: 'Error' + })); + }); + + conn.on('connect', common.mustCall(() => { + assert.strictEqual(conn, conn.resetAndDestroy().destroy()); + conn.on('error', common.mustNotCall()); + + conn.write(Buffer.from('fzfzfzfzfz'), common.expectsError({ + code: 'ERR_STREAM_DESTROYED', + message: 'Cannot call write after a stream was destroyed', + name: 'Error' + })); + server.close(); + })); +})); diff --git a/test/js/node/test/parallel/test-net-socket-setnodelay.js b/test/js/node/test/parallel/test-net-socket-setnodelay.js new file mode 100644 index 000000000000..97cf992b162e --- /dev/null +++ b/test/js/node/test/parallel/test-net-socket-setnodelay.js @@ -0,0 +1,56 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +const truthyValues = [true, 1, 'true', {}, []]; +const falseyValues = [false, 0, '']; +const genSetNoDelay = common.mustCall((desiredArg) => common.mustCall((enable) => { + assert.strictEqual(enable, desiredArg); +}), 2); + +// setNoDelay should default to true +let socket = new net.Socket({ + handle: { + setNoDelay: genSetNoDelay(true), + readStart() {} + } +}); +socket.setNoDelay(); + +socket = new net.Socket({ + handle: { + setNoDelay: genSetNoDelay(true), + readStart() {} + } +}); +truthyValues.forEach((testVal) => socket.setNoDelay(testVal)); + +socket = new net.Socket({ + handle: { + setNoDelay: common.mustNotCall(), + readStart() {} + } +}); +falseyValues.forEach((testVal) => socket.setNoDelay(testVal)); + +socket = new net.Socket({ + handle: { + setNoDelay: common.mustCall(3), + readStart() {} + } +}); +truthyValues.concat(falseyValues).concat(truthyValues) + .forEach((testVal) => socket.setNoDelay(testVal)); + +// If a handler doesn't have a setNoDelay function it shouldn't be called. +// In the case below, if it is called an exception will be thrown +socket = new net.Socket({ + handle: { + setNoDelay: null, + readStart() {} + } +}); +const returned = socket.setNoDelay(true); +assert.ok(returned instanceof net.Socket); diff --git a/test/js/node/test/parallel/test-net-socket-tos.js b/test/js/node/test/parallel/test-net-socket-tos.js new file mode 100644 index 000000000000..8510e840daa5 --- /dev/null +++ b/test/js/node/test/parallel/test-net-socket-tos.js @@ -0,0 +1,100 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +const server = net.createServer( + common.mustCall((socket) => { + socket.end(); + }), +); + +server.listen( + 0, + common.mustCall(() => { + const port = server.address().port; + const client = new net.Socket(); + + // Set TOS before connection to test caching behavior + client.setTypeOfService(0x10); + client.connect(port); + + client.on( + 'connect', + common.mustCall(() => { + // TEST 1: setTypeOfService validation + // Should throw if value is not a number, is NaN, or is out of range (0-255) + assert.throws(() => client.setTypeOfService('invalid'), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => client.setTypeOfService(NaN), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => client.setTypeOfService(256), { + code: 'ERR_OUT_OF_RANGE', + }); + assert.throws(() => client.setTypeOfService(-1), { + code: 'ERR_OUT_OF_RANGE', + }); + + // TEST 2a: Verify deferred application + // Check if the TOS value set before connect() was cached and applied. + // We mask with 0xFC to check only the high 6 bits (DSCP), + // ignoring the lowest 2 bits (ECN) which the OS may modify or zero out. + const mask = 0xFC; + const preConnectGot = client.getTypeOfService(); + + // Windows often resets TOS or ignores it without admin/registry tweaks. + // We only assert strict equality on non-Windows platforms. + if (!common.isWindows) { + assert.strictEqual( + preConnectGot & mask, + 0x10 & mask, + `Pre-connect TOS should be ${0x10 & mask}, got ${preConnectGot & mask}`, + ); + } + + // TEST 2b: Setting and getting TOS on an active connection + const tosValue = 0x10; // IPTOS_LOWDELAY (16) + + // On all platforms, this should succeed (tries both IPv4 and IPv6) + client.setTypeOfService(tosValue); + + // Verify values + const got = client.getTypeOfService(); + + if (!common.isWindows) { + assert.strictEqual( + got & mask, + tosValue & mask, + `Expected TOS ${tosValue & mask}, got ${got & mask}`, + ); + } + + // TEST 3: Boundary values + // Check min (0x00), max (0xFF), and arbitrary intermediate values + for (const boundaryValue of [0x00, 0xFF, 0x3F]) { + client.setTypeOfService(boundaryValue); + const gotBoundary = client.getTypeOfService(); + + if (!common.isWindows) { + assert.strictEqual( + gotBoundary & mask, + boundaryValue & mask, + `Expected TOS ${boundaryValue & mask}, got ${gotBoundary & mask}`, + ); + } + } + + client.end(); + }), + ); + + client.on( + 'end', + common.mustCall(() => { + server.close(); + }), + ); + }), +); diff --git a/test/js/node/test/parallel/test-net-socket-write-after-close.js b/test/js/node/test/parallel/test-net-socket-write-after-close.js new file mode 100644 index 000000000000..3c6537937d28 --- /dev/null +++ b/test/js/node/test/parallel/test-net-socket-write-after-close.js @@ -0,0 +1,42 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +{ + const server = net.createServer(); + + server.listen(common.mustCall(() => { + const port = server.address().port; + const client = net.connect({ port }, common.mustCall(() => { + client.on('error', common.mustCall((err) => { + server.close(); + assert.strictEqual(err.constructor, Error); + assert.strictEqual(err.message, `write ${common.isWindows ? 'EPIPE' : 'EBADF'}`); + })); + client._handle.close(); + client.write('foo'); + })); + })); +} + +{ + const server = net.createServer(); + + server.listen(common.mustCall(() => { + const port = server.address().port; + const client = net.connect({ port }, common.mustCall(() => { + client.on('error', common.expectsError({ + code: 'ERR_SOCKET_CLOSED', + message: 'Socket is closed', + name: 'Error' + })); + + server.close(); + + client._handle.close(); + client._handle = null; + client.write('foo'); + })); + })); +} diff --git a/test/js/node/test/parallel/test-net-stream.js b/test/js/node/test/parallel/test-net-stream.js new file mode 100644 index 000000000000..cf6d615591ea --- /dev/null +++ b/test/js/node/test/parallel/test-net-stream.js @@ -0,0 +1,51 @@ +// 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. + +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +const SIZE = 2E6; +const N = 10; +const buf = Buffer.alloc(SIZE, 'a'); + +const server = net.createServer(common.mustCall((socket) => { + socket.setNoDelay(); + + socket.on('error', common.mustCall(() => socket.destroy())) + .on('close', common.mustCall(() => server.close())); + + for (let i = 0; i < N; ++i) { + socket.write(buf, () => {}); + } + socket.end(); + +})).listen(0, common.mustCall(function() { + const conn = net.connect(this.address().port); + conn.on('data', common.mustCall((buf) => { + assert.strictEqual(conn, conn.pause()); + setTimeout(function() { + conn.destroy(); + }, 20); + })); +})); diff --git a/test/js/node/test/parallel/test-net-write-after-close.js b/test/js/node/test/parallel/test-net-write-after-close.js new file mode 100644 index 000000000000..9f259935f491 --- /dev/null +++ b/test/js/node/test/parallel/test-net-write-after-close.js @@ -0,0 +1,52 @@ +// 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. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); + +const net = require('net'); + +let serverSocket; + +const server = net.createServer(common.mustCall(function(socket) { + serverSocket = socket; + + socket.resume(); + + socket.on('error', common.mustNotCall()); +})); + +server.listen(0, common.mustCall(function() { + const client = net.connect(this.address().port, common.mustCall(() => { + // client.end() will close both the readable and writable side + // of the duplex because allowHalfOpen defaults to false. + // Then 'end' will be emitted when it receives a FIN packet from + // the other side. + client.on('end', common.mustCall(() => { + serverSocket.write('test', common.mustCall((err) => { + assert(err); + server.close(); + })); + })); + client.end(); + })); +})); diff --git a/test/js/node/test/parallel/test-net-write-after-end-nt.js b/test/js/node/test/parallel/test-net-write-after-end-nt.js new file mode 100644 index 000000000000..bcb986c22496 --- /dev/null +++ b/test/js/node/test/parallel/test-net-write-after-end-nt.js @@ -0,0 +1,32 @@ +'use strict'; +const common = require('../common'); + +const assert = require('assert'); +const net = require('net'); + +const { expectsError, mustCall } = common; + +// This test ensures those errors caused by calling `net.Socket.write()` +// after sockets ending will be emitted in the next tick. +const server = net.createServer(mustCall((socket) => { + socket.end(); +})).listen(mustCall(() => { + const client = net.connect(server.address().port, mustCall(() => { + let hasError = false; + client.on('error', mustCall((err) => { + hasError = true; + server.close(); + })); + client.on('end', mustCall(() => { + const ret = client.write('hello', expectsError({ + code: 'EPIPE', + message: 'This socket has been ended by the other party', + name: 'Error' + })); + + assert.strictEqual(ret, false); + assert(!hasError, 'The error should be emitted in the next tick.'); + })); + client.end(); + })); +})); diff --git a/test/js/node/test/parallel/test-tls-basic-validations.js b/test/js/node/test/parallel/test-tls-basic-validations.js new file mode 100644 index 000000000000..0446b6aef219 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-basic-validations.js @@ -0,0 +1,137 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); + +assert.throws( + () => tls.createSecureContext({ ciphers: 1 }), + { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: 'The "options.ciphers" property must be of type string.' + + ' Received type number (1)' + }); + +assert.throws( + () => tls.createServer({ ciphers: 1 }), + { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: 'The "options.ciphers" property must be of type string.' + + ' Received type number (1)' + }); + +assert.throws( + () => tls.createSecureContext({ key: 'dummykey', passphrase: 1 }), + { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: /The "options\.passphrase" property must be of type string/ + }); + +assert.throws( + () => tls.createServer({ key: 'dummykey', passphrase: 1 }), + { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: /The "options\.passphrase" property must be of type string/ + }); + +assert.throws( + () => tls.createServer({ ecdhCurve: 1 }), + { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: /The "options\.ecdhCurve" property must be of type string/ + }); + +assert.throws( + () => tls.createServer({ handshakeTimeout: 'abcd' }), + { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: 'The "options.handshakeTimeout" property must be of type number.' + + " Received type string ('abcd')" + } +); + +assert.throws( + () => tls.createServer({ sessionTimeout: 'abcd' }), + { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: /The "options\.sessionTimeout" property must be of type number/ + }); + +assert.throws( + () => tls.createServer({ ticketKeys: 'abcd' }), + { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: /The "options\.ticketKeys" property must be an instance of/ + }); + +assert.throws(() => tls.createServer({ ticketKeys: Buffer.alloc(0) }), { + code: 'ERR_INVALID_ARG_VALUE', + message: /The property 'options\.ticketKeys' must be exactly 48 bytes/ +}); + +{ + const buffer = Buffer.from('abcd'); + const out = {}; + tls.convertALPNProtocols(buffer, out); + out.ALPNProtocols.write('efgh'); + assert(buffer.equals(Buffer.from('abcd'))); + assert(out.ALPNProtocols.equals(Buffer.from('efgh'))); +} + +{ + const arrayBufferViewStr = 'abcd'; + const inputBuffer = Buffer.from(arrayBufferViewStr.repeat(8), 'utf8'); + for (const expectView of common.getArrayBufferViews(inputBuffer)) { + const out = {}; + const expected = Buffer.from(expectView.buffer.slice(), + expectView.byteOffset, + expectView.byteLength); + tls.convertALPNProtocols(expectView, out); + assert(out.ALPNProtocols.equals(expected)); + } +} + +{ + const protocols = [(new String('a')).repeat(500)]; + const out = {}; + assert.throws( + () => tls.convertALPNProtocols(protocols, out), + { + code: 'ERR_OUT_OF_RANGE', + message: 'The byte length of the protocol at index 0 exceeds the ' + + 'maximum length. It must be <= 255. Received 500' + } + ); +} + +assert.throws(() => { tls.createSecureContext({ minVersion: 'fhqwhgads' }); }, + { + code: 'ERR_TLS_INVALID_PROTOCOL_VERSION', + name: 'TypeError' + }); + +assert.throws(() => { tls.createSecureContext({ maxVersion: 'fhqwhgads' }); }, + { + code: 'ERR_TLS_INVALID_PROTOCOL_VERSION', + name: 'TypeError' + }); + +for (const checkServerIdentity of [undefined, null, 1, true]) { + assert.throws(() => { + tls.connect({ checkServerIdentity }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + }); +} diff --git a/test/js/node/test/parallel/test-tls-buffersize.js b/test/js/node/test/parallel/test-tls-buffersize.js new file mode 100644 index 000000000000..eadd4cb1e40c --- /dev/null +++ b/test/js/node/test/parallel/test-tls-buffersize.js @@ -0,0 +1,43 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const assert = require('assert'); +const fixtures = require('../common/fixtures'); +const tls = require('tls'); + +const iter = 10; + +const server = tls.createServer({ + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem') +}, common.mustCall((socket) => { + let str = ''; + socket.setEncoding('utf-8'); + socket.on('data', (chunk) => { str += chunk; }); + + socket.on('end', common.mustCall(() => { + assert.strictEqual(str, 'a'.repeat(iter - 1)); + server.close(); + })); +})); + +server.listen(0, common.mustCall(() => { + const client = tls.connect({ + port: server.address().port, + rejectUnauthorized: false + }, common.mustCall(() => { + assert.strictEqual(client.bufferSize, 0); + + for (let i = 1; i < iter; i++) { + client.write('a'); + assert.strictEqual(client.bufferSize, i); + } + + client.on('finish', common.mustCall(() => { + assert.strictEqual(client.bufferSize, 0); + })); + + client.end(); + })); +})); diff --git a/test/js/node/test/parallel/test-tls-cert-chains-concat.js b/test/js/node/test/parallel/test-tls-cert-chains-concat.js new file mode 100644 index 000000000000..ffb29cf5aae2 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-cert-chains-concat.js @@ -0,0 +1,48 @@ +'use strict'; +const common = require('../common'); +const fixtures = require('../common/fixtures'); + +// Check cert chain is received by client, and is completed with the ca cert +// known to the client. + +const { + assert, connect, debug, keys +} = require(fixtures.path('tls-connect')); + +// agent6-cert.pem includes cert for agent6 and ca3 +connect({ + client: { + checkServerIdentity: (servername, cert) => { }, + ca: keys.agent6.ca, + }, + server: { + cert: keys.agent6.cert, + key: keys.agent6.key, + }, +}, common.mustSucceed((pair, cleanup) => { + const peer = pair.client.conn.getPeerCertificate(); + debug('peer:\n', peer); + assert.strictEqual(peer.subject.emailAddress, 'adam.lippai@tresorit.com'); + assert.strictEqual(peer.subject.CN, 'Ádám Lippai'); + assert.strictEqual(peer.issuer.CN, 'ca3'); + assert.match(peer.serialNumber, /5B75D77EDC7FB5B7FA9F1424DA4C64FB815DCBDE/i); + + const next = pair.client.conn.getPeerCertificate(true).issuerCertificate; + const root = next.issuerCertificate; + delete next.issuerCertificate; + debug('next:\n', next); + assert.strictEqual(next.subject.CN, 'ca3'); + assert.strictEqual(next.issuer.CN, 'ca1'); + assert.match(next.serialNumber, /147D36C1C2F74206DE9FAB5F2226D78ADB00A425/i); + + debug('root:\n', root); + assert.strictEqual(root.subject.CN, 'ca1'); + assert.strictEqual(root.issuer.CN, 'ca1'); + assert.match(root.serialNumber, /4AB16C8DFD6A7D0D2DFCABDF9C4B0E92C6AD0229/i); + + // No client cert, so empty object returned. + assert.deepStrictEqual(pair.server.conn.getPeerCertificate(), {}); + assert.deepStrictEqual(pair.server.conn.getPeerCertificate(true), {}); + + return cleanup(); +})); diff --git a/test/js/node/test/parallel/test-tls-cli-max-version-1.2.js b/test/js/node/test/parallel/test-tls-cli-max-version-1.2.js new file mode 100644 index 000000000000..9bbc9ff0ecad --- /dev/null +++ b/test/js/node/test/parallel/test-tls-cli-max-version-1.2.js @@ -0,0 +1,15 @@ +// Flags: --tls-max-v1.2 +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +// Check that node `--tls-max-v1.2` is supported. + +const assert = require('assert'); +const tls = require('tls'); + +assert.strictEqual(tls.DEFAULT_MAX_VERSION, 'TLSv1.2'); +assert.strictEqual(tls.DEFAULT_MIN_VERSION, 'TLSv1.2'); + +// Check the min-max version protocol versions against these CLI settings. +require('./test-tls-min-max-version.js'); diff --git a/test/js/node/test/parallel/test-tls-cli-max-version-1.3.js b/test/js/node/test/parallel/test-tls-cli-max-version-1.3.js new file mode 100644 index 000000000000..c04354fe4ac9 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-cli-max-version-1.3.js @@ -0,0 +1,15 @@ +// Flags: --tls-max-v1.3 +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +// Check that node `--tls-max-v1.3` is supported. + +const assert = require('assert'); +const tls = require('tls'); + +assert.strictEqual(tls.DEFAULT_MAX_VERSION, 'TLSv1.3'); +assert.strictEqual(tls.DEFAULT_MIN_VERSION, 'TLSv1.2'); + +// Check the min-max version protocol versions against these CLI settings. +require('./test-tls-min-max-version.js'); diff --git a/test/js/node/test/parallel/test-tls-cli-min-version-1.0.js b/test/js/node/test/parallel/test-tls-cli-min-version-1.0.js new file mode 100644 index 000000000000..577562782ece --- /dev/null +++ b/test/js/node/test/parallel/test-tls-cli-min-version-1.0.js @@ -0,0 +1,15 @@ +// Flags: --tls-min-v1.0 --tls-min-v1.1 +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +// Check that `node --tls-v1.0` is supported, and overrides --tls-v1.1. + +const assert = require('assert'); +const tls = require('tls'); + +assert.strictEqual(tls.DEFAULT_MAX_VERSION, 'TLSv1.3'); +assert.strictEqual(tls.DEFAULT_MIN_VERSION, 'TLSv1'); + +// Check the min-max version protocol versions against these CLI settings. +require('./test-tls-min-max-version.js'); diff --git a/test/js/node/test/parallel/test-tls-cli-min-version-1.1.js b/test/js/node/test/parallel/test-tls-cli-min-version-1.1.js new file mode 100644 index 000000000000..3af2b39546c4 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-cli-min-version-1.1.js @@ -0,0 +1,15 @@ +// Flags: --tls-min-v1.1 +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +// Check that node `--tls-v1.1` is supported. + +const assert = require('assert'); +const tls = require('tls'); + +assert.strictEqual(tls.DEFAULT_MAX_VERSION, 'TLSv1.3'); +assert.strictEqual(tls.DEFAULT_MIN_VERSION, 'TLSv1.1'); + +// Check the min-max version protocol versions against these CLI settings. +require('./test-tls-min-max-version.js'); diff --git a/test/js/node/test/parallel/test-tls-cli-min-version-1.2.js b/test/js/node/test/parallel/test-tls-cli-min-version-1.2.js new file mode 100644 index 000000000000..8385eabd0bab --- /dev/null +++ b/test/js/node/test/parallel/test-tls-cli-min-version-1.2.js @@ -0,0 +1,15 @@ +// Flags: --tls-min-v1.2 +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +// Check that node `--tls-min-v1.2` is supported. + +const assert = require('assert'); +const tls = require('tls'); + +assert.strictEqual(tls.DEFAULT_MAX_VERSION, 'TLSv1.3'); +assert.strictEqual(tls.DEFAULT_MIN_VERSION, 'TLSv1.2'); + +// Check the min-max version protocol versions against these CLI settings. +require('./test-tls-min-max-version.js'); diff --git a/test/js/node/test/parallel/test-tls-cli-min-version-1.3.js b/test/js/node/test/parallel/test-tls-cli-min-version-1.3.js new file mode 100644 index 000000000000..1bccc2f6cd33 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-cli-min-version-1.3.js @@ -0,0 +1,15 @@ +// Flags: --tls-min-v1.3 +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +// Check that node `--tls-min-v1.3` is supported. + +const assert = require('assert'); +const tls = require('tls'); + +assert.strictEqual(tls.DEFAULT_MAX_VERSION, 'TLSv1.3'); +assert.strictEqual(tls.DEFAULT_MIN_VERSION, 'TLSv1.3'); + +// Check the min-max version protocol versions against these CLI settings. +require('./test-tls-min-max-version.js'); diff --git a/test/js/node/test/parallel/test-tls-client-getephemeralkeyinfo.js b/test/js/node/test/parallel/test-tls-client-getephemeralkeyinfo.js new file mode 100644 index 000000000000..0584e4d11e40 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-client-getephemeralkeyinfo.js @@ -0,0 +1,88 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +if (process.features.openssl_is_boringssl) { + require('../common/boringssl').testEphemeralKeyInfoUnsupported(); + return; +} + +const fixtures = require('../common/fixtures'); +const { hasOpenSSL } = require('../common/crypto'); + +const assert = require('assert'); +const { X509Certificate } = require('crypto'); +const tls = require('tls'); + +const key = fixtures.readKey('agent2-key.pem'); +const cert = fixtures.readKey('agent2-cert.pem'); + +function loadDHParam(n) { + return fixtures.readKey(`dh${n}.pem`); +} + +function test(size, type, name, cipher) { + assert(cipher); + + const options = { + key: key, + cert: cert, + ciphers: cipher, + maxVersion: 'TLSv1.2', + }; + + if (name) options.ecdhCurve = name; + + if (type === 'DH') { + if (size === 'auto') { + options.dhparam = 'auto'; + // The DHE parameters selected by OpenSSL depend on the strength of the + // certificate's key. For this test, we can assume that the modulus length + // of the certificate's key is equal to the size of the DHE parameter, but + // that is really only true for a few modulus lengths. + ({ + publicKey: { asymmetricKeyDetails: { modulusLength: size } } + } = new X509Certificate(cert)); + } else { + options.dhparam = loadDHParam(size); + } + } + + const server = tls.createServer(options, common.mustCall((conn) => { + assert.strictEqual(conn.getEphemeralKeyInfo(), null); + conn.end(); + })); + + server.on('close', common.mustSucceed()); + + server.listen(0, common.mustCall(() => { + 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()); + })); +} + +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'); +} else { + test(3072, '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'); +test(448, 'ECDH', 'X448', 'ECDHE-RSA-AES256-GCM-SHA384'); diff --git a/test/js/node/test/parallel/test-tls-client-reject-12.js b/test/js/node/test/parallel/test-tls-client-reject-12.js new file mode 100644 index 000000000000..f77d463f44dc --- /dev/null +++ b/test/js/node/test/parallel/test-tls-client-reject-12.js @@ -0,0 +1,13 @@ +'use strict'; + +// test-tls-client-reject specifically for TLS1.2. + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const tls = require('tls'); + +tls.DEFAULT_MAX_VERSION = 'TLSv1.2'; + +require('./test-tls-client-reject.js'); diff --git a/test/js/node/test/parallel/test-tls-client-reject.js b/test/js/node/test/parallel/test-tls-client-reject.js new file mode 100644 index 000000000000..cff0aabc89a7 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-client-reject.js @@ -0,0 +1,112 @@ +// 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. + +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +const options = { + key: fixtures.readKey('rsa_private.pem'), + cert: fixtures.readKey('rsa_cert.crt'), + ...(process.features.openssl_is_boringssl ? { maxVersion: 'TLSv1.2' } : {}), +}; + +const server = tls.createServer(options, function(socket) { + socket.pipe(socket); + // Pipe already ends... but leaving this here tests .end() after .end(). + socket.on('end', () => socket.end()); +}).listen(0, common.mustCall(function() { + unauthorized(); +})); + +function unauthorized() { + console.log('connect unauthorized'); + const socket = tls.connect({ + port: server.address().port, + servername: 'localhost', + rejectUnauthorized: false, + ...(process.features.openssl_is_boringssl ? { maxVersion: 'TLSv1.2' } : {}), + }, common.mustCall(function() { + let _data; + assert(!socket.authorized); + socket.on('data', common.mustCall((data) => { + assert.strictEqual(data.toString(), 'ok'); + _data = data; + })); + socket.on('end', common.mustCall(() => { + assert(_data, 'data failed to echo!'); + })); + socket.on('end', () => rejectUnauthorized()); + })); + socket.once('session', common.mustCall()); + socket.on('error', common.mustNotCall()); + socket.end('ok'); +} + +function rejectUnauthorized() { + console.log('reject unauthorized'); + const socket = tls.connect(server.address().port, { + servername: 'localhost', + ...(process.features.openssl_is_boringssl ? { maxVersion: 'TLSv1.2' } : {}), + }, common.mustNotCall()); + socket.on('data', common.mustNotCall()); + socket.on('error', common.mustCall(function(err) { + rejectUnauthorizedUndefined(); + })); + socket.end('ng'); +} + +function rejectUnauthorizedUndefined() { + console.log('reject unauthorized undefined'); + const socket = tls.connect(server.address().port, { + servername: 'localhost', + rejectUnauthorized: undefined, + ...(process.features.openssl_is_boringssl ? { maxVersion: 'TLSv1.2' } : {}), + }, common.mustNotCall()); + socket.on('data', common.mustNotCall()); + socket.on('error', common.mustCall(function(err) { + authorized(); + })); + socket.end('ng'); +} + +function authorized() { + console.log('connect authorized'); + const socket = tls.connect(server.address().port, { + ca: [fixtures.readKey('rsa_cert.crt')], + servername: 'localhost', + ...(process.features.openssl_is_boringssl ? { maxVersion: 'TLSv1.2' } : {}), + }, common.mustCall(function() { + console.log('... authorized'); + assert(socket.authorized); + socket.on('data', common.mustCall((data) => { + assert.strictEqual(data.toString(), 'ok'); + })); + socket.on('end', () => server.close()); + })); + socket.on('error', common.mustNotCall()); + socket.end('ok'); +} diff --git a/test/js/node/test/parallel/test-tls-client-renegotiation-13.js b/test/js/node/test/parallel/test-tls-client-renegotiation-13.js new file mode 100644 index 000000000000..80c4753d065e --- /dev/null +++ b/test/js/node/test/parallel/test-tls-client-renegotiation-13.js @@ -0,0 +1,55 @@ +'use strict'; + +const common = require('../common'); + +if (!common.hasCrypto) { + common.skip('missing crypto'); +} +const { hasOpenSSL3 } = require('../common/crypto'); + +const fixtures = require('../common/fixtures'); + +// Confirm that for TLSv1.3, renegotiate() is disallowed. + +const { + assert, connect, keys +} = require(fixtures.path('tls-connect')); + +const server = keys.agent10; + +connect({ + client: { + ca: server.ca, + checkServerIdentity: common.mustCall(), + }, + server: { + key: server.key, + cert: server.cert, + }, +}, common.mustSucceed((pair, cleanup) => { + const client = pair.client.conn; + + assert.strictEqual(client.getProtocol(), 'TLSv1.3'); + + const ok = client.renegotiate({}, common.mustCall((err) => { + if (process.features.openssl_is_boringssl) { + assert.throws(() => { throw err; }, { + message: 'TLS session renegotiation is unsupported by this TLS ' + + 'implementation', + code: 'ERR_TLS_RENEGOTIATION_UNSUPPORTED', + }); + } else { + assert.throws(() => { throw err; }, { + message: hasOpenSSL3 ? + 'error:0A00010A:SSL routines::wrong ssl version' : + 'error:1420410A:SSL routines:SSL_renegotiate:wrong ssl version', + code: 'ERR_SSL_WRONG_SSL_VERSION', + library: 'SSL routines', + reason: 'wrong ssl version', + }); + } + cleanup(); + })); + + assert.strictEqual(ok, false); +})); diff --git a/test/js/node/test/parallel/test-tls-client-resume-12.js b/test/js/node/test/parallel/test-tls-client-resume-12.js new file mode 100644 index 000000000000..7767d3dd2a5c --- /dev/null +++ b/test/js/node/test/parallel/test-tls-client-resume-12.js @@ -0,0 +1,13 @@ +'use strict'; + +// test-tls-client-resume specifically for TLS1.2. + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const tls = require('tls'); + +tls.DEFAULT_MAX_VERSION = 'TLSv1.2'; + +require('./test-tls-client-resume.js'); diff --git a/test/js/node/test/parallel/test-tls-client-resume.js b/test/js/node/test/parallel/test-tls-client-resume.js new file mode 100644 index 000000000000..7d1e964d8ec2 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-client-resume.js @@ -0,0 +1,115 @@ +// 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. + +'use strict'; + +// Check that the ticket from the first connection causes session resumption +// when used to make a second connection. + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +const options = { + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem') +}; + +// create server +const server = tls.Server(options, common.mustCall((socket) => { + socket.end('Goodbye'); +}, 2)); + +// start listening +server.listen(0, common.mustCall(function() { + let sessionx = null; // From right after connect, invalid for TLS1.3 + let session1 = null; // Delivered by the session event, always valid. + let sessions = 0; + let tls13; + const client1 = tls.connect({ + port: this.address().port, + rejectUnauthorized: false + }, common.mustCall(() => { + tls13 = client1.getProtocol() === 'TLSv1.3'; + assert.strictEqual(client1.isSessionReused(), false); + sessionx = client1.getSession(); + assert(sessionx); + + if (session1) + reconnect(); + })); + + client1.on('data', common.mustCall()); + + client1.once('session', common.mustCall((session) => { + console.log('session1'); + session1 = session; + assert(session1); + if (sessionx) + reconnect(); + })); + + client1.on('session', () => { + console.log('client1 session#', ++sessions); + }); + + client1.on('close', common.mustCall(() => { + console.log('client1 close'); + assert.strictEqual(sessions, tls13 ? 2 : 1); + })); + + function reconnect() { + assert(sessionx); + assert(session1); + if (tls13) + // For TLS1.3, the session immediately after handshake is a dummy, + // unresumable session. The one delivered later in session event is + // resumable. + assert.notStrictEqual(sessionx.compare(session1), 0); + else + // For TLS1.2, they are identical. + assert.strictEqual(sessionx.compare(session1), 0); + + const opts = { + port: server.address().port, + rejectUnauthorized: false, + session: session1, + }; + + const client2 = tls.connect(opts, common.mustCall(() => { + console.log('connect2'); + assert.strictEqual(client2.isSessionReused(), true); + })); + + client2.on('close', common.mustCall(() => { + console.log('close2'); + server.close(); + })); + + client2.resume(); + } + + client1.resume(); +})); diff --git a/test/js/node/test/parallel/test-tls-clientcertengine-invalid-arg-type.js b/test/js/node/test/parallel/test-tls-clientcertengine-invalid-arg-type.js new file mode 100644 index 000000000000..811e320b0788 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-clientcertengine-invalid-arg-type.js @@ -0,0 +1,15 @@ +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); + +{ + assert.throws( + () => { tls.createSecureContext({ clientCertEngine: 0 }); }, + { code: 'ERR_INVALID_ARG_TYPE', + message: / Received type number \(0\)/ }); +} diff --git a/test/js/node/test/parallel/test-tls-cnnic-whitelist.js b/test/js/node/test/parallel/test-tls-cnnic-whitelist.js new file mode 100644 index 000000000000..99ad02ee1c66 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-cnnic-whitelist.js @@ -0,0 +1,56 @@ +// Flags: --use-bundled-ca +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +function loadPEM(n) { + return fixtures.readKey(`${n}.pem`); +} + +const testCases = [ + // Test 1: for the fix of node#2061 + // agent6-cert.pem is signed by intermediate cert of ca3. + // The server has a cert chain of agent6->ca3->ca1(root) but + // tls.connect should be failed with an error of + // UNABLE_TO_GET_ISSUER_CERT_LOCALLY since the root CA of ca1 is not + // installed locally. + { + serverOpts: { + ca: loadPEM('ca3-key'), + key: loadPEM('agent6-key'), + cert: loadPEM('agent6-cert') + }, + clientOpts: { + port: undefined, + rejectUnauthorized: true + }, + errorCode: 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY' + }, +]; + +function runTest(tindex) { + const tcase = testCases[tindex]; + + if (!tcase) return; + + const server = tls.createServer(tcase.serverOpts, (s) => { + s.resume(); + }).listen(0, common.mustCall(function() { + tcase.clientOpts.port = this.address().port; + const client = tls.connect(tcase.clientOpts); + client.on('error', common.mustCall((e) => { + assert.strictEqual(e.code, tcase.errorCode); + server.close(common.mustCall(() => { + runTest(tindex + 1); + })); + })); + })); +} + +runTest(0); diff --git a/test/js/node/test/parallel/test-tls-connect-given-socket.js b/test/js/node/test/parallel/test-tls-connect-given-socket.js new file mode 100644 index 000000000000..f25cf4582d59 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-connect-given-socket.js @@ -0,0 +1,85 @@ +// 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. + +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const fixtures = require('../common/fixtures'); + +const assert = require('assert'); +const tls = require('tls'); +const net = require('net'); + +const options = { + key: fixtures.readKey('rsa_private.pem'), + cert: fixtures.readKey('rsa_cert.crt') +}; + +const server = tls.createServer(options, common.mustCall((socket) => { + socket.end('Hello'); +}, 2)).listen(0, common.mustCall(() => { + let waiting = 2; + function establish(socket, calls) { + const client = tls.connect({ + rejectUnauthorized: false, + socket: socket + }, common.mustCall(() => { + let data = ''; + client.on('data', common.mustCall((chunk) => { + data += chunk.toString(); + })); + client.on('end', common.mustCall(() => { + assert.strictEqual(data, 'Hello'); + if (--waiting === 0) + server.close(); + })); + }, calls)); + assert(client.readable); + assert(client.writable); + + return client; + } + + const { port } = server.address(); + + // Immediate death socket + const immediateDeath = net.connect(port); + establish(immediateDeath, 0).destroy(); + + // Outliving + const outlivingTCP = net.connect(port, common.mustCall(() => { + outlivingTLS.destroy(); + next(); + })); + const outlivingTLS = establish(outlivingTCP, 0); + + function next() { + // Already connected socket + const connected = net.connect(port, common.mustCall(() => { + establish(connected); + })); + + // Connecting socket + const connecting = net.connect(port); + establish(connecting); + } +})); diff --git a/test/js/node/test/parallel/test-tls-connect-memleak.js b/test/js/node/test/parallel/test-tls-connect-memleak.js new file mode 100644 index 000000000000..220ea4a9248e --- /dev/null +++ b/test/js/node/test/parallel/test-tls-connect-memleak.js @@ -0,0 +1,66 @@ +// 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. + +'use strict'; +// Flags: --expose-gc + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const { onGC } = require('../common/gc'); +const assert = require('assert'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +// Test that the implicit listener for an 'connect' event on tls.Sockets is +// added using `once()`, i.e. can be gc'ed once that event has occurred. + +const server = tls.createServer({ + cert: fixtures.readKey('rsa_cert.crt'), + key: fixtures.readKey('rsa_private.pem') +}).listen(0); + +let collected = false; +const gcListener = { ongc() { collected = true; } }; + +{ + const gcObject = {}; + onGC(gcObject, gcListener); + + const sock = tls.connect( + server.address().port, + { rejectUnauthorized: false }, + common.mustCall(() => { + assert.strictEqual(gcObject, gcObject); // Keep reference alive + assert.strictEqual(collected, false); + setImmediate(done, sock); + })); +} + +function done(sock) { + globalThis.gc(); + setImmediate(common.mustCall(() => { + assert.strictEqual(collected, true); + sock.end(); + server.close(); + })); +} diff --git a/test/js/node/test/parallel/test-tls-connect-timeout-option.js b/test/js/node/test/parallel/test-tls-connect-timeout-option.js new file mode 100644 index 000000000000..3c4328d94d91 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-connect-timeout-option.js @@ -0,0 +1,20 @@ +'use strict'; + +const common = require('../common'); + +// This test verifies that `tls.connect()` honors the `timeout` option when the +// socket is internally created. + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); + +const socket = tls.connect({ + port: 42, + lookup: () => {}, + timeout: 1000 +}); + +assert.strictEqual(socket.timeout, 1000); diff --git a/test/js/node/test/parallel/test-tls-dhparam-auto-boringssl.js b/test/js/node/test/parallel/test-tls-dhparam-auto-boringssl.js new file mode 100644 index 000000000000..54f2190d1a94 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-dhparam-auto-boringssl.js @@ -0,0 +1,19 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +if (!process.features.openssl_is_boringssl) + common.skip('only applies to BoringSSL builds'); + +const assert = require('assert'); +const tls = require('tls'); + +// BoringSSL does not provide SSL_CTX_set_dh_auto, so requesting automatic +// DH parameter selection via `dhparam: 'auto'` must throw. +assert.throws(() => { + tls.createSecureContext({ dhparam: 'auto' }); +}, { + code: 'ERR_CRYPTO_UNSUPPORTED_OPERATION', + message: 'Automatic DH parameter selection is not supported', +}); diff --git a/test/js/node/test/parallel/test-tls-disable-renegotiation.js b/test/js/node/test/parallel/test-tls-disable-renegotiation.js new file mode 100644 index 000000000000..84a6ead4a544 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-disable-renegotiation.js @@ -0,0 +1,99 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const fixtures = require('../common/fixtures'); + +// Tests that calling disableRenegotiation on a TLSSocket stops renegotiation. + +if (!common.hasCrypto) + common.skip('missing crypto'); + +if (process.features.openssl_is_boringssl) { + require('../common/boringssl').testRenegotiationUnsupported(); + return; +} + +const tls = require('tls'); + +// Renegotiation as a protocol feature was dropped after TLS1.2. +tls.DEFAULT_MAX_VERSION = 'TLSv1.2'; + +const options = { + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem'), +}; + +const server = tls.Server(options, common.mustCall((socket) => { + socket.on('error', common.mustCall((err) => { + common.expectsError({ + name: 'Error', + code: 'ERR_TLS_RENEGOTIATION_DISABLED', + message: 'TLS session renegotiation disabled for this socket' + })(err); + socket.destroy(); + server.close(); + })); + // Disable renegotiation after the first chunk of data received. + // Demonstrates that renegotiation works successfully up until + // disableRenegotiation is called. + socket.on('data', common.mustCall((chunk) => { + socket.write(chunk); + socket.disableRenegotiation(); + })); + socket.on('secure', common.mustCall(() => { + assert(socket._handle.handshakes < 2, + `Too many handshakes [${socket._handle.handshakes}]`); + })); +})); + + +server.listen(0, common.mustCall(() => { + const port = server.address().port; + const options = { + rejectUnauthorized: false, + port + }; + const client = tls.connect(options, common.mustCall(() => { + + assert.throws(() => client.renegotiate(), { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + }); + + assert.throws(() => client.renegotiate(common.mustNotCall()), { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + }); + + assert.throws(() => client.renegotiate({}, false), { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + }); + + assert.throws(() => client.renegotiate({}, null), { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + }); + + + // Negotiation is still permitted for this first + // attempt. This should succeed. + let ok = client.renegotiate(options, common.mustSucceed(() => { + // Once renegotiation completes, we write some + // data to the socket, which triggers the on + // data event on the server. After that data + // is received, disableRenegotiation is called. + client.write('data', common.mustCall(() => { + // This second renegotiation attempt should fail + // and the callback should never be invoked. The + // server will simply drop the connection after + // emitting the error. + ok = client.renegotiate(options, common.mustNotCall()); + assert.strictEqual(ok, true); + })); + })); + assert.strictEqual(ok, true); + client.on('secureConnect', common.mustCall()); + client.on('secure', common.mustCall()); + })); +})); diff --git a/test/js/node/test/parallel/test-tls-econnreset.js b/test/js/node/test/parallel/test-tls-econnreset.js index a056f908190f..8308c8904d99 100644 --- a/test/js/node/test/parallel/test-tls-econnreset.js +++ b/test/js/node/test/parallel/test-tls-econnreset.js @@ -34,11 +34,11 @@ let clientError = null; const server = tls.createServer({ cert: fixtures.readKey('agent1-cert.pem'), key: fixtures.readKey('agent1-key.pem'), -}, common.mustNotCall()).on('tlsClientError', function(err, conn) { +}, common.mustNotCall()).on('tlsClientError', common.mustCall(function(err, conn) { assert(!clientError && conn); clientError = err; server.close(); -}).listen(0, function() { +})).listen(0, function() { net.connect(this.address().port, function() { // Destroy the socket once it is connected, so the server sees ECONNRESET. this.destroy(); diff --git a/test/js/node/test/parallel/test-tls-empty-sni-context.js b/test/js/node/test/parallel/test-tls-empty-sni-context.js new file mode 100644 index 000000000000..6ecdfbeecbe3 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-empty-sni-context.js @@ -0,0 +1,35 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const { hasOpenSSL } = require('../common/crypto'); +const assert = require('assert'); +const tls = require('tls'); + +const options = { + SNICallback: (name, callback) => { + callback(null, tls.createSecureContext()); + } +}; + +const server = tls.createServer(options, (c) => { + assert.fail('Should not be called'); +}).on('tlsClientError', common.mustCall((err, c) => { + assert.match(err.message, /no suitable signature algorithm|NO_CERTIFICATE_SET/i); + server.close(); +})).listen(0, common.mustCall(() => { + const c = tls.connect({ + port: server.address().port, + rejectUnauthorized: false, + servername: 'any.name' + }, common.mustNotCall()); + + c.on('error', common.mustCall((err) => { + const expectedErr = process.features.openssl_is_boringssl ? + 'ERR_SSL_TLSV1_ALERT_INTERNAL_ERROR' : hasOpenSSL(4, 0) ? + 'ERR_SSL_TLS_ALERT_HANDSHAKE_FAILURE' : hasOpenSSL(3, 2) ? + 'ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE' : 'ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE'; + assert.strictEqual(err.code, expectedErr); + })); +})); diff --git a/test/js/node/test/parallel/test-tls-enable-keylog-cli.js b/test/js/node/test/parallel/test-tls-enable-keylog-cli.js new file mode 100644 index 000000000000..68378d0c6d0d --- /dev/null +++ b/test/js/node/test/parallel/test-tls-enable-keylog-cli.js @@ -0,0 +1,61 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); +const fixtures = require('../common/fixtures'); + +// Test --tls-keylog CLI flag. + +const assert = require('assert'); +const fs = require('fs'); +const { fork } = require('child_process'); + +if (process.argv[2] === 'test') + return test(); + +const tmpdir = require('../common/tmpdir'); +tmpdir.refresh(); +const file = tmpdir.resolve('keylog.log'); + +const child = fork(__filename, ['test'], { + execArgv: ['--tls-keylog=' + file] +}); + +child.on('close', common.mustCall((code, signal) => { + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + const log = fs.readFileSync(file, 'utf8').trim().split('\n'); + // Both client and server should log their secrets, + // so we should have two identical lines in the log + assert.strictEqual(log.length, 2); + assert.strictEqual(log[0], log[1]); +})); + +function test() { + const { + connect, keys + } = require(fixtures.path('tls-connect')); + + connect({ + client: { + checkServerIdentity: (servername, cert) => { }, + ca: `${keys.agent1.cert}\n${keys.agent6.ca}`, + }, + server: { + cert: keys.agent6.cert, + key: keys.agent6.key, + // Number of keylog events is dependent on protocol version + maxVersion: 'TLSv1.2', + }, + }, common.mustCall((err, pair, cleanup) => { + if (pair.server.err) { + console.trace('server', pair.server.err); + } + if (pair.client.err) { + console.trace('client', pair.client.err); + } + assert.ifError(pair.server.err); + assert.ifError(pair.client.err); + + return cleanup(); + })); +} diff --git a/test/js/node/test/parallel/test-tls-env-bad-extra-ca.js b/test/js/node/test/parallel/test-tls-env-bad-extra-ca.js new file mode 100644 index 000000000000..c9db7e4d0312 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-env-bad-extra-ca.js @@ -0,0 +1,44 @@ +// Setting NODE_EXTRA_CA_CERTS to non-existent file emits a warning + +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const fixtures = require('../common/fixtures'); +const fork = require('child_process').fork; +const tls = require('tls'); + +if (process.env.CHILD) { + // This will try to load the extra CA certs, and emit a warning when it fails. + return tls.createServer({}); +} + +const env = { + ...process.env, + CHILD: 'yes', + NODE_EXTRA_CA_CERTS: `${fixtures.fixturesDir}/no-such-file-exists-🐢`, +}; + +const opts = { + env: env, + silent: true, +}; +let stderr = ''; + +fork(__filename, opts) + .on('exit', common.mustCall(function(status) { + // Check that client succeeded in connecting. + assert.strictEqual(status, 0); + })) + .on('close', common.mustCall(function() { + if (!common.isWindows) { + const re = /Warning: Ignoring extra certs from.*no-such-file-exists-🐢.* load failed:.*No such file or directory/; + assert.match(stderr, re); + } + })) + .stderr.setEncoding('utf8').on('data', function(str) { + stderr += str; + }); diff --git a/test/js/node/test/parallel/test-tls-env-extra-ca-with-options.js b/test/js/node/test/parallel/test-tls-env-extra-ca-with-options.js new file mode 100644 index 000000000000..8f04decf670c --- /dev/null +++ b/test/js/node/test/parallel/test-tls-env-extra-ca-with-options.js @@ -0,0 +1,82 @@ +'use strict'; + +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('node:assert'); +const tls = require('node:tls'); +const { fork } = require('node:child_process'); +const fixtures = require('../common/fixtures'); + +const tests = [ + { + get clientOptions() { + const secureContext = tls.createSecureContext(); + secureContext.context.addCACert( + fixtures.readKey('ca1-cert.pem') + ); + + return { + secureContext + }; + } + }, + { + clientOptions: { + crl: fixtures.readKey('ca2-crl.pem') + } + }, + { + clientOptions: { + pfx: fixtures.readKey('agent1.pfx'), + passphrase: 'sample' + } + }, +]; + +if (process.argv[2]) { + const testNumber = parseInt(process.argv[2], 10); + assert(testNumber >= 0 && testNumber < tests.length); + + const test = tests[testNumber]; + + const clientOptions = { + ...test.clientOptions, + port: process.argv[3], + checkServerIdentity: common.mustCall() + }; + + const client = tls.connect(clientOptions, common.mustCall(() => { + client.end('hi'); + })); +} else { + const serverOptions = { + key: fixtures.readKey('agent3-key.pem'), + cert: fixtures.readKey('agent3-cert.pem') + }; + + for (const testNumber in tests) { + const server = tls.createServer(serverOptions, common.mustCall((socket) => { + socket.end('bye'); + server.close(); + })); + + server.listen(0, common.mustCall(() => { + const env = { + ...process.env, + NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'ca2-cert.pem') + }; + + const args = [ + testNumber, + server.address().port, + ]; + + fork(__filename, args, { env }).on('exit', common.mustCall((status) => { + assert.strictEqual(status, 0); + })); + })); + } +} diff --git a/test/js/node/test/parallel/test-tls-env-extra-ca.js b/test/js/node/test/parallel/test-tls-env-extra-ca.js new file mode 100644 index 000000000000..7ac5ca3c86e5 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-env-extra-ca.js @@ -0,0 +1,46 @@ +// Certs in NODE_EXTRA_CA_CERTS are used for TLS peer validation + +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +const { fork } = require('child_process'); + +if (process.env.CHILD) { + const copts = { + port: process.env.PORT, + checkServerIdentity: common.mustCall(), + }; + const client = tls.connect(copts, common.mustCall(function() { + client.end('hi'); + })); + return; +} + +const options = { + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem'), +}; + +const server = tls.createServer(options, common.mustCall(function(s) { + s.end('bye'); + server.close(); +})).listen(0, common.mustCall(function() { + const env = { + ...process.env, + CHILD: 'yes', + PORT: this.address().port, + NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'ca1-cert.pem') + }; + + fork(__filename, { env }).on('exit', common.mustCall(function(status) { + // Client did not succeed in connecting + assert.strictEqual(status, 0); + })); +})); diff --git a/test/js/node/test/parallel/test-tls-error-servername.js b/test/js/node/test/parallel/test-tls-error-servername.js new file mode 100644 index 000000000000..597b7f29a406 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-error-servername.js @@ -0,0 +1,48 @@ +'use strict'; + +// This tests the errors thrown from TLSSocket.prototype.setServername + +const common = require('../common'); +const fixtures = require('../common/fixtures'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { connect, TLSSocket } = require('tls'); +const { duplexPair } = require('stream'); +const [ clientSide, serverSide ] = duplexPair(); + +const key = fixtures.readKey('agent1-key.pem'); +const cert = fixtures.readKey('agent1-cert.pem'); +const ca = fixtures.readKey('ca1-cert.pem'); + +const client = connect({ + socket: clientSide, + ca, + host: 'agent1' // Hostname from certificate +}); + +[undefined, null, 1, true, {}].forEach((value) => { + assert.throws(() => { + client.setServername(value); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: 'The "name" argument must be of type string.' + + common.invalidArgTypeHelper(value) + }); +}); + +const server = new TLSSocket(serverSide, { + isServer: true, + key, + cert, + ca +}); + +assert.throws(() => { + server.setServername('localhost'); +}, { + code: 'ERR_TLS_SNI_FROM_SERVER', + message: 'Cannot issue SNI from a TLS server-side socket' +}); diff --git a/test/js/node/test/parallel/test-tls-error-stack.js b/test/js/node/test/parallel/test-tls-error-stack.js new file mode 100644 index 000000000000..02021b060ecb --- /dev/null +++ b/test/js/node/test/parallel/test-tls-error-stack.js @@ -0,0 +1,21 @@ +'use strict'; + +// This tests that the crypto error stack can be correctly converted. +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); + +assert.throws(() => { + tls.createSecureContext({ clientCertEngine: 'x' }); +}, (err) => { + if (err.code === 'ERR_CRYPTO_CUSTOM_ENGINE_NOT_SUPPORTED') + common.skip('OpenSSL dropped engine support'); + + return err.name === 'Error' && + /could not load the shared library/.test(err.message) && + Array.isArray(err.opensslErrorStack) && + err.opensslErrorStack.length > 0; +}); diff --git a/test/js/node/test/parallel/test-tls-exportkeyingmaterial.js b/test/js/node/test/parallel/test-tls-exportkeyingmaterial.js new file mode 100644 index 000000000000..5f3281ffc4f8 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-exportkeyingmaterial.js @@ -0,0 +1,102 @@ +'use strict'; + +// Test return value of tlsSocket.exportKeyingMaterial + +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const net = require('net'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +const key = fixtures.readKey('agent1-key.pem'); +const cert = fixtures.readKey('agent1-cert.pem'); + +const server = net.createServer(common.mustCall((s) => { + const tlsSocket = new tls.TLSSocket(s, { + isServer: true, + server: server, + secureContext: tls.createSecureContext({ key, cert }) + }); + + assert.throws(() => { + tlsSocket.exportKeyingMaterial(128, 'label'); + }, { + name: 'Error', + message: 'TLS socket connection must be securely established', + code: 'ERR_TLS_INVALID_STATE' + }); + + tlsSocket.on('secure', common.mustCall(() => { + const label = 'client finished'; + + const validKeyingMaterial = tlsSocket.exportKeyingMaterial(128, label); + assert.strictEqual(validKeyingMaterial.length, 128); + + const validKeyingMaterialWithContext = tlsSocket + .exportKeyingMaterial(128, label, Buffer.from([0, 1, 2, 3])); + assert.strictEqual(validKeyingMaterialWithContext.length, 128); + + // Ensure providing a context results in a different key than without + assert.notStrictEqual(validKeyingMaterial, validKeyingMaterialWithContext); + + const validKeyingMaterialWithEmptyContext = tlsSocket + .exportKeyingMaterial(128, label, Buffer.from([])); + assert.strictEqual(validKeyingMaterialWithEmptyContext.length, 128); + + assert.throws(() => { + tlsSocket.exportKeyingMaterial(128, label, 'stringAsContextNotSupported'); + }, { + name: 'TypeError', + code: 'ERR_INVALID_ARG_TYPE' + }); + + assert.throws(() => { + tlsSocket.exportKeyingMaterial(128, label, 1234); + }, { + name: 'TypeError', + code: 'ERR_INVALID_ARG_TYPE' + }); + + assert.throws(() => { + tlsSocket.exportKeyingMaterial(10, null); + }, { + name: 'TypeError', + code: 'ERR_INVALID_ARG_TYPE' + }); + + assert.throws(() => { + tlsSocket.exportKeyingMaterial('length', 1234); + }, { + name: 'TypeError', + code: 'ERR_INVALID_ARG_TYPE' + }); + + assert.throws(() => { + tlsSocket.exportKeyingMaterial(-3, 'a'); + }, { + name: 'RangeError', + code: 'ERR_OUT_OF_RANGE' + }); + + assert.throws(() => { + tlsSocket.exportKeyingMaterial(0, 'a'); + }, { + name: 'RangeError', + code: 'ERR_OUT_OF_RANGE' + }); + + tlsSocket.end(); + server.close(); + })); +})).listen(0, common.mustCall(() => { + const opts = { + port: server.address().port, + rejectUnauthorized: false + }; + + tls.connect(opts, common.mustCall(function() { this.end(); })); +})); diff --git a/test/js/node/test/parallel/test-tls-fast-writing.js b/test/js/node/test/parallel/test-tls-fast-writing.js index 4718acf28584..e59a1c27ccd1 100644 --- a/test/js/node/test/parallel/test-tls-fast-writing.js +++ b/test/js/node/test/parallel/test-tls-fast-writing.js @@ -37,7 +37,7 @@ let gotChunk = false; let gotDrain = false; function onconnection(conn) { - conn.on('data', function(c) { + conn.on('data', common.mustCall(function(c) { if (!gotChunk) { gotChunk = true; console.log('ok - got chunk'); @@ -49,7 +49,7 @@ function onconnection(conn) { if (gotDrain) process.exit(0); - }); + })); } server.listen(0, function() { diff --git a/test/js/node/test/parallel/test-tls-finished.js b/test/js/node/test/parallel/test-tls-finished.js new file mode 100644 index 000000000000..b23b4567d27e --- /dev/null +++ b/test/js/node/test/parallel/test-tls-finished.js @@ -0,0 +1,68 @@ +'use strict'; + +const common = require('../common'); +const fixtures = require('../common/fixtures'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +// This test ensures that tlsSocket.getFinished() and +// tlsSocket.getPeerFinished() return undefined before +// secure connection is established, and return non-empty +// Buffer objects with Finished messages afterwards, also +// verifying alice.getFinished() == bob.getPeerFinished() +// and alice.getPeerFinished() == bob.getFinished(). + +const assert = require('assert'); +const tls = require('tls'); + +const msg = {}; +const pem = (n) => fixtures.readKey(`${n}.pem`); +const server = tls.createServer({ + key: pem('agent1-key'), + cert: pem('agent1-cert'), + ...(process.features.openssl_is_boringssl ? { maxVersion: 'TLSv1.2' } : {}), +}, common.mustCall((alice) => { + msg.server = { + alice: alice.getFinished(), + bob: alice.getPeerFinished() + }; + server.close(); +})); + +server.listen(0, common.mustCall(() => { + const bob = tls.connect({ + port: server.address().port, + rejectUnauthorized: false, + ...(process.features.openssl_is_boringssl ? { maxVersion: 'TLSv1.2' } : {}), + }, common.mustCall(() => { + msg.client = { + alice: bob.getPeerFinished(), + bob: bob.getFinished() + }; + bob.end(); + })); + + msg.before = { + alice: bob.getPeerFinished(), + bob: bob.getFinished() + }; +})); + +process.on('exit', () => { + assert.strictEqual(undefined, msg.before.alice); + assert.strictEqual(undefined, msg.before.bob); + + assert(Buffer.isBuffer(msg.server.alice)); + assert(Buffer.isBuffer(msg.server.bob)); + assert(Buffer.isBuffer(msg.client.alice)); + assert(Buffer.isBuffer(msg.client.bob)); + + assert(msg.server.alice.length > 0); + assert(msg.server.bob.length > 0); + assert(msg.client.alice.length > 0); + assert(msg.client.bob.length > 0); + + assert(msg.server.alice.equals(msg.client.alice)); + assert(msg.server.bob.equals(msg.client.bob)); +}); diff --git a/test/js/node/test/parallel/test-tls-get-ca-certificates-system-without-flag.js b/test/js/node/test/parallel/test-tls-get-ca-certificates-system-without-flag.js new file mode 100644 index 000000000000..026e44fcaeda --- /dev/null +++ b/test/js/node/test/parallel/test-tls-get-ca-certificates-system-without-flag.js @@ -0,0 +1,36 @@ +'use strict'; + +// This tests that tls.getCACertificates() returns the system +// certificates correctly when --use-system-ca is disabled. + +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +const tmpdir = require('../common/tmpdir'); +const fs = require('fs'); + +const assert = require('assert'); +const { spawnSyncAndExitWithoutError } = require('../common/child_process'); +const fixtures = require('../common/fixtures'); +const tls = require('tls'); + +const certs = tls.getCACertificates('system'); +if (certs.length === 0) { + common.skip('No trusted system certificates installed. Skip.'); +} + +tmpdir.refresh(); +const certsJSON = tmpdir.resolve('certs.json'); +spawnSyncAndExitWithoutError(process.execPath, [ + '--no-use-system-ca', + fixtures.path('tls-get-ca-certificates.js'), +], { + env: { + ...process.env, + CA_TYPE: 'system', + CA_OUT: certsJSON, + } +}); + +const parsed = JSON.parse(fs.readFileSync(certsJSON, 'utf-8')); +assert.deepStrictEqual(parsed, certs); diff --git a/test/js/node/test/parallel/test-tls-getcertificate-x509.js b/test/js/node/test/parallel/test-tls-getcertificate-x509.js new file mode 100644 index 000000000000..704aa33e6edf --- /dev/null +++ b/test/js/node/test/parallel/test-tls-getcertificate-x509.js @@ -0,0 +1,38 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); +const { X509Certificate } = require('crypto'); + +const options = { + key: fixtures.readKey('agent6-key.pem'), + cert: fixtures.readKey('agent6-cert.pem') +}; + +const server = tls.createServer(options, function(cleartext) { + cleartext.end('World'); +}); + +server.once('secureConnection', common.mustCall(function(socket) { + const cert = socket.getX509Certificate(); + assert(cert instanceof X509Certificate); + assert.match(cert.serialNumber, /5B75D77EDC7FB5B7FA9F1424DA4C64FB815DCBDE/i); +})); + +server.listen(0, common.mustCall(function() { + const socket = tls.connect({ + port: this.address().port, + rejectUnauthorized: false + }, common.mustCall(function() { + const peerCert = socket.getPeerX509Certificate(); + assert(peerCert.issuerCertificate instanceof X509Certificate); + assert.strictEqual(peerCert.issuerCertificate.issuerCertificate, undefined); + assert.match(peerCert.issuerCertificate.serialNumber, /147D36C1C2F74206DE9FAB5F2226D78ADB00A425/i); + server.close(); + })); + socket.end('Hello'); +})); diff --git a/test/js/node/test/parallel/test-tls-getprotocol.js b/test/js/node/test/parallel/test-tls-getprotocol.js new file mode 100644 index 000000000000..2945ff99b5a2 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-getprotocol.js @@ -0,0 +1,68 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const { hasOpenSSL } = require('../common/crypto'); + +// This test ensures that `getProtocol` returns the right protocol +// from a TLS connection + +const assert = require('assert'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +let clientConfigs = [ + { + secureProtocol: 'TLSv1_method', + version: 'TLSv1', + ciphers: (hasOpenSSL(3, 1) ? 'DEFAULT:@SECLEVEL=0' : 'DEFAULT') + }, { + secureProtocol: 'TLSv1_1_method', + version: 'TLSv1.1', + ciphers: (hasOpenSSL(3, 1) ? 'DEFAULT:@SECLEVEL=0' : 'DEFAULT') + }, { + secureProtocol: 'TLSv1_2_method', + version: 'TLSv1.2' + }, +]; + +if (process.features.openssl_is_boringssl) { + // Remove the TLSv1 and TLSv1.1 cases. BoringSSL does not negotiate those + // legacy protocols in this configuration; keep TLSv1.2 to cover getProtocol() + // on a successful BoringSSL TLS handshake. + common.printSkipMessage('BoringSSL: skipping TLSv1/TLSv1.1 getProtocol cases'); + clientConfigs = clientConfigs.filter(({ version }) => version === 'TLSv1.2'); +} + +const serverConfig = { + secureProtocol: 'TLS_method', + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem') +}; + +if (!process.features.openssl_is_boringssl) { + serverConfig.ciphers = 'RSA@SECLEVEL=0'; +} + +const server = tls.createServer(serverConfig, common.mustCall(clientConfigs.length)) +.listen(0, common.localhostIPv4, common.mustCall(function() { + let connected = 0; + for (const v of clientConfigs) { + tls.connect({ + host: common.localhostIPv4, + port: server.address().port, + ciphers: v.ciphers, + rejectUnauthorized: false, + secureProtocol: v.secureProtocol + }, 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); + })).end(); + if (++connected === clientConfigs.length) + server.close(); + })); + } +})); diff --git a/test/js/node/test/parallel/test-tls-invalid-pfx.js b/test/js/node/test/parallel/test-tls-invalid-pfx.js new file mode 100644 index 000000000000..c16858f0f788 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-invalid-pfx.js @@ -0,0 +1,23 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const fixtures = require('../common/fixtures'); + +const { + assert, connect, keys +} = require(fixtures.path('tls-connect')); + +const invalidPfx = fixtures.readKey('cert-without-key.pfx'); + +connect({ + client: { + pfx: invalidPfx, + passphrase: 'test', + rejectUnauthorized: false + }, + server: keys.agent1 +}, common.mustCall((e, pair, cleanup) => { + assert.strictEqual(e.message, 'Unable to load private key from PFX data'); + cleanup(); +})); diff --git a/test/js/node/test/parallel/test-tls-ip-servername-forbidden.js b/test/js/node/test/parallel/test-tls-ip-servername-forbidden.js new file mode 100644 index 000000000000..646029501411 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-ip-servername-forbidden.js @@ -0,0 +1,18 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const tls = require('tls'); + +// Verify that passing an IP address the the servername option +// throws an error. +assert.throws(() => tls.connect({ + port: 1234, + servername: '127.0.0.1', +}, common.mustNotCall()), { + code: 'ERR_INVALID_ARG_VALUE', +}); diff --git a/test/js/node/test/parallel/test-tls-js-stream.js b/test/js/node/test/parallel/test-tls-js-stream.js new file mode 100644 index 000000000000..298252962ba9 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-js-stream.js @@ -0,0 +1,66 @@ +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const fixtures = require('../common/fixtures'); + +const net = require('net'); +const stream = require('stream'); +const tls = require('tls'); + +const server = tls.createServer({ + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem') +}, common.mustCall(function(c) { + console.log('new client'); + + c.resume(); + c.end('ohai'); +})).listen(0, common.mustCall(function() { + const raw = net.connect(this.address().port); + + let pending = false; + raw.on('readable', function() { + if (pending) + p._read(); + }); + + raw.on('end', function() { + p.push(null); + }); + + const p = new stream.Duplex({ + read: function read() { + pending = false; + + const chunk = raw.read(); + if (chunk) { + console.log('read', chunk); + this.push(chunk); + } else { + pending = true; + } + }, + write: function write(data, enc, cb) { + console.log('write', data, enc); + raw.write(data, enc, cb); + } + }); + + const socket = tls.connect({ + socket: p, + rejectUnauthorized: false + }, common.mustCall(function() { + console.log('client secure'); + + socket.resume(); + socket.end('hello'); + })); + + socket.once('close', function() { + console.log('client close'); + server.close(); + }); +})); diff --git a/test/js/node/test/parallel/test-tls-key-mismatch.js b/test/js/node/test/parallel/test-tls-key-mismatch.js new file mode 100644 index 000000000000..797c7c171dc5 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-key-mismatch.js @@ -0,0 +1,47 @@ +// 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. + +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) { + common.skip('missing crypto'); +} + +const fixtures = require('../common/fixtures'); +const { hasOpenSSL3 } = require('../common/crypto'); + +const assert = require('assert'); +const tls = require('tls'); +const errorMessageRegex = process.features.openssl_is_boringssl ? + /^Error: error:0b000074:X\.509 certificate routines:OPENSSL_internal:KEY_VALUES_MISMATCH$/ : + hasOpenSSL3 ? + /^Error: error:05800074:x509 certificate routines::key values mismatch$/ : + /^Error: error:0B080074:x509 certificate routines:X509_check_private_key:key values mismatch$/; + +const options = { + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent2-cert.pem') +}; + +assert.throws(function() { + tls.createSecureContext(options); +}, errorMessageRegex); diff --git a/test/js/node/test/parallel/test-tls-keylog-tlsv13.js b/test/js/node/test/parallel/test-tls-keylog-tlsv13.js new file mode 100644 index 000000000000..0ee20496c964 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-keylog-tlsv13.js @@ -0,0 +1,36 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +const server = tls.createServer({ + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem'), + // Amount of keylog events depends on negotiated protocol + // version, so force a specific one: + minVersion: 'TLSv1.3', + maxVersion: 'TLSv1.3', +}).listen(common.mustCall(() => { + const client = tls.connect({ + port: server.address().port, + rejectUnauthorized: false, + }); + + server.on('keylog', common.mustCall((line, tlsSocket) => { + assert(Buffer.isBuffer(line)); + assert.strictEqual(tlsSocket.encrypted, true); + }, 5)); + client.on('keylog', common.mustCall((line) => { + assert(Buffer.isBuffer(line)); + }, 5)); + + client.once('secureConnect', () => { + server.close(); + client.end(); + }); +})); diff --git a/test/js/node/test/parallel/test-tls-min-max-version.js b/test/js/node/test/parallel/test-tls-min-max-version.js new file mode 100644 index 000000000000..abddbbeb0eba --- /dev/null +++ b/test/js/node/test/parallel/test-tls-min-max-version.js @@ -0,0 +1,287 @@ +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) { + common.skip('missing crypto'); +} + +if (process.features.openssl_is_boringssl) { + require('../common/boringssl').testLegacyProtocolUnsupported(); + return; +} + +const { + hasOpenSSL, + hasOpenSSL3, +} = require('../common/crypto'); +const fixtures = require('../common/fixtures'); +const { inspect } = require('util'); + +// Check min/max protocol versions. + +const { + assert, connect, keys, tls +} = require(fixtures.path('tls-connect')); +const DEFAULT_MIN_VERSION = tls.DEFAULT_MIN_VERSION; +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'); + + let ciphers; + if (hasOpenSSL3 && (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') + ciphers = 'ALL@SECLEVEL=0'; + } + if (hasOpenSSL(3, 1) && cerr === 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION') { + ciphers = 'DEFAULT@SECLEVEL=0'; + } + // Report where test was called from. Strip leading garbage from + // at Object. (file:line) + // from the stack location, we only want the file:line part. + const where = inspect(new Error()).split('\n')[2].replace(/[^(]*/, ''); + connect({ + client: { + checkServerIdentity: (servername, cert) => { }, + ca: `${keys.agent1.cert}\n${keys.agent6.ca}`, + minVersion: cmin, + maxVersion: cmax, + secureProtocol: cprot, + ciphers: ciphers + }, + server: { + cert: keys.agent6.cert, + key: keys.agent6.key, + minVersion: smin, + maxVersion: smax, + secureProtocol: sprot, + ciphers: ciphers + }, + }, common.mustCall((err, pair, cleanup) => { + function u(_) { return _ === undefined ? 'U' : _; } + 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 (!proto) { + console.log('client', pair.client.err ? pair.client.err.code : undefined); + console.log('server', pair.server.err ? pair.server.err.code : undefined); + if (cerr) { + assert(pair.client.err); + // Accept these codes as aliases, the one reported depends on the + // OpenSSL version. + if (cerr === 'ERR_SSL_UNSUPPORTED_PROTOCOL' && + pair.client.err.code === 'ERR_SSL_VERSION_TOO_LOW') + cerr = 'ERR_SSL_VERSION_TOO_LOW'; + assert.strictEqual(pair.client.err.code, cerr); + } + if (serr) { + assert(pair.server.err); + assert.strictEqual(pair.server.err.code, serr); + } + return cleanup(); + } + + assert.ifError(err); + assert.ifError(pair.server.err); + assert.ifError(pair.client.err); + assert(pair.server.conn); + assert(pair.client.conn); + assert.strictEqual(pair.client.conn.getProtocol(), proto); + assert.strictEqual(pair.server.conn.getProtocol(), proto); + return cleanup(); + })); +} + +const U = undefined; + +// Default protocol is the max version. +test(U, U, U, U, U, U, DEFAULT_MAX_VERSION); + +// Insecure or invalid protocols cannot be enabled. +test(U, U, U, U, U, 'SSLv2_method', + U, U, 'ERR_TLS_INVALID_PROTOCOL_METHOD'); +test(U, U, U, U, U, 'SSLv3_method', + U, U, 'ERR_TLS_INVALID_PROTOCOL_METHOD'); +test(U, U, 'SSLv2_method', U, U, U, + U, 'ERR_TLS_INVALID_PROTOCOL_METHOD'); +test(U, U, 'SSLv3_method', U, U, U, + U, 'ERR_TLS_INVALID_PROTOCOL_METHOD'); +test(U, U, 'hokey-pokey', U, U, U, + U, 'ERR_TLS_INVALID_PROTOCOL_METHOD'); +test(U, U, U, U, U, 'hokey-pokey', + U, U, 'ERR_TLS_INVALID_PROTOCOL_METHOD'); + +// Regression test: this should not crash because node should not pass the error +// message (including unsanitized user input) to a printf-like function. +test(U, U, U, U, U, '%s_method', + U, U, 'ERR_TLS_INVALID_PROTOCOL_METHOD'); + +// Cannot use secureProtocol and min/max versions simultaneously. +test(U, U, U, U, 'TLSv1.2', 'TLS1_2_method', + U, U, 'ERR_TLS_PROTOCOL_VERSION_CONFLICT'); +test(U, U, U, 'TLSv1.2', U, 'TLS1_2_method', + U, U, 'ERR_TLS_PROTOCOL_VERSION_CONFLICT'); +test(U, 'TLSv1.2', 'TLS1_2_method', U, U, U, + U, 'ERR_TLS_PROTOCOL_VERSION_CONFLICT'); +test('TLSv1.2', U, 'TLS1_2_method', U, U, U, + U, 'ERR_TLS_PROTOCOL_VERSION_CONFLICT'); + +// TLS_method means "any supported protocol". +test(U, U, 'TLSv1_2_method', U, U, 'TLS_method', 'TLSv1.2'); +test(U, U, 'TLSv1_1_method', U, U, 'TLS_method', 'TLSv1.1'); +test(U, U, 'TLSv1_method', U, U, 'TLS_method', 'TLSv1'); +test(U, U, 'TLS_method', U, U, 'TLSv1_2_method', 'TLSv1.2'); +test(U, U, 'TLS_method', U, U, 'TLSv1_1_method', 'TLSv1.1'); +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 ? + 'ERR_SSL_NO_PROTOCOLS_AVAILABLE' : 'ERR_SSL_INTERNAL_ERROR'; +const NO_PROTOCOLS_AVAILABLE_SERVER_ALERT = hasOpenSSL3 ? + 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION' : 'ERR_SSL_TLSV1_ALERT_INTERNAL_ERROR'; + +// SSLv23 also means "any supported protocol" greater than the default +// minimum (which is configurable via command line). +if (DEFAULT_MIN_VERSION === 'TLSv1.3') { + test(U, U, 'TLSv1_2_method', U, U, 'SSLv23_method', + U, NO_PROTOCOLS_AVAILABLE_SERVER_ALERT, NO_PROTOCOLS_AVAILABLE_SERVER); +} else { + test(U, U, 'TLSv1_2_method', U, U, 'SSLv23_method', 'TLSv1.2'); +} + +if (DEFAULT_MIN_VERSION === 'TLSv1.3') { + test(U, U, 'TLSv1_1_method', U, U, 'SSLv23_method', + U, NO_PROTOCOLS_AVAILABLE_SERVER_ALERT, NO_PROTOCOLS_AVAILABLE_SERVER); + test(U, U, 'TLSv1_method', U, U, 'SSLv23_method', + U, NO_PROTOCOLS_AVAILABLE_SERVER_ALERT, NO_PROTOCOLS_AVAILABLE_SERVER); + test(U, U, 'SSLv23_method', U, U, 'TLSv1_1_method', + U, 'ERR_SSL_NO_PROTOCOLS_AVAILABLE', 'ERR_SSL_UNEXPECTED_MESSAGE'); + test(U, U, 'SSLv23_method', U, U, 'TLSv1_method', + U, 'ERR_SSL_NO_PROTOCOLS_AVAILABLE', 'ERR_SSL_UNEXPECTED_MESSAGE'); +} + +if (DEFAULT_MIN_VERSION === 'TLSv1.2') { + test(U, U, 'TLSv1_1_method', U, U, 'SSLv23_method', + U, 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION', + 'ERR_SSL_UNSUPPORTED_PROTOCOL'); + test(U, U, 'TLSv1_method', U, U, 'SSLv23_method', + U, 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION', + 'ERR_SSL_UNSUPPORTED_PROTOCOL'); + test(U, U, 'SSLv23_method', U, U, 'TLSv1_1_method', + U, 'ERR_SSL_UNSUPPORTED_PROTOCOL', 'ERR_SSL_WRONG_VERSION_NUMBER'); + test(U, U, 'SSLv23_method', U, U, 'TLSv1_method', + U, 'ERR_SSL_UNSUPPORTED_PROTOCOL', 'ERR_SSL_WRONG_VERSION_NUMBER'); +} + +if (DEFAULT_MIN_VERSION === 'TLSv1.1') { + test(U, U, 'TLSv1_1_method', U, U, 'SSLv23_method', 'TLSv1.1'); + test(U, U, 'TLSv1_method', U, U, 'SSLv23_method', + U, 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION', + 'ERR_SSL_UNSUPPORTED_PROTOCOL'); + test(U, U, 'SSLv23_method', U, U, 'TLSv1_1_method', 'TLSv1.1'); + test(U, U, 'SSLv23_method', U, U, 'TLSv1_method', + U, 'ERR_SSL_UNSUPPORTED_PROTOCOL', 'ERR_SSL_WRONG_VERSION_NUMBER'); +} + +if (DEFAULT_MIN_VERSION === 'TLSv1') { + test(U, U, 'TLSv1_1_method', U, U, 'SSLv23_method', 'TLSv1.1'); + test(U, U, 'TLSv1_method', U, U, 'SSLv23_method', 'TLSv1'); + test(U, U, 'SSLv23_method', U, U, 'TLSv1_1_method', 'TLSv1.1'); + test(U, U, 'SSLv23_method', U, U, 'TLSv1_method', 'TLSv1'); +} + +// TLSv1 thru TLSv1.2 are only supported with explicit configuration with API or +// CLI (--tls-v1.0 and --tls-v1.1). +test(U, U, 'TLSv1_2_method', U, U, 'TLSv1_2_method', 'TLSv1.2'); +test(U, U, 'TLSv1_1_method', U, U, 'TLSv1_1_method', 'TLSv1.1'); +test(U, U, 'TLSv1_method', U, U, 'TLSv1_method', 'TLSv1'); + +// The default default. +if (DEFAULT_MIN_VERSION === 'TLSv1.2') { + test(U, U, 'TLSv1_1_method', U, U, U, + U, 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION', + 'ERR_SSL_UNSUPPORTED_PROTOCOL'); + test(U, U, 'TLSv1_method', U, U, U, + U, 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION', + 'ERR_SSL_UNSUPPORTED_PROTOCOL'); + + if (DEFAULT_MAX_VERSION === 'TLSv1.2') { + test(U, U, U, U, U, 'TLSv1_1_method', + U, 'ERR_SSL_UNSUPPORTED_PROTOCOL', 'ERR_SSL_WRONG_VERSION_NUMBER'); + test(U, U, U, U, U, 'TLSv1_method', + U, 'ERR_SSL_UNSUPPORTED_PROTOCOL', 'ERR_SSL_WRONG_VERSION_NUMBER'); + } else { + // TLS1.3 client hellos are are not understood by TLS1.1 or below. + test(U, U, U, U, U, 'TLSv1_1_method', + U, 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION', + 'ERR_SSL_UNSUPPORTED_PROTOCOL'); + test(U, U, U, U, U, 'TLSv1_method', + U, 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION', + 'ERR_SSL_UNSUPPORTED_PROTOCOL'); + } +} + +// The default with --tls-v1.1. +if (DEFAULT_MIN_VERSION === 'TLSv1.1') { + test(U, U, 'TLSv1_1_method', U, U, U, 'TLSv1.1'); + test(U, U, 'TLSv1_method', U, U, U, + U, 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION', + 'ERR_SSL_UNSUPPORTED_PROTOCOL'); + test(U, U, U, U, U, 'TLSv1_1_method', 'TLSv1.1'); + + if (DEFAULT_MAX_VERSION === 'TLSv1.2') { + test(U, U, U, U, U, 'TLSv1_method', + U, 'ERR_SSL_UNSUPPORTED_PROTOCOL', 'ERR_SSL_WRONG_VERSION_NUMBER'); + } else { + // TLS1.3 client hellos are are not understood by TLS1.1 or below. + test(U, U, U, U, U, 'TLSv1_method', + U, 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION', + 'ERR_SSL_UNSUPPORTED_PROTOCOL'); + } +} + +// The default with --tls-v1.0. +if (DEFAULT_MIN_VERSION === 'TLSv1') { + test(U, U, 'TLSv1_1_method', U, U, U, 'TLSv1.1'); + test(U, U, 'TLSv1_method', U, U, U, 'TLSv1'); + test(U, U, U, U, U, 'TLSv1_1_method', 'TLSv1.1'); + test(U, U, U, U, U, 'TLSv1_method', 'TLSv1'); +} + +// TLS min/max are respected when set with no secureProtocol. +test('TLSv1', 'TLSv1.2', U, U, U, 'TLSv1_method', 'TLSv1'); +test('TLSv1', 'TLSv1.2', U, U, U, 'TLSv1_1_method', 'TLSv1.1'); +test('TLSv1', 'TLSv1.2', U, U, U, 'TLSv1_2_method', 'TLSv1.2'); +test('TLSv1', 'TLSv1.2', U, U, U, 'TLS_method', 'TLSv1.2'); + +test(U, U, 'TLSv1_method', 'TLSv1', 'TLSv1.2', U, 'TLSv1'); +test(U, U, 'TLSv1_1_method', 'TLSv1', 'TLSv1.2', U, 'TLSv1.1'); +test(U, U, 'TLSv1_2_method', 'TLSv1', 'TLSv1.2', U, 'TLSv1.2'); + +test('TLSv1', 'TLSv1.1', U, 'TLSv1', 'TLSv1.3', U, 'TLSv1.1'); +test('TLSv1', 'TLSv1.1', U, 'TLSv1', 'TLSv1.2', U, 'TLSv1.1'); +test('TLSv1', 'TLSv1.2', U, 'TLSv1', 'TLSv1.1', U, 'TLSv1.1'); +test('TLSv1', 'TLSv1.3', U, 'TLSv1', 'TLSv1.1', U, 'TLSv1.1'); +test('TLSv1', 'TLSv1', U, 'TLSv1', 'TLSv1.1', U, 'TLSv1'); +test('TLSv1', 'TLSv1.2', U, 'TLSv1', 'TLSv1', U, 'TLSv1'); +test('TLSv1', 'TLSv1.3', U, 'TLSv1', 'TLSv1', U, 'TLSv1'); +test('TLSv1.1', 'TLSv1.1', U, 'TLSv1', 'TLSv1.2', U, 'TLSv1.1'); +test('TLSv1', 'TLSv1.2', U, 'TLSv1.1', 'TLSv1.1', U, 'TLSv1.1'); +test('TLSv1', 'TLSv1.2', U, 'TLSv1', 'TLSv1.3', U, 'TLSv1.2'); + +// v-any client can connect to v-specific server +test('TLSv1', 'TLSv1.3', U, 'TLSv1.3', 'TLSv1.3', U, 'TLSv1.3'); +test('TLSv1', 'TLSv1.3', U, 'TLSv1.2', 'TLSv1.3', U, 'TLSv1.3'); +test('TLSv1', 'TLSv1.3', U, 'TLSv1.2', 'TLSv1.2', U, 'TLSv1.2'); +test('TLSv1', 'TLSv1.3', U, 'TLSv1.1', 'TLSv1.1', U, 'TLSv1.1'); +test('TLSv1', 'TLSv1.3', U, 'TLSv1', 'TLSv1', U, 'TLSv1'); + +// v-specific client can connect to v-any server +test('TLSv1.3', 'TLSv1.3', U, 'TLSv1', 'TLSv1.3', U, 'TLSv1.3'); +test('TLSv1.2', 'TLSv1.2', U, 'TLSv1', 'TLSv1.3', U, 'TLSv1.2'); +test('TLSv1.1', 'TLSv1.1', U, 'TLSv1', 'TLSv1.3', U, 'TLSv1.1'); +test('TLSv1', 'TLSv1', U, 'TLSv1', 'TLSv1.3', U, 'TLSv1'); diff --git a/test/js/node/test/parallel/test-tls-multi-key.js b/test/js/node/test/parallel/test-tls-multi-key.js new file mode 100644 index 000000000000..0a9c6f108bf6 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-multi-key.js @@ -0,0 +1,196 @@ +// 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. + +'use strict'; +const common = require('../common'); + +// Test multi-identity ('key')/multi-algorithm scenarios. + +if (!common.hasCrypto) + common.skip('missing crypto'); + +if (process.features.openssl_is_boringssl) { + require('../common/boringssl').assertMultiKeyUnsupported(); + return; +} + +const fixtures = require('../common/fixtures'); +const assert = require('assert'); +const tls = require('tls'); + +// Key is ordered as ec, rsa, cert is ordered as rsa, ec. +test({ + key: [ + fixtures.readKey('ec10-key.pem'), + fixtures.readKey('agent1-key.pem'), + ], + cert: [ + fixtures.readKey('agent1-cert.pem'), + fixtures.readKey('ec10-cert.pem'), + ], + eccCN: 'agent10.example.com', + client: { ca: [ + fixtures.readKey('ca5-cert.pem'), + fixtures.readKey('ca1-cert.pem'), + ] }, +}); + +// Key and cert are ordered as ec, rsa. +test({ + key: [ + fixtures.readKey('ec10-key.pem'), + fixtures.readKey('agent1-key.pem'), + ], + cert: [ + fixtures.readKey('agent1-cert.pem'), + fixtures.readKey('ec10-cert.pem'), + ], + eccCN: 'agent10.example.com', + client: { ca: [ + fixtures.readKey('ca5-cert.pem'), + fixtures.readKey('ca1-cert.pem'), + ] }, +}); + +// Key, cert, and pfx options can be used simultaneously. +test({ + key: [ + fixtures.readKey('ec-key.pem'), + ], + cert: [ + fixtures.readKey('ec-cert.pem'), + ], + pfx: fixtures.readKey('agent1.pfx'), + passphrase: 'sample', + client: { ca: [ + fixtures.readKey('ec-cert.pem'), + fixtures.readKey('ca1-cert.pem'), + ] }, +}); + +// Key and cert with mixed algorithms, and cert chains with intermediate CAs +test({ + key: [ + fixtures.readKey('ec10-key.pem'), + fixtures.readKey('agent10-key.pem'), + ], + cert: [ + fixtures.readKey('agent10-cert.pem'), + fixtures.readKey('ec10-cert.pem'), + ], + rsaCN: 'agent10.example.com', + eccCN: 'agent10.example.com', + client: { ca: [ + fixtures.readKey('ca2-cert.pem'), + fixtures.readKey('ca5-cert.pem'), + ] }, +}); + +// Key and cert with mixed algorithms, and cert chains with intermediate CAs, +// using PFX for EC. +test({ + key: [ + fixtures.readKey('agent10-key.pem'), + ], + cert: [ + fixtures.readKey('agent10-cert.pem'), + ], + pfx: fixtures.readKey('ec10.pfx'), + passphrase: 'sample', + rsaCN: 'agent10.example.com', + eccCN: 'agent10.example.com', + client: { ca: [ + fixtures.readKey('ca2-cert.pem'), + fixtures.readKey('ca5-cert.pem'), + ] }, +}); + +// Key and cert with mixed algorithms, and cert chains with intermediate CAs, +// using PFX for RSA. +test({ + key: [ + fixtures.readKey('ec10-key.pem'), + ], + cert: [ + fixtures.readKey('ec10-cert.pem'), + ], + pfx: fixtures.readKey('agent10.pfx'), + passphrase: 'sample', + rsaCN: 'agent10.example.com', + eccCN: 'agent10.example.com', + client: { ca: [ + fixtures.readKey('ca2-cert.pem'), + fixtures.readKey('ca5-cert.pem'), + ] }, +}); + +function test(options) { + const rsaCN = options.rsaCN || 'agent1'; + const eccCN = options.eccCN || 'agent2'; + const clientTrustRoots = options.client.ca; + delete options.rsaCN; + delete options.eccCN; + delete options.client; + const server = tls.createServer(options, function(conn) { + conn.end('ok'); + }).listen(0, common.mustCall(connectWithEcdsa)); + + function connectWithEcdsa() { + const ecdsa = tls.connect(this.address().port, { + ciphers: 'ECDHE-ECDSA-AES256-GCM-SHA384', + rejectUnauthorized: true, + ca: clientTrustRoots, + checkServerIdentity: common.mustCall((_, c) => assert.strictEqual(c.subject.CN, eccCN)), + maxVersion: 'TLSv1.2', + }, common.mustCall(function() { + assert.deepStrictEqual(ecdsa.getCipher(), { + name: 'ECDHE-ECDSA-AES256-GCM-SHA384', + standardName: 'TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384', + version: 'TLSv1.2', + }); + + assert.strictEqual(ecdsa.getPeerCertificate().subject.CN, eccCN); + assert.strictEqual(ecdsa.getPeerCertificate().asn1Curve, 'prime256v1'); + ecdsa.end(); + connectWithRsa(); + })); + } + + function connectWithRsa() { + const rsa = tls.connect(server.address().port, { + ciphers: 'ECDHE-RSA-AES256-GCM-SHA384', + rejectUnauthorized: true, + ca: clientTrustRoots, + checkServerIdentity: common.mustCallAtLeast((_, c) => assert.strictEqual(c.subject.CN, rsaCN)), + maxVersion: 'TLSv1.2', + }, common.mustCall(function() { + assert.deepStrictEqual(rsa.getCipher(), { + name: 'ECDHE-RSA-AES256-GCM-SHA384', + standardName: 'TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384', + version: 'TLSv1.2', + }); + assert.strictEqual(rsa.getPeerCertificate().subject.CN, rsaCN); + assert(rsa.getPeerCertificate().exponent, 'cert for an RSA key'); + rsa.end(); + server.close(); + })); + } +} diff --git a/test/js/node/test/parallel/test-tls-net-socket-keepalive-12.js b/test/js/node/test/parallel/test-tls-net-socket-keepalive-12.js new file mode 100644 index 000000000000..d2fb230796e5 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-net-socket-keepalive-12.js @@ -0,0 +1,13 @@ +'use strict'; + +// test-tls-net-socket-keepalive specifically for TLS1.2. + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const tls = require('tls'); + +tls.DEFAULT_MAX_VERSION = 'TLSv1.2'; + +require('./test-tls-net-socket-keepalive.js'); diff --git a/test/js/node/test/parallel/test-tls-net-socket-keepalive.js b/test/js/node/test/parallel/test-tls-net-socket-keepalive.js new file mode 100644 index 000000000000..4acb4e80224e --- /dev/null +++ b/test/js/node/test/parallel/test-tls-net-socket-keepalive.js @@ -0,0 +1,57 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const fixtures = require('../common/fixtures'); +const tls = require('tls'); +const net = require('net'); + +// This test ensures that when tls sockets are created with `allowHalfOpen`, +// they won't hang. +const key = fixtures.readKey('agent1-key.pem'); +const cert = fixtures.readKey('agent1-cert.pem'); +const ca = fixtures.readKey('ca1-cert.pem'); +const options = { + key, + cert, + ca: [ca], +}; + +const server = tls.createServer(options, common.mustCall((conn) => { + conn.write('hello', common.mustCall()); + conn.on('data', common.mustCall()); + conn.on('end', common.mustCall()); + conn.on('data', common.mustCall()); + conn.on('close', common.mustCall()); + conn.end(); +})).listen(0, common.mustCall(() => { + const netSocket = new net.Socket({ + allowHalfOpen: true, + }); + + const socket = tls.connect({ + socket: netSocket, + rejectUnauthorized: false, + }); + + const { port, address } = server.address(); + + // Doing `net.Socket.connect()` after `tls.connect()` will make tls module + // wrap the socket in StreamWrap. + netSocket.connect({ + port, + address, + }); + + socket.on('secureConnect', common.mustCall()); + socket.on('end', common.mustCall()); + socket.on('data', common.mustCall()); + socket.on('close', common.mustCall(() => { + server.close(); + })); + + socket.write('hello'); + socket.end(); +})); diff --git a/test/js/node/test/parallel/test-tls-no-cert-required.js b/test/js/node/test/parallel/test-tls-no-cert-required.js new file mode 100644 index 000000000000..499ab2dfd14e --- /dev/null +++ b/test/js/node/test/parallel/test-tls-no-cert-required.js @@ -0,0 +1,62 @@ +// 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. + +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); + +// Omitting the cert or pfx option to tls.createServer() should not throw. +if (process.features.openssl_is_boringssl) { + // AECDH-NULL-SHA is a no-authentication/no-encryption cipher and hence + // does not need a certificate. BoringSSL does not provide that anonymous + // cipher suite, so only this cipher-specific no-cert case is skipped. + common.printSkipMessage('BoringSSL: skipping anonymous AECDH-NULL-SHA case'); +} else { + tls.createServer({ ciphers: 'AECDH-NULL-SHA' }) + .listen(0, common.mustCall(close)); +} + +tls.createServer(assert.fail) + .listen(0, common.mustCall(close)); + +tls.createServer({}) + .listen(0, common.mustCall(close)); + +assert.throws( + () => tls.createServer('this is not valid'), + { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: 'The "options" argument must be of type object. ' + + "Received type string ('this is not valid')" + } +); + +tls.createServer() + .listen(0, common.mustCall(close)); + +function close() { + this.close(); +} diff --git a/test/js/node/test/parallel/test-tls-no-sslv23.js b/test/js/node/test/parallel/test-tls-no-sslv23.js new file mode 100644 index 000000000000..f1ba670ff076 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-no-sslv23.js @@ -0,0 +1,58 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); + +assert.throws(function() { + tls.createSecureContext({ secureProtocol: 'blargh' }); +}, { + code: 'ERR_TLS_INVALID_PROTOCOL_METHOD', + message: 'Unknown method: blargh', +}); + +const errMessageSSLv2 = /SSLv2 methods disabled/; + +assert.throws(function() { + tls.createSecureContext({ secureProtocol: 'SSLv2_method' }); +}, errMessageSSLv2); + +assert.throws(function() { + tls.createSecureContext({ secureProtocol: 'SSLv2_client_method' }); +}, errMessageSSLv2); + +assert.throws(function() { + tls.createSecureContext({ secureProtocol: 'SSLv2_server_method' }); +}, errMessageSSLv2); + +const errMessageSSLv3 = /SSLv3 methods disabled/; + +assert.throws(function() { + tls.createSecureContext({ secureProtocol: 'SSLv3_method' }); +}, errMessageSSLv3); + +assert.throws(function() { + tls.createSecureContext({ secureProtocol: 'SSLv3_client_method' }); +}, errMessageSSLv3); + +assert.throws(function() { + tls.createSecureContext({ secureProtocol: 'SSLv3_server_method' }); +}, errMessageSSLv3); + +// Note that SSLv2 and SSLv3 are disallowed but SSLv2_method and friends are +// still accepted. They are OpenSSL's way of saying that all known protocols +// are supported unless explicitly disabled (which we do for SSLv2 and SSLv3.) +tls.createSecureContext({ secureProtocol: 'SSLv23_method' }); +tls.createSecureContext({ secureProtocol: 'SSLv23_client_method' }); +tls.createSecureContext({ secureProtocol: 'SSLv23_server_method' }); +tls.createSecureContext({ secureProtocol: 'TLSv1_method' }); +tls.createSecureContext({ secureProtocol: 'TLSv1_client_method' }); +tls.createSecureContext({ secureProtocol: 'TLSv1_server_method' }); +tls.createSecureContext({ secureProtocol: 'TLSv1_1_method' }); +tls.createSecureContext({ secureProtocol: 'TLSv1_1_client_method' }); +tls.createSecureContext({ secureProtocol: 'TLSv1_1_server_method' }); +tls.createSecureContext({ secureProtocol: 'TLSv1_2_method' }); +tls.createSecureContext({ secureProtocol: 'TLSv1_2_client_method' }); +tls.createSecureContext({ secureProtocol: 'TLSv1_2_server_method' }); diff --git a/test/js/node/test/parallel/test-tls-off-thread-cert-loading-disabled.js b/test/js/node/test/parallel/test-tls-off-thread-cert-loading-disabled.js new file mode 100644 index 000000000000..c2e466fcae1d --- /dev/null +++ b/test/js/node/test/parallel/test-tls-off-thread-cert-loading-disabled.js @@ -0,0 +1,40 @@ +'use strict'; +// This tests that when --use-openssl-ca is specified, no off-thread cert loading happens. + +const common = require('../common'); +if (!common.hasCrypto) { + common.skip('missing crypto'); +} +const { spawnSyncAndAssert } = require('../common/child_process'); +const fixtures = require('../common/fixtures'); +const assert = require('assert'); + +spawnSyncAndAssert( + process.execPath, + [ '--use-openssl-ca', fixtures.path('list-certs.js') ], + { + env: { + ...process.env, + NODE_DEBUG_NATIVE: 'crypto', + NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'fake-startcom-root-cert.pem'), + CERTS_TYPE: 'default', + } + }, + { + stderr(output) { + assert.doesNotMatch( + output, + /Started loading bundled root certificates off-thread/ + ); + assert.doesNotMatch( + output, + /Started loading extra root certificates off-thread/ + ); + assert.doesNotMatch( + output, + /Started loading system root certificates off-thread/ + ); + return true; + } + } +); diff --git a/test/js/node/test/parallel/test-tls-psk-alpn-callback-exception-handling.js b/test/js/node/test/parallel/test-tls-psk-alpn-callback-exception-handling.js new file mode 100644 index 000000000000..cdeb9f3b31f8 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-psk-alpn-callback-exception-handling.js @@ -0,0 +1,430 @@ +'use strict'; + +// This test verifies that exceptions in pskCallback and ALPNCallback are +// properly routed through tlsClientError instead of becoming uncaught +// exceptions. This is a regression test for a vulnerability where callback +// validation errors would bypass all standard TLS error handlers. +// +// The vulnerability allows remote attackers to crash TLS servers or cause +// resource exhaustion (file descriptor leaks) when pskCallback or ALPNCallback +// throw exceptions during validation. + +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +if (process.features.openssl_is_boringssl) { + require('../common/boringssl').testPskTls13Unsupported(); + return; +} + +const assert = require('assert'); +const { describe, it } = require('node:test'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +const CIPHERS = 'PSK+HIGH'; +const TEST_TIMEOUT = 5000; + +// Helper to create a promise that rejects on uncaughtException or timeout +function createTestPromise() { + const { promise, resolve, reject } = Promise.withResolvers(); + let settled = false; + + const cleanup = () => { + if (!settled) { + settled = true; + process.removeListener('uncaughtException', onUncaught); + clearTimeout(timeout); + } + }; + + const onUncaught = (err) => { + cleanup(); + reject(new Error( + `Uncaught exception instead of tlsClientError: ${err.code || err.message}` + )); + }; + + const timeout = setTimeout(() => { + cleanup(); + reject(new Error('Test timed out - tlsClientError was not emitted')); + }, TEST_TIMEOUT); + + process.on('uncaughtException', onUncaught); + + return { + resolve: (value) => { + cleanup(); + resolve(value); + }, + reject: (err) => { + cleanup(); + reject(err); + }, + promise, + }; +} + +describe('TLS callback exception handling', () => { + + // Test 1: PSK server callback returning invalid type should emit tlsClientError + it('pskCallback returning invalid type emits tlsClientError', async (t) => { + const server = tls.createServer({ + ciphers: CIPHERS, + pskCallback: () => { + // Return invalid type (string instead of object/Buffer) + return 'invalid-should-be-object-or-buffer'; + }, + pskIdentityHint: 'test-hint', + }); + + t.after(() => server.close()); + + const { promise, resolve, reject } = createTestPromise(); + + server.on('tlsClientError', common.mustCall((err, socket) => { + try { + assert.ok(err instanceof Error); + assert.strictEqual(err.code, 'ERR_INVALID_ARG_TYPE'); + socket.destroy(); + resolve(); + } catch (e) { + reject(e); + } + })); + + server.on('secureConnection', () => { + reject(new Error('secureConnection should not fire')); + }); + + await new Promise((res) => server.listen(0, res)); + + const client = tls.connect({ + port: server.address().port, + host: '127.0.0.1', + ciphers: CIPHERS, + checkServerIdentity: () => {}, + pskCallback: () => ({ + psk: Buffer.alloc(32), + identity: 'test-identity', + }), + }); + + client.on('error', () => {}); + + await promise; + }); + + // Test 2: PSK server callback throwing should emit tlsClientError + it('pskCallback throwing emits tlsClientError', async (t) => { + const server = tls.createServer({ + ciphers: CIPHERS, + pskCallback: () => { + throw new Error('Intentional callback error'); + }, + pskIdentityHint: 'test-hint', + }); + + t.after(() => server.close()); + + const { promise, resolve, reject } = createTestPromise(); + + server.on('tlsClientError', common.mustCall((err, socket) => { + try { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Intentional callback error'); + socket.destroy(); + resolve(); + } catch (e) { + reject(e); + } + })); + + server.on('secureConnection', () => { + reject(new Error('secureConnection should not fire')); + }); + + await new Promise((res) => server.listen(0, res)); + + const client = tls.connect({ + port: server.address().port, + host: '127.0.0.1', + ciphers: CIPHERS, + checkServerIdentity: () => {}, + pskCallback: () => ({ + psk: Buffer.alloc(32), + identity: 'test-identity', + }), + }); + + client.on('error', () => {}); + + await promise; + }); + + // Test 3: ALPN callback returning non-matching protocol should emit tlsClientError + it('ALPNCallback returning invalid result emits tlsClientError', async (t) => { + const server = tls.createServer({ + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem'), + ALPNCallback: () => { + // Return a protocol not in the client's list + return 'invalid-protocol-not-in-list'; + }, + }); + + t.after(() => server.close()); + + const { promise, resolve, reject } = createTestPromise(); + + server.on('tlsClientError', common.mustCall((err, socket) => { + try { + assert.ok(err instanceof Error); + assert.strictEqual(err.code, 'ERR_TLS_ALPN_CALLBACK_INVALID_RESULT'); + socket.destroy(); + resolve(); + } catch (e) { + reject(e); + } + })); + + server.on('secureConnection', () => { + reject(new Error('secureConnection should not fire')); + }); + + await new Promise((res) => server.listen(0, res)); + + const client = tls.connect({ + port: server.address().port, + host: '127.0.0.1', + rejectUnauthorized: false, + ALPNProtocols: ['http/1.1', 'h2'], + }); + + client.on('error', () => {}); + + await promise; + }); + + // Test 4: ALPN callback throwing should emit tlsClientError + it('ALPNCallback throwing emits tlsClientError', async (t) => { + const server = tls.createServer({ + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem'), + ALPNCallback: () => { + throw new Error('Intentional ALPN callback error'); + }, + }); + + t.after(() => server.close()); + + const { promise, resolve, reject } = createTestPromise(); + + server.on('tlsClientError', common.mustCall((err, socket) => { + try { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Intentional ALPN callback error'); + socket.destroy(); + resolve(); + } catch (e) { + reject(e); + } + })); + + server.on('secureConnection', () => { + reject(new Error('secureConnection should not fire')); + }); + + await new Promise((res) => server.listen(0, res)); + + const client = tls.connect({ + port: server.address().port, + host: '127.0.0.1', + rejectUnauthorized: false, + ALPNProtocols: ['http/1.1'], + }); + + client.on('error', () => {}); + + await promise; + }); + + // Test 5: PSK client callback returning invalid type should emit error event + it('client pskCallback returning invalid type emits error', async (t) => { + const PSK = Buffer.alloc(32); + + const server = tls.createServer({ + ciphers: CIPHERS, + pskCallback: () => PSK, + pskIdentityHint: 'test-hint', + }); + + t.after(() => server.close()); + + const { promise, resolve, reject } = createTestPromise(); + + server.on('secureConnection', () => { + reject(new Error('secureConnection should not fire')); + }); + + await new Promise((res) => server.listen(0, res)); + + const client = tls.connect({ + port: server.address().port, + host: '127.0.0.1', + ciphers: CIPHERS, + checkServerIdentity: () => {}, + pskCallback: () => { + // Return invalid type - should cause validation error + return 'invalid-should-be-object'; + }, + }); + + client.on('error', common.mustCall((err) => { + try { + assert.ok(err instanceof Error); + assert.strictEqual(err.code, 'ERR_INVALID_ARG_TYPE'); + resolve(); + } catch (e) { + reject(e); + } + })); + + await promise; + }); + + // Test 6: PSK client callback throwing should emit error event + it('client pskCallback throwing emits error', async (t) => { + const PSK = Buffer.alloc(32); + + const server = tls.createServer({ + ciphers: CIPHERS, + pskCallback: () => PSK, + pskIdentityHint: 'test-hint', + }); + + t.after(() => server.close()); + + const { promise, resolve, reject } = createTestPromise(); + + server.on('secureConnection', () => { + reject(new Error('secureConnection should not fire')); + }); + + await new Promise((res) => server.listen(0, res)); + + const client = tls.connect({ + port: server.address().port, + host: '127.0.0.1', + ciphers: CIPHERS, + checkServerIdentity: () => {}, + pskCallback: () => { + throw new Error('Intentional client PSK callback error'); + }, + }); + + client.on('error', common.mustCall((err) => { + try { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Intentional client PSK callback error'); + resolve(); + } catch (e) { + reject(e); + } + })); + + await promise; + }); + + // Test 7: SNI callback throwing should emit tlsClientError + it('SNICallback throwing emits tlsClientError', async (t) => { + const server = tls.createServer({ + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem'), + SNICallback: (servername, cb) => { + throw new Error('Intentional SNI callback error'); + }, + }); + + t.after(() => server.close()); + + const { promise, resolve, reject } = createTestPromise(); + + server.on('tlsClientError', common.mustCall((err, socket) => { + try { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Intentional SNI callback error'); + socket.destroy(); + resolve(); + } catch (e) { + reject(e); + } + })); + + server.on('secureConnection', () => { + reject(new Error('secureConnection should not fire')); + }); + + await new Promise((res) => server.listen(0, res)); + + const client = tls.connect({ + port: server.address().port, + host: '127.0.0.1', + servername: 'evil.attacker.com', + rejectUnauthorized: false, + }); + + client.on('error', () => {}); + + await promise; + }); + + // Test 8: SNI callback with validation error should emit tlsClientError + it('SNICallback validation error emits tlsClientError', async (t) => { + const server = tls.createServer({ + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem'), + SNICallback: (servername, cb) => { + // Simulate common developer pattern: throw on unknown servername + if (servername !== 'expected.example.com') { + throw new Error(`Unknown servername: ${servername}`); + } + cb(null, null); + }, + }); + + t.after(() => server.close()); + + const { promise, resolve, reject } = createTestPromise(); + + server.on('tlsClientError', common.mustCall((err, socket) => { + try { + assert.ok(err instanceof Error); + assert.ok(err.message.includes('Unknown servername')); + socket.destroy(); + resolve(); + } catch (e) { + reject(e); + } + })); + + server.on('secureConnection', () => { + reject(new Error('secureConnection should not fire')); + }); + + await new Promise((res) => server.listen(0, res)); + + const client = tls.connect({ + port: server.address().port, + host: '127.0.0.1', + servername: 'unexpected.domain.com', + rejectUnauthorized: false, + }); + + client.on('error', () => {}); + + await promise; + }); +}); diff --git a/test/js/node/test/parallel/test-tls-psk-circuit.js b/test/js/node/test/parallel/test-tls-psk-circuit.js new file mode 100644 index 000000000000..c9c93d533501 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-psk-circuit.js @@ -0,0 +1,81 @@ +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) { + common.skip('missing crypto'); +} + +if (process.features.openssl_is_boringssl) { + require('../common/boringssl').testPskTls13Unsupported(); + return; +} + +const { hasOpenSSL } = require('../common/crypto'); +const assert = require('assert'); +const tls = require('tls'); + +const CIPHERS = 'PSK+HIGH:TLS_AES_128_GCM_SHA256'; +const USERS = { + UserA: Buffer.allocUnsafe(128), + UserB: Buffer.from('82072606b502b0f4025e90eb75fe137d', 'hex'), +}; +const TEST_DATA = 'x'; + +const serverOptions = { + ciphers: CIPHERS, + pskCallback: common.mustCallAtLeast((socket, id) => { + assert.ok(socket instanceof tls.TLSSocket); + assert.ok(typeof id === 'string'); + return USERS[id]; + }), +}; + +function test(secret, opts, error) { + const cb = !error ? + common.mustCall((c) => { c.pipe(c); }) : + common.mustNotCall(); + const server = tls.createServer(serverOptions, cb); + server.listen(0, common.mustCall(() => { + const options = { + port: server.address().port, + ciphers: CIPHERS, + checkServerIdentity: () => {}, + pskCallback: common.mustCall(() => secret), + ...opts, + }; + + if (!error) { + const client = tls.connect(options, common.mustCall(() => { + client.end(TEST_DATA); + + client.on('data', common.mustCall((data) => { + assert.strictEqual(data.toString(), TEST_DATA); + })); + client.on('close', common.mustCall(() => server.close())); + })); + } else { + const client = tls.connect(options, common.mustNotCall()); + client.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, error); + server.close(); + })); + } + })); +} + +test({ psk: USERS.UserA, identity: 'UserA' }); +test({ psk: USERS.UserA, identity: 'UserA' }, { maxVersion: 'TLSv1.2' }); +test({ psk: USERS.UserA, identity: 'UserA' }, { minVersion: 'TLSv1.3' }); +test({ psk: USERS.UserB, identity: 'UserB' }); +test({ psk: USERS.UserB, identity: 'UserB' }, { minVersion: 'TLSv1.3' }); +// Unrecognized user should fail handshake +const expectedHandshakeErr = hasOpenSSL(4, 0) ? + 'ERR_SSL_TLS_ALERT_HANDSHAKE_FAILURE' : hasOpenSSL(3, 2) ? + 'ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE' : 'ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE'; +test({ psk: USERS.UserB, identity: 'UserC' }, {}, expectedHandshakeErr); +// Recognized user but incorrect secret should fail handshake +const expectedIllegalParameterErr = hasOpenSSL(3, 4) ? 'ERR_SSL_TLSV1_ALERT_DECRYPT_ERROR' : + hasOpenSSL(3, 2) ? + 'ERR_SSL_SSL/TLS_ALERT_ILLEGAL_PARAMETER' : 'ERR_SSL_SSLV3_ALERT_ILLEGAL_PARAMETER'; +test({ psk: USERS.UserA, identity: 'UserB' }, {}, expectedIllegalParameterErr); +test({ psk: USERS.UserB, identity: 'UserB' }); diff --git a/test/js/node/test/parallel/test-tls-psk-server.js b/test/js/node/test/parallel/test-tls-psk-server.js index b92609584015..692550fc1c19 100644 --- a/test/js/node/test/parallel/test-tls-psk-server.js +++ b/test/js/node/test/parallel/test-tls-psk-server.js @@ -1,10 +1,20 @@ 'use strict'; const common = require('../common'); -if (!common.hasCrypto) +if (!common.hasCrypto) { common.skip('missing crypto'); -if (!common.opensslCli) +} + +if (process.features.openssl_is_boringssl) { + require('../common/boringssl').testPskTls13Unsupported(); + return; +} + +const { opensslCli } = require('../common/crypto'); + +if (!opensslCli) { common.skip('missing openssl cli'); +} const assert = require('assert'); @@ -18,12 +28,12 @@ const IDENTITY = 'TestUser'; const server = tls.createServer({ ciphers: CIPHERS, pskIdentityHint: IDENTITY, - pskCallback(socket, identity) { + pskCallback: common.mustCall((socket, identity) => { assert.ok(socket instanceof tls.TLSSocket); assert.ok(typeof identity === 'string'); if (identity === IDENTITY) return Buffer.from(KEY, 'hex'); - } + }), }); server.on('connection', common.mustCall()); @@ -40,8 +50,8 @@ let gotHello = false; let sentWorld = false; let gotWorld = false; -server.listen(0, () => { - const client = spawn(common.opensslCli, [ +server.listen(0, common.mustCall(() => { + const client = spawn(opensslCli, [ 's_client', '-connect', `127.0.0.1:${server.address().port}`, '-cipher', CIPHERS, @@ -74,4 +84,4 @@ server.listen(0, () => { assert.strictEqual(code, 0); server.close(); })); -}); +})); diff --git a/test/js/node/test/parallel/test-tls-reduced-SECLEVEL-in-cipher.js b/test/js/node/test/parallel/test-tls-reduced-SECLEVEL-in-cipher.js new file mode 100644 index 000000000000..cca22067a0fe --- /dev/null +++ b/test/js/node/test/parallel/test-tls-reduced-SECLEVEL-in-cipher.js @@ -0,0 +1,31 @@ +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +if (process.features.openssl_is_boringssl) { + require('../common/boringssl').assertOpenSSLSecurityLevelsUnsupported(); + return; +} + +const assert = require('assert'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +{ + const options = { + key: fixtures.readKey('agent11-key.pem'), + cert: fixtures.readKey('agent11-cert.pem'), + ciphers: 'DEFAULT' + }; + + // Should throw error as key is too small because openssl v3 doesn't allow it + assert.throws(() => tls.createServer(options, common.mustNotCall()), + /key too small/i); + + // Reducing SECLEVEL to 0 in ciphers retains compatibility with previous versions of OpenSSL like using a small key. + // As ciphers are getting set before the cert and key get loaded. + options.ciphers = 'DEFAULT:@SECLEVEL=0'; + assert.ok(tls.createServer(options, common.mustNotCall())); +} diff --git a/test/js/node/test/parallel/test-tls-secure-session.js b/test/js/node/test/parallel/test-tls-secure-session.js new file mode 100644 index 000000000000..b4b9638a2ccc --- /dev/null +++ b/test/js/node/test/parallel/test-tls-secure-session.js @@ -0,0 +1,46 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const fixtures = require('../common/fixtures'); +const assert = require('assert'); +const tls = require('tls'); + +const options = { + key: fixtures.readKey('agent1-key.pem'), + + // NOTE: Certificate Common Name is 'agent1' + cert: fixtures.readKey('agent1-cert.pem'), + + // NOTE: TLS 1.3 creates new session ticket **after** handshake so + // `getSession()` output will be different even if the session was reused + // during the handshake. + secureProtocol: 'TLSv1_2_method' +}; + +const server = tls.createServer(options, common.mustCall((socket) => { + socket.end(); +})).listen(0, common.mustCall(() => { + let connected = false; + let session = null; + + const client = tls.connect({ + rejectUnauthorized: false, + port: server.address().port, + }, common.mustCall(() => { + assert(!connected); + assert(!session); + + connected = true; + })); + + client.on('session', common.mustCall((newSession) => { + assert(connected); + assert(!session); + + session = newSession; + + client.end(); + server.close(); + })); +})); diff --git a/test/js/node/test/parallel/test-tls-server-capture-rejection.js b/test/js/node/test/parallel/test-tls-server-capture-rejection.js new file mode 100644 index 000000000000..f9bd3320e101 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-server-capture-rejection.js @@ -0,0 +1,34 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const events = require('events'); +const fixtures = require('../common/fixtures'); +const { createServer, connect } = require('tls'); +const cert = fixtures.readKey('rsa_cert.crt'); +const key = fixtures.readKey('rsa_private.pem'); + +events.captureRejections = true; + +const server = createServer({ cert, key }, common.mustCall(async (sock) => { + server.close(); + + const _err = new Error('kaboom'); + sock.on('error', common.mustCall((err) => { + assert.strictEqual(err, _err); + })); + throw _err; +})); + +server.listen(0, common.mustCall(() => { + const sock = connect({ + port: server.address().port, + host: server.address().host, + rejectUnauthorized: false + }); + + sock.on('close', common.mustCall()); +})); diff --git a/test/js/node/test/parallel/test-tls-server-failed-handshake-emits-clienterror.js b/test/js/node/test/parallel/test-tls-server-failed-handshake-emits-clienterror.js new file mode 100644 index 000000000000..9c30989af0af --- /dev/null +++ b/test/js/node/test/parallel/test-tls-server-failed-handshake-emits-clienterror.js @@ -0,0 +1,29 @@ +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const tls = require('tls'); +const net = require('net'); +const assert = require('assert'); + +const bonkers = Buffer.alloc(1024, 42); + + +const server = tls.createServer({}) + .listen(0, function() { + const c = net.connect({ port: this.address().port }, function() { + c.write(bonkers); + }); + + }).on('tlsClientError', common.mustCall(function(e) { + assert.ok(e instanceof Error, + 'Instance of Error should be passed to error handler'); + assert.match( + e.message, + /SSL routines:[^:]*:wrong[ _]version[ _]number/i, + ); + + server.close(); + })); diff --git a/test/js/node/test/parallel/test-tls-session-timeout-errors.js b/test/js/node/test/parallel/test-tls-session-timeout-errors.js new file mode 100644 index 000000000000..6e5646127c80 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-session-timeout-errors.js @@ -0,0 +1,36 @@ +'use strict'; +// This tests validation of sessionTimeout option in TLS server. +const common = require('../common'); + +if (!common.hasCrypto) { + common.skip('missing crypto'); +} + +const tmpdir = require('../common/tmpdir'); +tmpdir.refresh(); + +const assert = require('assert'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +const key = fixtures.readKey('rsa_private.pem'); +const cert = fixtures.readKey('rsa_cert.crt'); + +// Node.js should not allow setting negative timeouts since new versions of +// OpenSSL do not handle those as users might expect + +for (const sessionTimeout of [-1, -100, -(2 ** 31)]) { + assert.throws(() => { + tls.createServer({ + key: key, + cert: cert, + ca: [cert], + sessionTimeout, + maxVersion: 'TLSv1.2', + }); + }, { + code: 'ERR_OUT_OF_RANGE', + message: 'The value of "options.sessionTimeout" is out of range. It ' + + `must be >= 0 && <= ${2 ** 31 - 1}. Received ${sessionTimeout}`, + }); +} diff --git a/test/js/node/test/parallel/test-tls-set-ciphers.js b/test/js/node/test/parallel/test-tls-set-ciphers.js index 1e63e9376e13..82a19bb9e90f 100644 --- a/test/js/node/test/parallel/test-tls-set-ciphers.js +++ b/test/js/node/test/parallel/test-tls-set-ciphers.js @@ -90,7 +90,9 @@ function test(cciphers, sciphers, cipher, cerr, serr, options) { const U = undefined; let expectedTLSAlertError = 'ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE'; -if (hasOpenSSL(3, 2)) { +if (hasOpenSSL(4, 0)) { + expectedTLSAlertError = 'ERR_SSL_TLS_ALERT_HANDSHAKE_FAILURE'; +} else if (hasOpenSSL(3, 2)) { expectedTLSAlertError = 'ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE'; } diff --git a/test/js/node/test/parallel/test-tls-set-default-ca-certificates-array-buffer.js b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-array-buffer.js new file mode 100644 index 000000000000..0ea30721e57c --- /dev/null +++ b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-array-buffer.js @@ -0,0 +1,39 @@ +// Flags: --no-use-system-ca +'use strict'; + +// This tests tls.setDefaultCACertificates() support ArrayBufferView. + +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +const tls = require('tls'); +const fixtures = require('../common/fixtures'); +const { assertEqualCerts } = require('../common/tls'); + +const fixtureCert = fixtures.readKey('fake-startcom-root-cert.pem'); + +// Should accept Buffer. +tls.setDefaultCACertificates([Buffer.from(fixtureCert)]); +const result = tls.getCACertificates('default'); +assertEqualCerts(result, [fixtureCert]); + +// Reset it to empty. +tls.setDefaultCACertificates([]); +assertEqualCerts(tls.getCACertificates('default'), []); + +// Should accept Uint8Array. +const encoder = new TextEncoder(); +const uint8Cert = encoder.encode(fixtureCert); +tls.setDefaultCACertificates([uint8Cert]); +const uint8Result = tls.getCACertificates('default'); +assertEqualCerts(uint8Result, [fixtureCert]); + +// Reset it to empty. +tls.setDefaultCACertificates([]); +assertEqualCerts(tls.getCACertificates('default'), []); + +// Should accept DataView. +const dataViewCert = new DataView(uint8Cert.buffer, uint8Cert.byteOffset, uint8Cert.byteLength); +tls.setDefaultCACertificates([dataViewCert]); +const dataViewResult = tls.getCACertificates('default'); +assertEqualCerts(dataViewResult, [fixtureCert]); diff --git a/test/js/node/test/parallel/test-tls-set-default-ca-certificates-basic.js b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-basic.js new file mode 100644 index 000000000000..f6772110e54e --- /dev/null +++ b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-basic.js @@ -0,0 +1,58 @@ +'use strict'; + +// This tests the basic functionality of tls.setDefaultCACertificates(). + +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +const tls = require('tls'); +const fixtures = require('../common/fixtures'); +const { assertEqualCerts } = require('../common/tls'); + +const originalBundled = tls.getCACertificates('bundled'); +const originalSystem = tls.getCACertificates('system'); +const fixtureCert = fixtures.readKey('fake-startcom-root-cert.pem'); + +function testSetCertificates(certs) { + // Test setting it can be verified with tls.getCACertificates(). + tls.setDefaultCACertificates(certs); + const result = tls.getCACertificates('default'); + assertEqualCerts(result, certs); + + // Verify that other certificate types are unchanged + const newBundled = tls.getCACertificates('bundled'); + const newSystem = tls.getCACertificates('system'); + assertEqualCerts(newBundled, originalBundled); + assertEqualCerts(newSystem, originalSystem); + + // Test implicit defaults. + const implicitDefaults = tls.getCACertificates(); + assertEqualCerts(implicitDefaults, certs); + + // Test cached results. + const cachedResult = tls.getCACertificates('default'); + assertEqualCerts(cachedResult, certs); + const cachedImplicitDefaults = tls.getCACertificates(); + assertEqualCerts(cachedImplicitDefaults, certs); +} + +// Test setting with fixture certificate. +testSetCertificates([fixtureCert]); + +// Test setting with empty array. +testSetCertificates([]); + +// Test setting with bundled certificates +testSetCertificates(originalBundled); + +// Test combining bundled and extra certificates. +testSetCertificates([...originalBundled, fixtureCert]); + +// Test setting with a subset of bundled certificates +if (originalBundled.length >= 3) { + testSetCertificates(originalBundled.slice(0, 3)); +} + +// Test duplicate certificates +tls.setDefaultCACertificates([fixtureCert, fixtureCert, fixtureCert]); +assertEqualCerts(tls.getCACertificates('default'), [fixtureCert]); diff --git a/test/js/node/test/parallel/test-tls-set-default-ca-certificates-error.js b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-error.js new file mode 100644 index 000000000000..1d529a97265a --- /dev/null +++ b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-error.js @@ -0,0 +1,41 @@ +'use strict'; + +// This tests input validation of tls.setDefaultCACertificates(). + +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +const fixtures = require('../common/fixtures'); +const assert = require('assert'); +const tls = require('tls'); +const { assertEqualCerts } = require('../common/tls'); + +const defaultCerts = tls.getCACertificates('default'); +const fixtureCert = fixtures.readKey('fake-startcom-root-cert.pem'); + +for (const invalid of [null, undefined, 'string', 42, {}, true]) { + // Test input validation - should throw when not passed an array + assert.throws(() => tls.setDefaultCACertificates(invalid), { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "certs" argument must be an instance of Array/ + }); + // Verify that default certificates remain unchanged after error. + assertEqualCerts(tls.getCACertificates('default'), defaultCerts); +} + +for (const invalid of [null, undefined, 42, {}, true]) { + // Test input validation - should throw when passed an array with invalid elements + assert.throws(() => tls.setDefaultCACertificates([invalid]), { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "certs\[0\]" argument must be of type string or an instance of ArrayBufferView/ + }); + // Verify that default certificates remain unchanged after error. + assertEqualCerts(tls.getCACertificates('default'), defaultCerts); + + assert.throws(() => tls.setDefaultCACertificates([fixtureCert, invalid]), { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "certs\[1\]" argument must be of type string or an instance of ArrayBufferView/ + }); + // Verify that default certificates remain unchanged after error. + assertEqualCerts(tls.getCACertificates('default'), defaultCerts); +} diff --git a/test/js/node/test/parallel/test-tls-set-default-ca-certificates-extra-override.js b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-extra-override.js new file mode 100644 index 000000000000..cf7790e5d083 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-extra-override.js @@ -0,0 +1,19 @@ +'use strict'; + +// This tests that tls.setDefaultCACertificates() properly overrides certificates +// added through NODE_EXTRA_CA_CERTS environment variable. + +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +const fixtures = require('../common/fixtures'); +const { spawnSyncAndExitWithoutError } = require('../common/child_process'); + +spawnSyncAndExitWithoutError(process.execPath, [ + fixtures.path('tls-extra-ca-override.js'), +], { + env: { + ...process.env, + NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'fake-startcom-root-cert.pem') + } +}); diff --git a/test/js/node/test/parallel/test-tls-set-default-ca-certificates-mixed-types.js b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-mixed-types.js new file mode 100644 index 000000000000..2f22ed8ec343 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-mixed-types.js @@ -0,0 +1,46 @@ +'use strict'; + +// This tests mixed input types for tls.setDefaultCACertificates(). + +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +const tls = require('tls'); +const { assertEqualCerts } = require('../common/tls'); + +const bundledCerts = tls.getCACertificates('bundled'); +if (bundledCerts.length < 4) { + common.skip('Not enough bundled CA certificates available'); +} + +const encoder = new TextEncoder(); + +// Test mixed array with string and Buffer. +{ + tls.setDefaultCACertificates([bundledCerts[0], Buffer.from(bundledCerts[1], 'utf8')]); + const result = tls.getCACertificates('default'); + assertEqualCerts(result, [bundledCerts[0], bundledCerts[1]]); +} + +// Test mixed array with string and Uint8Array. +{ + tls.setDefaultCACertificates([bundledCerts[1], encoder.encode(bundledCerts[2])]); + const result = tls.getCACertificates('default'); + assertEqualCerts(result, [bundledCerts[1], bundledCerts[2]]); +} + +// Test mixed array with string and DataView. +{ + const uint8Cert = encoder.encode(bundledCerts[3]); + const dataViewCert = new DataView(uint8Cert.buffer, uint8Cert.byteOffset, uint8Cert.byteLength); + tls.setDefaultCACertificates([bundledCerts[1], dataViewCert]); + const result = tls.getCACertificates('default'); + assertEqualCerts(result, [bundledCerts[1], bundledCerts[3]]); +} + +// Test mixed array with Buffer and Uint8Array. +{ + tls.setDefaultCACertificates([Buffer.from(bundledCerts[0], 'utf8'), encoder.encode(bundledCerts[2])]); + const result = tls.getCACertificates('default'); + assertEqualCerts(result, [bundledCerts[0], bundledCerts[2]]); +} diff --git a/test/js/node/test/parallel/test-tls-set-default-ca-certificates-precedence-bundled.js b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-precedence-bundled.js new file mode 100644 index 000000000000..a9658adbb01e --- /dev/null +++ b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-precedence-bundled.js @@ -0,0 +1,53 @@ +'use strict'; + +// This tests that per-connection ca option overrides bundled default CA certificates. + +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +const assert = require('assert'); +const https = require('https'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); +const { includesCert } = require('../common/tls'); + +const server = https.createServer({ + cert: fixtures.readKey('agent8-cert.pem'), + key: fixtures.readKey('agent8-key.pem'), +}, common.mustCall((req, res) => { + res.writeHead(200); + res.end('override works'); +}, 1)); + +server.listen(0, common.mustCall(() => { + const port = server.address().port; + const bundledCerts = tls.getCACertificates('bundled'); + const fakeStartcomCert = fixtures.readKey('fake-startcom-root-cert.pem'); + + // Set default CA to bundled certs (which don't include fake-startcom-root-cert) + tls.setDefaultCACertificates(bundledCerts); + + // Verify that fake-startcom-root-cert is not in default + const defaultCerts = tls.getCACertificates('default'); + assert(!includesCert(defaultCerts, fakeStartcomCert)); + + // Connection with per-connection ca should succeed despite wrong default + const req = https.request({ + hostname: 'localhost', + port: port, + path: '/', + method: 'GET', + ca: [fakeStartcomCert] // This should override the bundled defaults + }, common.mustCall((res) => { + assert.strictEqual(res.statusCode, 200); + let data = ''; + res.on('data', (chunk) => data += chunk); + res.on('end', common.mustCall(() => { + assert.strictEqual(data, 'override works'); + server.close(); + })); + })); + + req.on('error', common.mustNotCall('Should not error with per-connection ca option')); + req.end(); +})); diff --git a/test/js/node/test/parallel/test-tls-set-default-ca-certificates-precedence-empty.js b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-precedence-empty.js new file mode 100644 index 000000000000..1eacbc3109d2 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-precedence-empty.js @@ -0,0 +1,51 @@ +'use strict'; + +// This tests that per-connection ca option overrides empty default CA certificates + +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +const assert = require('assert'); +const https = require('https'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +const server = https.createServer({ + cert: fixtures.readKey('agent8-cert.pem'), + key: fixtures.readKey('agent8-key.pem'), +}, common.mustCall((req, res) => { + res.writeHead(200); + res.end('per-connection ca works'); +}, 1)); + +server.listen(0, common.mustCall(() => { + const port = server.address().port; + const fakeStartcomCert = fixtures.readKey('fake-startcom-root-cert.pem'); + + // Set default CA to empty array - connections should normally fail + tls.setDefaultCACertificates([]); + + // Verify that default CA is empty + const defaultCerts = tls.getCACertificates('default'); + assert.deepStrictEqual(defaultCerts, []); + + // Connection with per-connection ca option should succeed despite empty default + const req = https.request({ + hostname: 'localhost', + port: port, + path: '/', + method: 'GET', + ca: [fakeStartcomCert] // This should override the empty default + }, common.mustCall((res) => { + assert.strictEqual(res.statusCode, 200); + let data = ''; + res.on('data', (chunk) => data += chunk); + res.on('end', common.mustCall(() => { + assert.strictEqual(data, 'per-connection ca works'); + server.close(); + })); + })); + + req.on('error', common.mustNotCall('Should not error with per-connection ca option')); + req.end(); +})); diff --git a/test/js/node/test/parallel/test-tls-sni-option.js b/test/js/node/test/parallel/test-tls-sni-option.js new file mode 100644 index 000000000000..9857b53afd45 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-sni-option.js @@ -0,0 +1,174 @@ +// 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. + +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +function loadPEM(n) { + return fixtures.readKey(`${n}.pem`); +} + +const serverOptions = { + key: loadPEM('agent2-key'), + cert: loadPEM('agent2-cert'), + requestCert: true, + rejectUnauthorized: false, + SNICallback: function(servername, callback) { + const context = SNIContexts[servername]; + + // Just to test asynchronous callback + setTimeout(function() { + if (context) { + if (context.emptyRegression) + callback(null, {}); + else + callback(null, tls.createSecureContext(context)); + } else { + callback(null, null); + } + }, 100); + } +}; + +const SNIContexts = { + 'a.example.com': { + key: loadPEM('agent1-key'), + cert: loadPEM('agent1-cert'), + ca: [ loadPEM('ca2-cert') ] + }, + 'b.example.com': { + key: loadPEM('agent3-key'), + cert: loadPEM('agent3-cert') + }, + 'c.another.com': { + emptyRegression: true + } +}; + +test({ + port: undefined, + key: loadPEM('agent1-key'), + cert: loadPEM('agent1-cert'), + ca: [loadPEM('ca1-cert')], + servername: 'a.example.com', + rejectUnauthorized: false +}, + true, + { sni: 'a.example.com', authorized: false }, + null, + null); + +test({ + port: undefined, + key: loadPEM('agent4-key'), + cert: loadPEM('agent4-cert'), + ca: [loadPEM('ca1-cert')], + servername: 'a.example.com', + rejectUnauthorized: false +}, + true, + { sni: 'a.example.com', authorized: true }, + null, + null); + +test({ + port: undefined, + key: loadPEM('agent2-key'), + cert: loadPEM('agent2-cert'), + ca: [loadPEM('ca2-cert')], + servername: 'b.example.com', + rejectUnauthorized: false +}, + true, + { sni: 'b.example.com', authorized: false }, + null, + null); + +test({ + port: undefined, + key: loadPEM('agent3-key'), + cert: loadPEM('agent3-cert'), + ca: [loadPEM('ca1-cert')], + servername: 'c.wrong.com', + rejectUnauthorized: false +}, + false, + { sni: 'c.wrong.com', authorized: false }, + null, + null); + +test({ + port: undefined, + key: loadPEM('agent3-key'), + cert: loadPEM('agent3-cert'), + ca: [loadPEM('ca1-cert')], + servername: 'c.another.com', + rejectUnauthorized: false +}, + false, + null, + 'Client network socket disconnected before secure TLS ' + + 'connection was established', + 'Invalid SNI context'); + +function test(options, clientResult, serverResult, clientError, serverError) { + const server = tls.createServer(serverOptions, common.mustCallAtLeast((c) => { + assert.deepStrictEqual( + serverResult, + { sni: c.servername, authorized: c.authorized } + ); + }, 0)); + + if (serverResult) { + assert(!serverError); + server.on('tlsClientError', common.mustNotCall()); + } else { + assert(serverError); + server.on('tlsClientError', common.mustCall((err) => { + assert.strictEqual(err.message, serverError); + })); + } + + server.listen(0, common.mustCall(() => { + options.port = server.address().port; + const client = tls.connect(options, common.mustCallAtLeast(() => { + const result = client.authorizationError && + (client.authorizationError === 'ERR_TLS_CERT_ALTNAME_INVALID'); + assert.strictEqual(result, clientResult); + client.end(); + }, 0)); + + client.on('close', common.mustCall(() => server.close())); + + if (clientError) + client.on('error', common.mustCall((err) => { + assert.strictEqual(err.message, clientError); + })); + else + client.on('error', common.mustNotCall()); + })); +} diff --git a/test/js/node/test/parallel/test-tls-snicallback-error.js b/test/js/node/test/parallel/test-tls-snicallback-error.js new file mode 100644 index 000000000000..aac7cb9f9670 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-snicallback-error.js @@ -0,0 +1,24 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const net = require('net'); +const tls = require('tls'); + +for (const SNICallback of ['fhqwhgads', 42, {}, []]) { + assert.throws(() => { + tls.createServer({ SNICallback }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + }); + + assert.throws(() => { + new tls.TLSSocket(new net.Socket(), { isServer: true, SNICallback }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + }); +} diff --git a/test/js/node/test/parallel/test-tls-ticket-cluster.js b/test/js/node/test/parallel/test-tls-ticket-cluster.js new file mode 100644 index 000000000000..f183b53f24c0 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-ticket-cluster.js @@ -0,0 +1,140 @@ +// 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. + +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +if (process.features.openssl_is_boringssl) { + require('../common/boringssl').testTls13SessionTicketSemanticsDiffer(); + return; +} + +const assert = require('assert'); +const tls = require('tls'); +const cluster = require('cluster'); +const fixtures = require('../common/fixtures'); + +const workerCount = 4; +const expectedReqCount = 16; + +if (cluster.isPrimary) { + let listeningCount = 0; + let reusedCount = 0; + let reqCount = 0; + let lastSession = null; + let workerPort = null; + + function shoot() { + console.error('[primary] connecting', + workerPort, 'session?', !!lastSession); + const c = tls.connect(workerPort, { + session: lastSession, + rejectUnauthorized: false + }, () => { + c.on('end', c.end); + }).on('close', () => { + // Wait for close to shoot off another connection. We don't want to shoot + // until a new session is allocated, if one will be. The new session is + // not guaranteed on secureConnect (it depends on TLS1.2 vs TLS1.3), but + // it is guaranteed to happen before the connection is closed. + if (++reqCount === expectedReqCount) { + Object.keys(cluster.workers).forEach(function(id) { + cluster.workers[id].send('die'); + }); + } else { + shoot(); + } + }).once('session', common.mustCallAtLeast((session) => { + assert(!lastSession); + lastSession = session; + }, 0)); + + c.resume(); // See close_notify comment in server + } + + function fork() { + const worker = cluster.fork(); + worker.on('message', ({ msg, port }) => { + console.error('[primary] got %j', msg); + if (msg === 'reused') { + ++reusedCount; + } else if (msg === 'listening' && ++listeningCount === workerCount) { + workerPort = port; + shoot(); + } + }); + + worker.on('exit', () => { + console.error('[primary] worker died'); + }); + } + for (let i = 0; i < workerCount; i++) { + fork(); + } + + process.on('exit', () => { + assert.strictEqual(reqCount, expectedReqCount); + assert.strictEqual(reusedCount + 1, reqCount); + }); + return; +} + +const key = fixtures.readKey('rsa_private.pem'); +const cert = fixtures.readKey('rsa_cert.crt'); + +const options = { key, cert }; + +const server = tls.createServer(options, (c) => { + console.error('[worker] connection reused?', c.isSessionReused()); + if (c.isSessionReused()) { + process.send({ msg: 'reused' }); + } else { + process.send({ msg: 'not-reused' }); + } + // Used to just .end(), but that means client gets close_notify before + // NewSessionTicket. Send data until that problem is solved. + c.end('x'); +}); + +server.listen(0, () => { + const { port } = server.address(); + process.send({ + msg: 'listening', + port, + }); +}); + +process.on('message', function listener(msg) { + console.error('[worker] got %j', msg); + if (msg === 'die') { + server.close(() => { + console.error('[worker] server close'); + + process.exit(); + }); + } +}); + +process.on('exit', () => { + console.error('[worker] exit'); +}); diff --git a/test/js/node/test/parallel/test-tls-ticket-invalid-arg.js b/test/js/node/test/parallel/test-tls-ticket-invalid-arg.js new file mode 100644 index 000000000000..55143cdca31e --- /dev/null +++ b/test/js/node/test/parallel/test-tls-ticket-invalid-arg.js @@ -0,0 +1,24 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) { + common.skip('missing crypto'); +} + +const assert = require('assert'); +const tls = require('tls'); + +const server = new tls.Server(); + +[null, undefined, 0, 1, 1n, Symbol(), {}, [], true, false, '', () => {}] + .forEach((arg) => + assert.throws( + () => server.setTicketKeys(arg), + { code: 'ERR_INVALID_ARG_TYPE' } + )); + +[new Uint8Array(1), Buffer.from([1]), new DataView(new ArrayBuffer(2))].forEach( + (arg) => + assert.throws(() => { + server.setTicketKeys(arg); + }, /Session ticket keys must be a 48-byte buffer/) +); diff --git a/test/js/node/test/parallel/test-tls-ticket.js b/test/js/node/test/parallel/test-tls-ticket.js new file mode 100644 index 000000000000..08ff5853deb2 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-ticket.js @@ -0,0 +1,163 @@ +// 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. + +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); +const net = require('net'); +const crypto = require('crypto'); +const fixtures = require('../common/fixtures'); + +if (process.features.openssl_is_boringssl && + tls.DEFAULT_MAX_VERSION !== 'TLSv1.2') { + require('../common/boringssl').testTls13SessionTicketSemanticsDiffer(); + return; +} + +const keys = crypto.randomBytes(48); +const serverLog = []; +const ticketLog = []; + +let s; + +let serverCount = 0; +function createServer() { + const id = serverCount++; + + let counter = 0; + let previousKey = null; + + const server = tls.createServer({ + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem'), + ticketKeys: keys + }, common.mustCallAtLeast(function(c) { + serverLog.push(id); + c.end('x'); + + counter++; + + // Rotate ticket keys + // + // Take especial care to account for TLS1.2 and TLS1.3 differences around + // when ticket keys are encrypted. In TLS1.2, they are encrypted before the + // handshake complete callback, but in TLS1.3, they are encrypted after. + // There is no callback or way for us to know when they were sent, so hook + // the client's reception of the keys, and use it as proof that the current + // keys were used, and its safe to rotate them. + // + // Rotation can occur right away if the session was reused, the keys were + // already decrypted or we wouldn't have a reused session. + function setTicketKeys(keys) { + if (c.isSessionReused()) + server.setTicketKeys(keys); + else + s.once('session', () => { + server.setTicketKeys(keys); + }); + } + if (counter === 1) { + previousKey = server.getTicketKeys(); + assert.strictEqual(previousKey.compare(keys), 0); + setTicketKeys(crypto.randomBytes(48)); + } else if (counter === 2) { + setTicketKeys(previousKey); + } else if (counter === 3) { + // Use keys from counter=2 + } else { + throw new Error('UNREACHABLE'); + } + })); + + return server; +} + +const naturalServers = [ createServer(), createServer(), createServer() ]; + +// 3x servers +const servers = naturalServers.concat(naturalServers).concat(naturalServers); + +// Create one TCP server and balance sockets to multiple TLS server instances +const shared = net.createServer(function(c) { + servers.shift().emit('connection', c); +}).listen(0, function() { + start(function() { + shared.close(); + }); +}); + +// 'session' events only occur for new sessions. The first connection is new. +// After, for each set of 3 connections, the middle connection is made when the +// server has random keys set, so the client's ticket is silently ignored, and a +// new ticket is sent. +const onNewSession = common.mustCall((s, session) => { + assert(session); + assert.strictEqual(session.compare(s.getSession()), 0); +}, 4); + +function start(callback) { + let sess = null; + let left = servers.length; + + function connect() { + s = tls.connect(shared.address().port, { + session: sess, + rejectUnauthorized: false + }, function() { + if (s.isSessionReused()) + ticketLog.push(s.getTLSTicket().toString('hex')); + }); + s.on('data', () => { + s.end(); + }); + s.on('close', function() { + if (--left === 0) + callback(); + else + connect(); + }); + s.on('session', (session) => { + sess ||= session; + }); + s.once('session', (session) => onNewSession(s, session)); + s.once('session', () => ticketLog.push(s.getTLSTicket().toString('hex'))); + } + + connect(); +} + +process.on('exit', function() { + assert.strictEqual(ticketLog.length, serverLog.length); + for (let i = 0; i < naturalServers.length - 1; i++) { + assert.notStrictEqual(serverLog[i], serverLog[i + 1]); + assert.strictEqual(ticketLog[i], ticketLog[i + 1]); + + // 2nd connection should have different ticket + assert.notStrictEqual(ticketLog[i], ticketLog[i + naturalServers.length]); + + // 3rd connection should have the same ticket + assert.strictEqual(ticketLog[i], ticketLog[i + naturalServers.length * 2]); + } +}); diff --git a/test/js/node/test/parallel/test-tls-timeout-server.js b/test/js/node/test/parallel/test-tls-timeout-server.js new file mode 100644 index 000000000000..7ca85f14d7ae --- /dev/null +++ b/test/js/node/test/parallel/test-tls-timeout-server.js @@ -0,0 +1,47 @@ +// 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. + +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const tls = require('tls'); +const net = require('net'); +const fixtures = require('../common/fixtures'); + +const options = { + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem'), + handshakeTimeout: 50 +}; + +const server = tls.createServer(options, common.mustNotCall()); + +server.on('tlsClientError', common.mustCall(function(err, conn) { + conn.destroy(); + server.close(); +})); + +server.listen(0, common.mustCall(function() { + net.connect({ host: '127.0.0.1', port: this.address().port }); +})); diff --git a/test/js/node/test/parallel/test-tls-wrap-econnreset-pipe.js b/test/js/node/test/parallel/test-tls-wrap-econnreset-pipe.js new file mode 100644 index 000000000000..f294f23f1d00 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-wrap-econnreset-pipe.js @@ -0,0 +1,48 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); +const net = require('net'); +const { fork } = require('child_process'); + +const tmpdir = require('../common/tmpdir'); + +// Run in a child process because the PIPE file descriptor stays open until +// Node.js completes, blocking the tmpdir and preventing cleanup. + +if (process.argv[2] !== 'child') { + // Parent + tmpdir.refresh(); + + // Run test + const child = fork(__filename, ['child'], { stdio: 'inherit' }); + child.on('exit', common.mustCall(function(code) { + assert.strictEqual(code, 0); + })); + + return; +} + +// Child +const server = net.createServer((c) => { + c.end(); +}).listen(common.PIPE, common.mustCall(() => { + let errored = false; + tls.connect({ path: common.PIPE }) + .once('error', common.mustCall((e) => { + assert.strictEqual(e.code, 'ECONNRESET'); + assert.strictEqual(e.path, common.PIPE); + assert.strictEqual(e.port, undefined); + assert.strictEqual(e.host, undefined); + assert.strictEqual(e.localAddress, undefined); + server.close(); + errored = true; + })) + .on('close', common.mustCall(() => { + assert.strictEqual(errored, true); + })); +})); diff --git a/test/js/node/test/parallel/test-tls-wrap-event-emmiter.js b/test/js/node/test/parallel/test-tls-wrap-event-emmiter.js new file mode 100644 index 000000000000..47933da674bc --- /dev/null +++ b/test/js/node/test/parallel/test-tls-wrap-event-emmiter.js @@ -0,0 +1,17 @@ +'use strict'; + +// Issue: https://github.com/nodejs/node/issues/3655 +// Test checks if we get exception instead of runtime error + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); + +const TlsSocket = require('tls').TLSSocket; +const EventEmitter = require('events').EventEmitter; +assert.throws( + () => { new TlsSocket(new EventEmitter()); }, + TypeError +); diff --git a/test/js/node/test/parallel/test-vm-module-errors.js b/test/js/node/test/parallel/test-vm-module-errors.js index 61f9c68fa39b..ad247f83edcb 100644 --- a/test/js/node/test/parallel/test-vm-module-errors.js +++ b/test/js/node/test/parallel/test-vm-module-errors.js @@ -225,8 +225,8 @@ async function checkInvalidOptionForEvaluate() { function checkInvalidCachedData() { [true, false, 'foo', {}, Array, function() {}].forEach((invalidArg) => { - const message = 'The "options.cachedData" property must be of ' + - 'type Buffer, TypedArray, or DataView.' + + const message = 'The "options.cachedData" property must be an ' + + 'instance of Buffer, TypedArray, or DataView.' + common.invalidArgTypeHelper(invalidArg); assert.throws( () => new SourceTextModule('import "foo";', { cachedData: invalidArg }), diff --git a/test/js/node/test/sequential/test-net-listen-shared-ports.js b/test/js/node/test/sequential/test-net-listen-shared-ports.js new file mode 100644 index 000000000000..34091cba15ca --- /dev/null +++ b/test/js/node/test/sequential/test-net-listen-shared-ports.js @@ -0,0 +1,67 @@ +// 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. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const cluster = require('cluster'); +const net = require('net'); + +if (cluster.isPrimary) { + const worker1 = cluster.fork(); + + worker1.on('message', common.mustCall(function(msg) { + assert.strictEqual(msg, 'success'); + const worker2 = cluster.fork(); + + worker2.on('message', common.mustCall(function(msg) { + assert.strictEqual(msg, 'server2:EADDRINUSE'); + worker1.kill(); + worker2.kill(); + })); + })); +} else { + const server1 = net.createServer(common.mustNotCall()); + const server2 = net.createServer(common.mustNotCall()); + + server1.on('error', function(err) { + // no errors expected + process.send(`server1:${err.code}`); + }); + + server2.on('error', function(err) { + // An error is expected on the second worker + process.send(`server2:${err.code}`); + }); + + server1.listen({ + host: 'localhost', + port: common.PORT, + exclusive: false, + }, common.mustCall(function() { + server2.listen({ port: common.PORT + 1, exclusive: true }, + common.mustCall(function() { + // The first worker should succeed + process.send('success'); + }) + ); + })); +} diff --git a/test/js/node/test/sequential/test-net-localport.js b/test/js/node/test/sequential/test-net-localport.js new file mode 100644 index 000000000000..4539acaaee4c --- /dev/null +++ b/test/js/node/test/sequential/test-net-localport.js @@ -0,0 +1,20 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +const server = net.createServer(common.mustCall((socket) => { + assert.strictEqual(socket.remotePort, common.PORT); + socket.end(); + socket.on('close', function() { + server.close(); + }); +})).listen(0).on('listening', common.mustCall(function() { + const client = net.connect({ + host: '127.0.0.1', + port: this.address().port, + localPort: common.PORT, + }).on('connect', common.mustCall(() => { + assert.strictEqual(client.localPort, common.PORT); + })); +})); diff --git a/test/js/node/tls/node-tls-cert.test.ts b/test/js/node/tls/node-tls-cert.test.ts index b091f9672173..6deb6fbb2ad1 100644 --- a/test/js/node/tls/node-tls-cert.test.ts +++ b/test/js/node/tls/node-tls-cert.test.ts @@ -720,69 +720,84 @@ describe("tls ciphers should work", () => { }); }); -it("server-side getPeerCertificate() should not leak", async () => { - // Guards against the SSL_get_peer_certificate X509 ref leak and the - // computeRaw BIO leak on the server getPeerCertificate() path. - const { promise: serverSocketPromise, resolve: onServerSocket } = Promise.withResolvers(); - const server = tls.createServer( - { - key: serverTls.key, - cert: serverTls.cert, - ca: [clientTls.ca], - requestCert: true, - rejectUnauthorized: false, - }, - socket => onServerSocket(socket), - ); - await once(server.listen(0, "127.0.0.1"), "listening"); - - const client = tls.connect({ - host: "127.0.0.1", - port: (server.address() as AddressInfo).port, - key: clientTls.key, - cert: clientTls.cert, - ca: [serverTls.ca], - checkServerIdentity, - }); - await once(client, "secureConnect"); +// A local `bun bd` debug build is ASAN-instrumented but not named `bun-asan`; +// ASAN's default 256MB quarantine retains every freed allocation, so RSS grows +// by the total allocation churn regardless of leaks and the threshold below +// cannot distinguish a leak from the quarantine. Skip every debug build - +// since isASAN learned that local debug builds are ASAN-instrumented too, +// debug+ASAN is exactly the un-measurable combination (quarantine + redzones +// inflate RSS unboundedly for this access pattern); CI's release ASAN lane +// (isDebug false) keeps running with its own threshold. +it.skipIf(isDebug)( + "server-side getPeerCertificate() should not leak", + async () => { + // Guards against the SSL_get_peer_certificate X509 ref leak and the + // computeRaw BIO leak on the server getPeerCertificate() path. + const { promise: serverSocketPromise, resolve: onServerSocket } = Promise.withResolvers(); + const server = tls.createServer( + { + key: serverTls.key, + cert: serverTls.cert, + ca: [clientTls.ca], + requestCert: true, + rejectUnauthorized: false, + }, + socket => onServerSocket(socket), + ); + await once(server.listen(0, "127.0.0.1"), "listening"); - const serverSocket = await serverSocketPromise; - try { - // Make sure the client actually sent a cert so we exercise the - // SSL_get_peer_certificate path rather than falling through to the - // cert-chain branch. - const first = serverSocket.getPeerCertificate(); - expect(first).toBeDefined(); - expect(first?.subject).toBeDefined(); - - function spin(n: number) { - for (let i = 0; i < n; i++) { - serverSocket.getPeerCertificate(); - serverSocket.getPeerCertificate(false); + const client = tls.connect({ + host: "127.0.0.1", + port: (server.address() as AddressInfo).port, + key: clientTls.key, + cert: clientTls.cert, + ca: [serverTls.ca], + checkServerIdentity, + }); + await once(client, "secureConnect"); + + const serverSocket = await serverSocketPromise; + try { + // Make sure the client actually sent a cert so we exercise the + // SSL_get_peer_certificate path rather than falling through to the + // cert-chain branch. + const first = serverSocket.getPeerCertificate(); + expect(first).toBeDefined(); + expect(first?.subject).toBeDefined(); + + function spin(n: number) { + for (let i = 0; i < n; i++) { + serverSocket.getPeerCertificate(); + serverSocket.getPeerCertificate(false); + } + Bun.gc(true); + Bun.gc(true); } - Bun.gc(true); - Bun.gc(true); - } - // Run in fixed-size rounds with a GC after each so the steady-state - // heap footprint stays bounded. The first few rounds grow the heap - // regardless of leaks, so take the baseline after warmup. - const perRound = isDebug ? 2_500 : 5_000; - for (let round = 0; round < 4; round++) spin(perRound); - const baseline = process.memoryUsage.rss(); - - for (let round = 0; round < 10; round++) spin(perRound); - const after = process.memoryUsage.rss(); - const growth = after - baseline; - - // Unpatched, the BIO leak alone is ~800 bytes/call → ~40MB over the - // 50k abbreviated calls here (~20MB for 25k in debug). Leave slack for - // allocator/ASAN noise but stay well below that. - const threshold = 1024 * 1024 * (isDebug ? 10 : isASAN ? 16 : 12); - expect(growth).toBeLessThan(threshold); - } finally { - client.end(); - serverSocket.end(); - server.close(); - } -}, 180_000); + // Run in fixed-size rounds with a GC after each so the steady-state + // heap footprint stays bounded. The first few rounds grow the heap + // regardless of leaks, so take the baseline after warmup. + const perRound = isDebug ? 2_500 : 5_000; + for (let round = 0; round < 4; round++) spin(perRound); + const baseline = process.memoryUsage.rss(); + + for (let round = 0; round < 10; round++) spin(perRound); + const after = process.memoryUsage.rss(); + const growth = after - baseline; + + // Unpatched, the BIO leak alone is ~800 bytes/call → ~40MB over the + // 50k abbreviated calls here (~20MB for 25k in debug). Leave slack for + // allocator/ASAN noise but stay well below that. Both calls in the loop + // build the full leaf-certificate object (getPeerCertificate(false) used + // to return {}), so the debug budget covers 2x the constructions. Local + // debug (non-`bun-asan`) builds skip this test entirely - see skipIf above. + const threshold = 1024 * 1024 * (isDebug ? 20 : isASAN ? 16 : 12); + expect(growth).toBeLessThan(threshold); + } finally { + client.end(); + serverSocket.end(); + server.close(); + } + }, + 180_000, +); diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index 62344267b18e..e52d06ba7257 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -275,7 +275,10 @@ for (const { name, connect } of tests) { expect(cert.serialNumber).toBe("71A46AE89FD817EF81A34D5973E1DE42F09B9D63"); expect(cert.raw).toBeInstanceOf(Buffer); } finally { - socket.end(); + // Tear the socket down immediately: the local server is disposed right + // after this test, and a lingering half-closed connection would observe + // its hard close as ECONNRESET (Node surfaces the same error). + socket.destroy(); } }); @@ -544,7 +547,17 @@ it("setSession() should not leak the SSL_SESSION returned by d2i_SSL_SESSION", a // With it: ~5–10 MB (allocator noise, no per-call growth). await using proc = Bun.spawn({ cmd: [bunExe(), join(import.meta.dirname, "node-tls-set-session-leak.fixture.ts"), "20000"], - env: bunEnv, + env: { + ...bunEnv, + // ASAN's default 256MB quarantine retains every freed allocation, so + // RSS growth would measure the total allocation churn instead of leaks + // on any ASAN-instrumented build (including a local `bun bd` debug + // build, which is ASAN but not named `bun-asan`). Cap the quarantine + // so the measurement reflects live memory. + // Preserve the harness ASAN options (bunEnv sets allow_user_segv_handler / + // disable_coredump) instead of rebuilding from process.env only. + ASAN_OPTIONS: ["quarantine_size_mb=8", bunEnv.ASAN_OPTIONS ?? process.env.ASAN_OPTIONS].filter(Boolean).join(":"), + }, stdout: "pipe", stderr: "pipe", }); @@ -554,8 +567,127 @@ it("setSession() should not leak the SSL_SESSION returned by d2i_SSL_SESSION", a expect(calls).toBe(20000); // Leave generous headroom above the fixed-build measurement so unrelated // allocator changes don't turn this into a flaky test, while still being - // far below the ~125 MB leak signature. ASAN's quarantine retains freed - // allocations so widen the threshold there. - expect(growthBytes).toBeLessThan((isASAN ? 200 : 40) * 1024 * 1024); + // far below the ~125 MB leak signature. + expect(growthBytes).toBeLessThan((isASAN ? 60 : 40) * 1024 * 1024); expect(exitCode).toBe(0); }, 60_000); + +it.each([["TLSv1.2"], ["TLSv1.3"]] as const)( + "%s: data written after secureConnect is delivered both ways even when the server ends first", + async version => { + // Under TLS 1.2 the server finishes its handshake one flight before the + // client, so a write()+end() server has already sent its FIN by the time + // the client's reply arrives - the half-closed socket must keep reading. + const serverReceived: string[] = []; + const serverGotData = Promise.withResolvers(); + const server = tls.createServer({ ...COMMON_CERT_, minVersion: version, maxVersion: version }, socket => { + socket.on("data", d => { + serverReceived.push(d.toString()); + serverGotData.resolve(); + }); + socket.write("hello"); + socket.end(); + }); + server.listen(0); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + const client = tlsConnect({ port, host: "127.0.0.1", rejectUnauthorized: false }); + let clientReceived = ""; + client.on("data", d => (clientReceived += d)); + await once(client, "secureConnect"); + expect(client.getProtocol()).toBe(version); + client.write("hello"); + client.end(); + await once(client, "close"); + // The server's read of the client's last record happens on its own loop + // turn - wait for it instead of sleeping. + await serverGotData.promise; + expect(clientReceived).toBe("hello"); + expect(serverReceived.join("")).toBe("hello"); + server.close(); + await once(server, "close"); + }, +); + +it("tls.DEFAULT_MAX_VERSION is honored by contexts built without explicit versions", async () => { + const prev = tls.DEFAULT_MAX_VERSION; + try { + tls.DEFAULT_MAX_VERSION = "TLSv1.2"; + const server = tls.createServer({ ...COMMON_CERT_ }, socket => { + socket.end(); + }); + server.listen(0); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + const client = tlsConnect({ port, host: "127.0.0.1", rejectUnauthorized: false }); + await once(client, "secureConnect"); + expect(client.getProtocol()).toBe("TLSv1.2"); + client.end(); + await once(client, "close"); + server.close(); + await once(server, "close"); + } finally { + tls.DEFAULT_MAX_VERSION = prev; + } +}); + +it("'session' and 'keylog' are emitted for a TLSSocket over a duplex stream (tls.connect({ socket }))", async () => { + // The TLS-over-duplex wrapper has no us_socket_t, so its parked + // new-session/keylog queues are drained by the Rust SSLWrapper instead of + // us_dispatch_session/us_dispatch_keylog - this covers that path end to end. + const server = tls.createServer({ ...COMMON_CERT_ }, socket => { + socket.on("data", () => socket.end()); + }); + server.listen(0); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + + const raw = net.connect(port, "127.0.0.1"); + await once(raw, "connect"); + const duplex = new SocketProxy(raw); + const client = tls.connect({ socket: duplex, rejectUnauthorized: false }); + const sessionPromise = once(client, "session"); + const keylogPromise = once(client, "keylog"); + await once(client, "secureConnect"); + client.write("x"); + const [session] = await sessionPromise; + const [keylogLine] = await keylogPromise; + expect(Buffer.isBuffer(session)).toBe(true); + expect(session.length).toBeGreaterThan(0); + expect(Buffer.isBuffer(keylogLine)).toBe(true); + expect(keylogLine.length).toBeGreaterThan(0); + client.end(); + await once(client, "close"); + server.close(); + await once(server, "close"); +}); + +it("delivers 'session' even when the data handler destroys the socket immediately", async () => { + // The TLS1.3 NewSessionTickets ride in the same read pass as the response + // bytes. If the parked session were only flushed after the data dispatch, + // a consumer that tears the socket down inside 'data' (an https.Agent with + // keepAlive off destroys the tunneled socket as soon as the response + // completes) would silently lose the 'session' event - Node delivers the + // session before the data reaches JS. + const server = tls.createServer({ ...COMMON_CERT_ }, socket => { + socket.on("data", () => socket.write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")); + }); + server.listen(0); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + + let session = false; + const client = tlsConnect({ port, host: "127.0.0.1", rejectUnauthorized: false }, () => { + client.write("x"); + }); + client.on("session", () => (session = true)); + client.on("data", () => { + // Mirrors the agent flow: socket destroyed during the data dispatch, + // before any later flush could run. + client.destroy(); + }); + await once(client, "close"); + expect(session).toBe(true); + server.close(); + await once(server, "close"); +}); diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 14623946082c..ba3cc4a613c2 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -1,3 +1,4 @@ +import crypto from "crypto"; import { readFileSync, realpathSync } from "fs"; import { tls as cert1, isDebug } from "harness"; import { AddressInfo } from "net"; @@ -700,6 +701,56 @@ it("connectionListener should emit the right amount of times, and with alpnProto expect(count).toBe(50); }); +it("destroying the socket from inside SNICallback or ALPNCallback does not crash the process", async () => { + // Both callbacks run synchronously from inside the native handshake; a + // destroy() there must defer the SSL teardown until the handshake call + // unwinds instead of freeing it out from under BoringSSL. + const connections: Array<{ destroy(): void }> = []; + for (const extra of [ + { + ALPNCallback(this: unknown, { protocols }: { protocols: string[] }) { + (this as { destroy(): void }).destroy(); + return protocols[0]; + }, + }, + { + SNICallback(_name: string, cb: (err: Error | null, ctx?: unknown) => void) { + connections.at(-1)?.destroy(); + cb(null, undefined); + }, + }, + ]) { + // Declared above the for-of's iterable so the SNICallback closure (built + // once when the array literal is evaluated) captures it; reset per case. + connections.length = 0; + const server = tls.createServer({ key: cert1.key, cert: cert1.cert, ...extra }, socket => socket.end()); + server.on("connection", socket => connections.push(socket)); + server.on("tlsClientError", () => {}); + await new Promise(resolve => server.listen(0, resolve)); + const { port } = server.address() as AddressInfo; + await new Promise(resolve => { + const client = tls.connect( + { + port, + rejectUnauthorized: false, + ALPNProtocols: ["x/1"], + servername: "x.test", + checkServerIdentity: () => undefined, + }, + () => { + client.end(); + resolve(); + }, + ); + client.on("error", () => resolve()); + client.on("close", () => resolve()); + }); + server.close(); + } + // Reaching here without an abort/ASAN report is the assertion. + expect(true).toBe(true); +}); + it("leaves socket.authorized false unless a client certificate was requested and verified", async () => { // A server that never requested a client certificate must not report the // connection as authorized (matches Node.js fail-closed semantics). @@ -771,3 +822,409 @@ it("leaves socket.authorized false unless a client certificate was requested and } } }); + +it("createServer({pfx, requestCert}) verifies client certificates against the pfx-embedded CA", async () => { + // agent1.pfx bundles agent1's key/cert plus ca1; a server built from it must + // be able to verify a client certificate signed by that embedded CA. + const fixtures = join(import.meta.dir, "../test/fixtures/keys"); + const { promise, resolve, reject } = Promise.withResolvers(); + const server: Server = createServer( + { + pfx: readFileSync(join(fixtures, "agent1.pfx")), + passphrase: "sample", + requestCert: true, + rejectUnauthorized: false, + }, + socket => { + resolve(socket.authorized); + socket.end(); + }, + ); + server.on("error", reject); + server.listen(0); + await once(server, "listening"); + const address = server.address() as AddressInfo; + const client = connect({ + port: address.port, + host: "127.0.0.1", + key: readFileSync(join(fixtures, "agent1-key.pem"), "utf8"), + cert: readFileSync(join(fixtures, "agent1-cert.pem"), "utf8"), + rejectUnauthorized: false, + }); + client.on("error", reject); + try { + expect(await promise).toBe(true); + } finally { + client.end(); + server.close(); + } +}); + +it("SNICallback errors abort the handshake and surface as tlsClientError", async () => { + // Node drops the connection before the handshake completes (no TLS alert is + // sent) and emits 'tlsClientError' on the server with the callback's error. + const cases: [string, (name: string, cb: (err: Error | null, ctx?: unknown) => void) => void, string][] = [ + ["cb(error)", (_name, cb) => cb(new Error("sni rejected")), "sni rejected"], + ["invalid context", (_name, cb) => cb(null, {}), "Invalid SNI context"], + [ + "throw", + () => { + throw new Error("sni threw"); + }, + "sni threw", + ], + ]; + for (const [label, SNICallback, expectedMessage] of cases) { + const server: Server = createServer({ ...COMMON_CERT, SNICallback }); + const tlsClientErrors: Error[] = []; + server.on("tlsClientError", err => tlsClientErrors.push(err)); + server.on("secureConnection", () => { + throw new Error(`secureConnection must not fire (${label})`); + }); + server.listen(0); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + const client = connect({ port, host: "127.0.0.1", servername: "a.example.com", rejectUnauthorized: false }); + const [clientErr] = (await once(client, "error")) as [Error]; + // The server dropped the connection before the handshake completed - the + // client must NOT see a TLS alert error. + expect(clientErr.message).toMatch(/disconnected before secure TLS connection was established|ECONNRESET/); + expect(tlsClientErrors.length).toBe(1); + expect(tlsClientErrors[0].message).toBe(expectedMessage); + server.close(); + await once(server, "close"); + } +}); + +it("SNICallback returning no context falls through to the default context", async () => { + const server: Server = createServer({ ...COMMON_CERT, SNICallback: (_name, cb) => cb(null, null) }, socket => { + socket.end(); + }); + server.on("tlsClientError", err => { + throw err; + }); + server.listen(0); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + const client = connect({ port, host: "127.0.0.1", servername: "a.example.com", rejectUnauthorized: false }); + await once(client, "secureConnect"); + client.end(); + server.close(); + await once(server, "close"); +}); + +it("ALPNCallback errors refuse the connection and surface as tlsClientError", async () => { + const cases: [ + string, + (arg: { servername: string; protocols: string[] }) => string | undefined, + RegExp | undefined, + RegExp, + ][] = [ + [ + "invalid result", + () => "not-offered", + /ERR_TLS_ALPN_CALLBACK_INVALID_RESULT/, + /did not match any of the client's offered protocols/, + ], + [ + "throw", + () => { + throw new Error("alpn threw"); + }, + undefined, + /alpn threw/, + ], + ]; + for (const [label, ALPNCallback, codeRe, msgRe] of cases) { + const server: Server = createServer({ ...COMMON_CERT, ALPNCallback }); + const tlsClientErrors: (Error & { code?: string })[] = []; + server.on("tlsClientError", err => tlsClientErrors.push(err)); + server.on("secureConnection", () => { + throw new Error(`secureConnection must not fire (${label})`); + }); + server.listen(0); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + const client = connect({ + port, + host: "127.0.0.1", + ALPNProtocols: ["http/1.1", "h2"], + rejectUnauthorized: false, + }); + // The client gets the fatal no_application_protocol alert (or sees the + // connection drop) - either way the connection must fail. + await once(client, "error"); + expect(tlsClientErrors.length).toBe(1); + if (codeRe) expect(String(tlsClientErrors[0].code)).toMatch(codeRe); + expect(tlsClientErrors[0].message).toMatch(msgRe); + server.close(); + await once(server, "close"); + } +}); + +it("ALPNCallback returning an offered protocol completes the handshake with it", async () => { + const server: Server = createServer({ ...COMMON_CERT, ALPNCallback: () => "h2" }, socket => { + expect((socket as TLSSocket).alpnProtocol).toBe("h2"); + socket.end(); + }); + server.on("tlsClientError", err => { + throw err; + }); + server.listen(0); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + const client = connect({ port, host: "127.0.0.1", ALPNProtocols: ["http/1.1", "h2"], rejectUnauthorized: false }); + await once(client, "secureConnect"); + expect(client.alpnProtocol).toBe("h2"); + client.end(); + server.close(); + await once(server, "close"); +}); + +it("an asynchronous SNICallback suspends the handshake and resumes with the selected context", async () => { + // The callback resolves on a later tick - the handshake must wait for it + // (BoringSSL select-certificate retry) instead of falling through to the + // default context. + const sniCert = { ...COMMON_CERT }; + let callbackRan = false; + const server: Server = createServer({ + ...COMMON_CERT, + SNICallback: (name, cb) => { + setTimeout(() => { + callbackRan = true; + expect(name).toBe("async.example.com"); + cb(null, tls.createSecureContext(sniCert)); + }, 50); + }, + }); + server.on("secureConnection", socket => { + expect((socket as TLSSocket).servername).toBe("async.example.com"); + socket.end(); + }); + server.on("tlsClientError", err => { + throw err; + }); + server.listen(0); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + const client = connect({ port, host: "127.0.0.1", servername: "async.example.com", rejectUnauthorized: false }); + await once(client, "secureConnect"); + expect(callbackRan).toBe(true); + client.end(); + await once(client, "close"); + server.close(); + await once(server, "close"); +}); + +it("an asynchronous SNICallback error aborts the suspended handshake with tlsClientError", async () => { + const server: Server = createServer({ + ...COMMON_CERT, + SNICallback: (_name, cb) => { + setTimeout(() => cb(new Error("async sni rejected")), 50); + }, + }); + const tlsClientErrors: Error[] = []; + server.on("tlsClientError", err => tlsClientErrors.push(err)); + server.on("secureConnection", () => { + throw new Error("secureConnection must not fire"); + }); + server.listen(0); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + const client = connect({ port, host: "127.0.0.1", servername: "rejected.example.com", rejectUnauthorized: false }); + await once(client, "error"); + expect(tlsClientErrors.length).toBe(1); + expect(tlsClientErrors[0].message).toBe("async sni rejected"); + server.close(); + await once(server, "close"); +}); + +it("destroying the connection while an asynchronous SNICallback is pending does not crash", async () => { + let resolveLater: (() => void) | undefined; + const server: Server = createServer({ + ...COMMON_CERT, + SNICallback: (_name, cb) => { + // Resolve only after the client is long gone. + resolveLater = () => cb(null, tls.createSecureContext({ ...COMMON_CERT })); + }, + }); + server.on("tlsClientError", () => {}); + server.listen(0); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + const client = connect({ port, host: "127.0.0.1", servername: "gone.example.com", rejectUnauthorized: false }); + client.on("error", () => {}); + // Give the ClientHello time to reach the server and suspend, then kill the client. + await new Promise(r => setTimeout(r, 100)); + client.destroy(); + await new Promise(r => setTimeout(r, 100)); + // The late resolution must be a harmless no-op. + resolveLater?.(); + await new Promise(r => setTimeout(r, 100)); + server.close(); + await once(server, "close"); + expect(true).toBe(true); +}); + +it("SNICallback accepts a raw native context (Node's context.context || context)", async () => { + // cb(null, secureContext.context) - passing the unwrapped native context - + // must select it, same as passing the wrapper. + const server: Server = createServer({ + ...COMMON_CERT, + SNICallback: (_name, cb) => { + cb(null, (tls.createSecureContext(COMMON_CERT) as any).context); + }, + }); + server.on("secureConnection", socket => socket.end()); + server.on("tlsClientError", err => { + throw err; + }); + server.listen(0); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + const client = connect({ port, host: "127.0.0.1", servername: "raw.example.com", rejectUnauthorized: false }); + await once(client, "secureConnect"); + expect(client.authorized).toBe(false); // self-signed, but the handshake completed + client.end(); + await once(client, "close"); + server.close(); + await once(server, "close"); +}); + +it("SNICallback runs even when the requested servername matches the bind hostname", async () => { + // Node calls a user SNICallback for every SNI; the listener's own bind + // hostname being pre-registered internally must not shadow it. The callback + // selects a DIFFERENT certificate (the RSA fixture) than the server's own + // (COMMON_CERT), and the client must actually receive the callback's pick - + // not just observe that the callback ran while the internal entry's cert + // got presented anyway. + let sniCalls = 0; + const sniCert = tls.createSecureContext({ key: rawKey, cert: cert }); + const server: Server = createServer({ + ...COMMON_CERT, + SNICallback: (name, cb) => { + sniCalls++; + expect(name).toBe("localhost"); + cb(null, sniCert); + }, + }); + server.on("secureConnection", socket => socket.end()); + server.on("tlsClientError", err => { + throw err; + }); + server.listen(0, "localhost"); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + // host: "localhost" defaults servername to "localhost" - the bind hostname. + const client = connect({ port, host: "localhost", rejectUnauthorized: false }); + await once(client, "secureConnect"); + expect(sniCalls).toBe(1); + // The peer certificate must be the SNICallback's RSA cert, not COMMON_CERT. + const peerCert = client.getPeerCertificate(); + const expectedCert = new crypto.X509Certificate(cert); + expect(peerCert.fingerprint256).toBe(expectedCert.fingerprint256); + client.end(); + await once(client, "close"); + server.close(); + await once(server, "close"); +}); + +it("setSecureContext() clears omitted options instead of keeping stale values", async () => { + const server: Server = createServer({ + ...COMMON_CERT, + ca: [COMMON_CERT.cert], + ciphers: "TLS_AES_256_GCM_SHA384", + }); + expect((server as any).ca).toEqual([COMMON_CERT.cert]); + expect((server as any).ciphers).toBe("TLS_AES_256_GCM_SHA384"); + // Replacing the context without ca/ciphers must clear them (Node resets + // omitted fields), not silently keep the previous call's values. + server.setSecureContext({ ...COMMON_CERT }); + expect((server as any).ca).toBeUndefined(); + expect((server as any).ciphers).toBeUndefined(); + expect((server as any).cert).toBe(COMMON_CERT.cert); + expect((server as any).key).toBe(COMMON_CERT.key); +}); + +it("SNICallback rejecting with a non-Error value drops the connection (no hang)", async () => { + // cb(true) / cb("reason"): Node treats any truthy err as an abort. The + // boolean form must not be confused with internal sentinels - the + // connection is dropped, not suspended. + for (const rejection of [true, "rejected", "throw"] as const) { + const server: Server = createServer({ + ...COMMON_CERT, + SNICallback: (_name, cb) => { + // "throw" exercises the synchronous-throw path (throw true), which + // must be normalized the same way as cb(non-Error). + if (rejection === "throw") throw true; + cb(rejection as any); + }, + }); + const clientErrors: Error[] = []; + server.on("tlsClientError", err => clientErrors.push(err)); + server.listen(0); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + const client = connect({ port, host: "127.0.0.1", servername: "reject.example.com", rejectUnauthorized: false }); + const [err] = await once(client, "error"); + expect((err as Error).message).toMatch(/disconnected before secure|ECONNRESET/); + expect(clientErrors.length).toBe(1); + server.close(); + await once(server, "close"); + } +}); + +it("an asynchronous SNICallback resolving cb(null, null) falls back like the synchronous form", async () => { + // Async null selection must take the same fallback path as sync null - the + // handshake completes with the server's own certificate. + const server: Server = createServer({ + ...COMMON_CERT, + SNICallback: (_name, cb) => { + setTimeout(() => cb(null, null as any), 30); + }, + }); + server.on("secureConnection", socket => socket.end()); + server.on("tlsClientError", err => { + throw err; + }); + server.listen(0); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + const client = connect({ port, host: "127.0.0.1", servername: "fallback.example.com", rejectUnauthorized: false }); + await once(client, "secureConnect"); + const expectedCert = new crypto.X509Certificate(COMMON_CERT.cert); + expect(client.getPeerCertificate().fingerprint256).toBe(expectedCert.fingerprint256); + client.end(); + await once(client, "close"); + server.close(); + await once(server, "close"); +}); + +it("an asynchronous SNICallback resolving cb(null, null) still honors addContext entries", async () => { + // The async-null fallback must consult the static SNI tree with the + // servername, not just fall to the default context: addContext's cert is + // the one the client must receive. + const altCert = { key: rawKey, cert: cert }; + const server: Server = createServer({ + ...COMMON_CERT, + SNICallback: (_name, cb) => { + setTimeout(() => cb(null, null as any), 30); + }, + }); + server.addContext("alt.example.com", altCert); + server.on("secureConnection", socket => socket.end()); + server.on("tlsClientError", err => { + throw err; + }); + server.listen(0); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + const client = connect({ port, host: "127.0.0.1", servername: "alt.example.com", rejectUnauthorized: false }); + await once(client, "secureConnect"); + const expectedCert = new crypto.X509Certificate(cert); + expect(client.getPeerCertificate().fingerprint256).toBe(expectedCert.fingerprint256); + client.end(); + await once(client, "close"); + server.close(); + await once(server, "close"); +}); diff --git a/test/js/node/tls/ssl-ctx-cache.test.ts b/test/js/node/tls/ssl-ctx-cache.test.ts index 189b349ceb8b..96d2684a05a1 100644 --- a/test/js/node/tls/ssl-ctx-cache.test.ts +++ b/test/js/node/tls/ssl-ctx-cache.test.ts @@ -9,7 +9,7 @@ import tls from "node:tls"; // @ts-expect-error - debug-only export import { sslCtxLiveCount } from "bun:internal-for-testing"; import { tempDir, tls as tlsCerts } from "harness"; -import { writeFileSync } from "node:fs"; +import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; async function withServer(fn: (port: number) => Promise) { @@ -75,16 +75,19 @@ test("Bun.connect with servername-only tls reuses one SSL_CTX", async () => { } }); -// `tls.createSecureContext()` is now WeakGCMap-memoised by digest in native -// code (replacing the SHA-256/WeakRef Map that lived in tls.ts), so the same -// options return the same native handle. -test("createSecureContext returns the same native handle for identical configs", () => { +// The user-facing `tls.createSecureContext()` is uncached: every call owns its +// SSL_CTX exclusively (so addCACert on one context can never leak into +// another); only internal consumers (tls.connect / Bun.connect / fetch) share +// contexts through the per-digest native cache. +test("createSecureContext owns its native handle exclusively (identical configs get distinct SSL_CTXs)", () => { const opts = { ca: tlsCerts.cert, rejectUnauthorized: false }; const a = tls.createSecureContext(opts); const b = tls.createSecureContext({ ...opts }); // The JS wrapper carries per-call `servername`, so wrappers differ; the // SSL_CTX-owning `.context` is the deduped native cell. - expect(a.context).toBe(b.context); + // The user-facing createSecureContext() owns its SSL_CTX exclusively so + // addCACert on one context can never affect another. + expect(a.context).not.toBe(b.context); // Different config → different handle. const c = tls.createSecureContext({ rejectUnauthorized: false }); expect(c.context).not.toBe(a.context); @@ -177,14 +180,14 @@ test("file-backed config: in-place rotation invalidates cache (mtime+size in dig const caFile = join(String(dir), "ca.pem"); await withServer(async port => { - // Pin the wrapped SecureContext so GC between connects can't drop the - // count and turn the strict equalities below into flakes — `.context` is - // populated from the Symbol-keyed slot via `createSecureContext`. + // Exercise the cached connect path (which memoises by config digest); + // the user-facing createSecureContext() now owns its SSL_CTX exclusively, + // so it would create a fresh CTX per call and defeat the cache this test + // is about. Pin each socket so GC between connects can't drop the count. const pin: unknown[] = []; const connectOnce = async () => { - const sc = tls.createSecureContext({ caFile, rejectUnauthorized: false } as any); - pin.push(sc); - const s = tls.connect({ port, secureContext: sc }); + const s = tls.connect({ port, caFile, rejectUnauthorized: false } as any); + pin.push(s); await once(s, "secureConnect"); s.destroy(); await once(s, "close"); @@ -207,3 +210,116 @@ test("file-backed config: in-place rotation invalidates cache (mtime+size in dig pin.length = 0; }); }); + +test("addCACert on one user-facing context does not affect another with identical options", () => { + const a = tls.createSecureContext({}); + const b = tls.createSecureContext({}); + expect(a.context).not.toBe(b.context); + a.context.addCACert(tlsCerts.cert); + // b's native context is a different object and stays untouched. + expect(a.context).not.toBe(b.context); +}); + +test("setDefaultCACertificates() override applies to plain tls.connect (no explicit ca)", async () => { + const keys = (f: string) => readFileSync(join(import.meta.dir, "../test/fixtures/keys", f)); + const prev = tls.getCACertificates("default"); + try { + tls.setDefaultCACertificates([keys("ca1-cert.pem").toString()]); + const server = tls.createServer({ key: keys("agent1-key.pem"), cert: keys("agent1-cert.pem") }, s => s.end("ok")); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as any).port; + const socket = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: true, servername: "agent1" }); + await once(socket, "secureConnect"); + expect(socket.authorized).toBe(true); + socket.destroy(); + server.close(); + } finally { + tls.setDefaultCACertificates(prev); + } +}); + +test("ca: [] skips the setDefaultCACertificates override (distinct from ca: undefined)", async () => { + // Providing any `ca` value - including an empty array - bypasses the + // process-default override that setDefaultCACertificates() installs (the + // override only applies when `ca` is absent), so the connection verifies + // against the bundled roots instead. NOTE: this is not Node's full + // "ca: [] = empty trust store" semantics (an explicitly-empty list should + // trust NOTHING, not fall back to bundled roots) - that needs an explicit + // empty-CA flag through the native config and remains a follow-up. Make a + // fixture CA a process default first so the two cases are observably + // different. + const keys = (f: string) => readFileSync(join(import.meta.dir, "../test/fixtures/keys", f), "utf8"); + const prevCerts = tls.getCACertificates("default"); + tls.setDefaultCACertificates([keys("ca1-cert.pem")]); + try { + const server = tls.createServer({ key: keys("agent1-key.pem"), cert: keys("agent1-cert.pem") }, s => s.end()); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const port = (server.address() as import("net").AddressInfo).port; + + // ca undefined -> the process defaults (which now include ca1) -> authorized. + const c1 = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false, servername: "agent1" }); + await once(c1, "secureConnect"); + expect(c1.authorized).toBe(true); + c1.end(); + await once(c1, "close"); + + // ca: [] -> the override is skipped, the bundled roots apply (which do not + // include ca1) -> NOT authorized. + const c2 = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false, servername: "agent1", ca: [] }); + await once(c2, "secureConnect"); + expect(c2.authorized).toBe(false); + expect(c2.authorizationError).toBeTruthy(); + c2.end(); + await once(c2, "close"); + + server.close(); + await once(server, "close"); + } finally { + tls.setDefaultCACertificates(prevCerts); + } +}); + +test("setDefaultCACertificates() applies to a server's client-cert verification (no explicit ca)", async () => { + // The server path (setSecureContext -> Bun.listen) does not go through + // InternalSecureContext; the process-default override must still apply so + // an mTLS server with no explicit `ca` verifies client certificates against + // the overridden defaults rather than the bundled roots. + const keys = (f: string) => readFileSync(join(import.meta.dir, "../test/fixtures/keys", f), "utf8"); + const prevCerts = tls.getCACertificates("default"); + tls.setDefaultCACertificates([keys("ca1-cert.pem")]); + try { + const server = tls.createServer({ + key: keys("agent1-key.pem"), + cert: keys("agent1-cert.pem"), + requestCert: true, + rejectUnauthorized: false, + }); + const authorized = Promise.withResolvers(); + server.on("secureConnection", socket => { + authorized.resolve(socket.authorized); + socket.end(); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const port = (server.address() as import("net").AddressInfo).port; + + // The client presents agent1's cert (signed by ca1, which is now a process + // default). The server must verify it as authorized. + const client = tls.connect({ + port, + host: "127.0.0.1", + rejectUnauthorized: false, + key: keys("agent1-key.pem"), + cert: keys("agent1-cert.pem"), + }); + await once(client, "secureConnect"); + expect(await authorized.promise).toBe(true); + client.end(); + await once(client, "close"); + server.close(); + await once(server, "close"); + } finally { + tls.setDefaultCACertificates(prevCerts); + } +}); diff --git a/test/js/node/tls/tls-connect-socket-churn.test.ts b/test/js/node/tls/tls-connect-socket-churn.test.ts index fd08c38a167c..306ffc164b57 100644 --- a/test/js/node/tls/tls-connect-socket-churn.test.ts +++ b/test/js/node/tls/tls-connect-socket-churn.test.ts @@ -59,12 +59,14 @@ test("tls.connect churn does not leak SSL_CTX or us_socket_context_t", async () // crypto, not a wait-for-condition. }, 30_000); -test("createSecureContext memoises the native SSL_CTX (not the wrapper) by config", () => { +test("createSecureContext owns its native SSL_CTX exclusively (fresh wrapper too)", () => { const a = tls.createSecureContext({ cert: tlsCerts.cert }); const b = tls.createSecureContext({ cert: tlsCerts.cert, servername: "other.example" }); - // Same SSL_CTX-relevant fields → same native handle… - expect(a.context).toBe(b.context); - // …but the wrapper is fresh so per-call fields don't leak across callers. + // The user-facing constructor owns its SSL_CTX exclusively so addCACert on + // one context can never affect another; only the internal connect/listen + // paths memoise by config digest. + expect(a.context).not.toBe(b.context); + // The wrapper is fresh too, so per-call fields don't leak across callers. expect(a).not.toBe(b); expect(b.servername).toBe("other.example"); expect(a.servername).toBeUndefined(); diff --git a/test/js/web/fetch/chunked-trailing.test.js b/test/js/web/fetch/chunked-trailing.test.js index d24cd9f77b35..9e458160ad40 100644 --- a/test/js/web/fetch/chunked-trailing.test.js +++ b/test/js/web/fetch/chunked-trailing.test.js @@ -5,6 +5,7 @@ it("handles trailing headers split across packets", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -35,6 +36,7 @@ it("handles trailing headers in a single packet", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -60,6 +62,7 @@ it("handles trailing headers with empty body", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -84,6 +87,7 @@ it("handles multiple trailing headers", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -111,6 +115,7 @@ it("handles trailing headers with very long delay", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -139,6 +144,7 @@ it("handles trailing headers with byte-by-byte transmission", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -178,6 +184,7 @@ it("handles trailing headers with malformed format (missing final CRLF)", async const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -204,6 +211,7 @@ it("handles trailing headers with extremely large values", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -229,6 +237,7 @@ it("handles connection close during trailing headers", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -254,6 +263,7 @@ it("handles trailing headers with multiple header lines", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -281,6 +291,7 @@ it("handles trailing headers with empty values", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -306,6 +317,7 @@ it("handles delayed trailing headers", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -335,6 +347,7 @@ it("handles trailing headers after the final chunk only", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -367,6 +380,7 @@ it("handles chunked extensions with empty extension", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -393,6 +407,7 @@ it("handles chunked extensions with simple key", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -419,6 +434,7 @@ it("handles chunked extensions with key-value pair", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -445,6 +461,7 @@ it("handles chunked extensions with quoted value", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -471,6 +488,7 @@ it("handles chunked extensions on multiple chunks", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -502,6 +520,7 @@ it("handles chunked extensions with trailing headers", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -532,6 +551,7 @@ it("handles chunked extensions with special characters", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -558,6 +578,7 @@ it("proper error if missing zero-length chunk", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -589,6 +610,7 @@ it("proper error if missing data in middle of chunk extension", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -622,6 +644,7 @@ it("proper error if missing CRLF after chunk data", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); diff --git a/test/js/web/fetch/fetch-leak.test.ts b/test/js/web/fetch/fetch-leak.test.ts index f32c2c7fbd99..7d142fc760ec 100644 --- a/test/js/web/fetch/fetch-leak.test.ts +++ b/test/js/web/fetch/fetch-leak.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, tls as COMMON_CERT, gc, isASAN, isCI } from "harness"; +import { bunEnv, bunExe, tls as COMMON_CERT, gc, isASAN, isCI, isDebug } from "harness"; import { once } from "node:events"; import { createServer } from "node:http"; import { join } from "node:path"; @@ -143,7 +143,9 @@ describe.each(["FormData", "Blob", "Buffer", "String", "URLSearchParams", "strea } expect(last).toBeLessThan(first * 10); }, - 20 * 1000, + // The URLSearchParams variant URL-encodes the 2MB body on each of the 500 + // requests - pure throughput that a debug build cannot fit in 20s. + isDebug ? 120 * 1000 : 20 * 1000, ); }); diff --git a/test/js/web/fetch/fetch-tls-abortsignal-timeout.test.ts b/test/js/web/fetch/fetch-tls-abortsignal-timeout.test.ts index 67eaeb02d77e..ff174b9f41a1 100644 --- a/test/js/web/fetch/fetch-tls-abortsignal-timeout.test.ts +++ b/test/js/web/fetch/fetch-tls-abortsignal-timeout.test.ts @@ -1,5 +1,5 @@ import { expect, it } from "bun:test"; -import { expiredTls, tls as validTls } from "harness"; +import { expiredTls, isDebug, tls as validTls } from "harness"; const CERT_LOCALHOST_IP = { ...validTls }; const CERT_EXPIRED = { ...expiredTls }; @@ -13,7 +13,10 @@ for (const timeout of [0, 1, 10, 20, 100, 300]) { return new Response("Hello World"); }, }); - const THRESHOLD = 50; + // The whole budget for timeout(0) is TLS-fetch setup + abort plumbing, + // which a debug build exceeds; still asserts the abort lands well before + // the server's 1000ms reply. + const THRESHOLD = isDebug ? 500 : 50; const time = performance.now(); try { diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index b5bbca34e6ec..d289fc0a3b65 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -9,6 +9,7 @@ import { gc, isASAN, isBroken, + isDebug, isFlaky, isMacOS, isWindows, @@ -2447,6 +2448,10 @@ describe("fetch should allow duplex", () => { it("should allow to follow redirect if connection is closed, abort should work even if the socket was closed before the redirect", async () => { for (const type of ["normal", "delay"]) { await using server = net.createServer(socket => { + // Raw test server: tolerate client aborts, surface anything unexpected. + socket.on("error", (err: NodeJS.ErrnoException) => { + if (err.code !== "ECONNRESET" && err.code !== "EPIPE" && err.code !== "ECONNABORTED") throw err; + }); let body = ""; socket.on("data", data => { body += data.toString("utf8"); @@ -2765,7 +2770,10 @@ it("releases interim 1xx response bytes as they are parsed while waiting for the // Only a small parse tail may be retained while the interim responses stream in; // the ~48 MB of already-consumed 1xx bytes must not accumulate in the process. const deltaMB = (rssDuringFlood - rssBefore) / 1024 / 1024; - expect(deltaMB).toBeLessThan(isASAN ? 48 : 16); + // A local `bun bd` debug build is ASAN-instrumented but not named + // `bun-asan`, so isASAN is false there; its quarantine retains the freed + // flood bytes the same way - give it the same allowance. + expect(deltaMB).toBeLessThan(isASAN || isDebug ? 48 : 16); } finally { for (const socket of sockets) socket.destroy(); server.close(); diff --git a/test/js/web/websocket/websocket-permessage-deflate-edge-cases.test.ts b/test/js/web/websocket/websocket-permessage-deflate-edge-cases.test.ts index 37a718e03465..b6645ea5ae10 100644 --- a/test/js/web/websocket/websocket-permessage-deflate-edge-cases.test.ts +++ b/test/js/web/websocket/websocket-permessage-deflate-edge-cases.test.ts @@ -221,6 +221,10 @@ test("WebSocket client rejects decompression bombs", async () => { const port = await serverReady; tcpServer.on("connection", socket => { + // Raw test server: tolerate client aborts, surface anything unexpected. + socket.on("error", (err: NodeJS.ErrnoException) => { + if (err.code !== "ECONNRESET" && err.code !== "EPIPE" && err.code !== "ECONNABORTED") throw err; + }); let buffer = Buffer.alloc(0); socket.on("data", data => { diff --git a/test/js/web/websocket/websocket-subprotocol-strict.test.ts b/test/js/web/websocket/websocket-subprotocol-strict.test.ts index b393194e160a..0cb1c35dd7c2 100644 --- a/test/js/web/websocket/websocket-subprotocol-strict.test.ts +++ b/test/js/web/websocket/websocket-subprotocol-strict.test.ts @@ -17,6 +17,10 @@ describe("WebSocket strict RFC 6455 subprotocol handling", () => { }); server.on("connection", socket => { + // Raw test server: tolerate client aborts, surface anything unexpected. + socket.on("error", (err: NodeJS.ErrnoException) => { + if (err.code !== "ECONNRESET" && err.code !== "EPIPE" && err.code !== "ECONNABORTED") throw err; + }); let requestData = ""; socket.on("data", data => { diff --git a/test/no-validate-leaksan.txt b/test/no-validate-leaksan.txt index 8dde0b650a6c..3dd7aa3dd320 100644 --- a/test/no-validate-leaksan.txt +++ b/test/no-validate-leaksan.txt @@ -216,7 +216,6 @@ test/js/node/test/parallel/test-http-dummy-characters-smuggling.js test/js/node/test/parallel/test-http-missing-header-separator-lf.js test/js/node/test/parallel/test-http-invalid-te.js test/js/node/test/parallel/test-http-missing-header-separator-cr.js -test/js/node/test/parallel/test-http-server-reject-chunked-with-content-length.js test/js/node/test/parallel/test-http-chunked-smuggling.js test/js/node/test/parallel/test-http-double-content-length.js test/js/node/test/parallel/test-http-blank-header.js @@ -386,6 +385,9 @@ test/js/node/vm/vm.test.ts # VM has terminated test/js/node/test/parallel/test-net-during-close.js +test/js/node/test/parallel/test-net-socket-reset-send.js +test/js/node/test/parallel/test-net-connect-reset-after-destroy.js +test/js/node/test/parallel/test-net-connect-reset-until-connected.js # JSC::BuiltinNames::~BuiltinNames test/js/bun/shell/shell-hang.test.ts @@ -446,4 +448,7 @@ test/js/bun/test/parallel/test-http-should-not-accept-untrusted-certificates.ts test/js/node/test/parallel/test-https-localaddress-bind-error.js test/js/node/test/parallel/test-crypto-op-during-process-exit.js -test/js/third_party/prisma/prisma.test.ts \ No newline at end of file +test/js/third_party/prisma/prisma.test.ts +# upgradeDuplexToTLS protos/server_name Box<[u8]> are owned by the JS-held +# socket cell; tests that exit before a final GC report them as indirect leaks. +test/js/node/tls/node-tls-connect.test.ts From ce06dce0198a97bb92b09b799673a361c2755812 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Tue, 16 Jun 2026 14:34:43 -0700 Subject: [PATCH 5/5] =?UTF-8?q?net,tls:=20address=20review=20nits=20?= =?UTF-8?q?=E2=80=94=20pfx=20mac=20arm=20+=20passphrase=20coercion=20befor?= =?UTF-8?q?e=20borrow,=20register=20ERR=5FTLS=5FALPN=5FCALLBACK=5FINVALID?= =?UTF-8?q?=5FRESULT,=20drop=20redundant=20requestCert=20guards,=20drop=20?= =?UTF-8?q?addressType=20from=20connect=20perf=20detail=20[build=20images]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/js/builtins.d.ts | 1 + src/js/node/net.ts | 14 +++++--------- src/jsc/ErrorCode.rs | 8 +++++++- src/jsc/bindings/ErrorCode.ts | 1 + src/runtime/api/bun/SecureContext.rs | 21 ++++++++++++--------- 5 files changed, 26 insertions(+), 19 deletions(-) diff --git a/src/js/builtins.d.ts b/src/js/builtins.d.ts index 328356a9ee70..ca6ef21a762b 100644 --- a/src/js/builtins.d.ts +++ b/src/js/builtins.d.ts @@ -752,6 +752,7 @@ declare function $ERR_VM_MODULE_NOT_MODULE(): Error; declare function $ERR_VM_MODULE_DIFFERENT_CONTEXT(): Error; declare function $ERR_VM_MODULE_LINK_FAILURE(message: string, cause: Error): Error; declare function $ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS(): TypeError; +declare function $ERR_TLS_ALPN_CALLBACK_INVALID_RESULT(message: string): TypeError; declare function $ERR_HTTP2_TOO_MANY_CUSTOM_SETTINGS(): Error; declare function $ERR_HTTP2_CONNECT_AUTHORITY(): Error; declare function $ERR_HTTP2_CONNECT_SCHEME(): Error; diff --git a/src/js/node/net.ts b/src/js/node/net.ts index a28ca4bc4646..19e7a2306ed1 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -635,10 +635,9 @@ const ServerHandlers: SocketHandler = { // Node: the callback selected a protocol the client did not offer - // refuse the connection and report ERR_TLS_ALPN_CALLBACK_INVALID_RESULT // through 'tlsClientError'. - const err = new TypeError( + const err = $ERR_TLS_ALPN_CALLBACK_INVALID_RESULT( `ALPN callback returned a value (${result}) that did not match any of the client's offered protocols (${ArrayPrototypeJoin.$call(protocols, ", ")})`, - ) as TypeError & { code?: string }; - err.code = "ERR_TLS_ALPN_CALLBACK_INVALID_RESULT"; + ); if (self) self[kALPNError] = err; return undefined; } @@ -787,10 +786,7 @@ const ServerHandlers: SocketHandler = { self.authorized = false; self.authorizationError = verifyError.code || verifyError.message; server?.emit("tlsClientError", verifyError, self); - // Node only enforces client-cert verification (and the resulting destroy) - // when the server actually requested a cert; a server without requestCert - // leaves `authorized` false but keeps the connection open. - if (self._rejectUnauthorized && self._requestCert) { + if (self._rejectUnauthorized) { // if we reject we still need to emit secure self.emit("secure", self); // No error argument: the socket has no 'error' listener yet, so destroy(err) @@ -798,7 +794,7 @@ const ServerHandlers: SocketHandler = { self.destroy(); return; } - } else if (self._requestCert) { + } else { self.authorized = true; } } @@ -2740,7 +2736,7 @@ function internalConnectMultiple(context, canceled?) { startPerf(context, kPerfHooksNetConnectContext, { type: "net", name: "connect", - detail: { host: address, port, addressType }, + detail: { host: address, port }, }); } diff --git a/src/jsc/ErrorCode.rs b/src/jsc/ErrorCode.rs index 28024ed1ae0f..6e3c6f15c27c 100644 --- a/src/jsc/ErrorCode.rs +++ b/src/jsc/ErrorCode.rs @@ -695,8 +695,11 @@ impl ErrorCode { /// `ERR_HTTP2_GOAWAY_SESSION` pub const HTTP2_GOAWAY_SESSION: ErrorCode = ErrorCode(318); + /// `ERR_TLS_ALPN_CALLBACK_INVALID_RESULT` (instanceof TypeError) + pub const TLS_ALPN_CALLBACK_INVALID_RESULT: ErrorCode = ErrorCode(319); + /// == C++ `NODE_ERROR_COUNT`. - pub const COUNT: u16 = 319; + pub const COUNT: u16 = 320; } // ────────────────────────────────────────────────────────────────────────── @@ -998,6 +1001,8 @@ impl ErrorCode { pub const ERR_TLS_SNI_FROM_SERVER: ErrorCode = ErrorCode::TLS_SNI_FROM_SERVER; pub const ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS: ErrorCode = ErrorCode::TLS_ALPN_CALLBACK_WITH_PROTOCOLS; + pub const ERR_TLS_ALPN_CALLBACK_INVALID_RESULT: ErrorCode = + ErrorCode::TLS_ALPN_CALLBACK_INVALID_RESULT; pub const ERR_SSL_NO_CIPHER_MATCH: ErrorCode = ErrorCode::SSL_NO_CIPHER_MATCH; pub const ERR_UNAVAILABLE_DURING_EXIT: ErrorCode = ErrorCode::UNAVAILABLE_DURING_EXIT; pub const ERR_UNCAUGHT_EXCEPTION_CAPTURE_ALREADY_SET: ErrorCode = @@ -1395,6 +1400,7 @@ static CODE_STR: [&str; ErrorCode::COUNT as usize] = [ "ERR_POSTGRES_CONNECTION_REFUSED", "ERR_MYSQL_CONNECTION_REFUSED", "ERR_HTTP2_GOAWAY_SESSION", + "ERR_TLS_ALPN_CALLBACK_INVALID_RESULT", ]; // ────────────────────────────────────────────────────────────────────────── diff --git a/src/jsc/bindings/ErrorCode.ts b/src/jsc/bindings/ErrorCode.ts index a43f8a4fa2fc..f5291369d788 100644 --- a/src/jsc/bindings/ErrorCode.ts +++ b/src/jsc/bindings/ErrorCode.ts @@ -331,5 +331,6 @@ const errors: ErrorCodeMapping = [ // Appended (not alphabetical): discriminants are index-aligned with the // checked-in Rust mirror (src/jsc/ErrorCode.rs) — only ever append here. ["ERR_HTTP2_GOAWAY_SESSION", Error], + ["ERR_TLS_ALPN_CALLBACK_INVALID_RESULT", TypeError], ]; export default errors; diff --git a/src/runtime/api/bun/SecureContext.rs b/src/runtime/api/bun/SecureContext.rs index 5fd94b9e4761..eba8704ffc31 100644 --- a/src/runtime/api/bun/SecureContext.rs +++ b/src/runtime/api/bun/SecureContext.rs @@ -94,6 +94,17 @@ impl SecureContext { if args.is_empty() { return Err(global.throw(format_args!("PFX certificate argument is mandatory"))); } + // The passphrase is optional; the C side treats NULL as "". Coerce it + // before borrowing the pfx ArrayBuffer so a user toString() cannot + // detach the buffer behind the borrowed slice. + let pass_owned: Option> = if args.len() > 1 && !args[1].is_undefined_or_null() { + let p = args[1].to_slice(global)?; + let mut v = p.slice().to_vec(); + v.push(0); + Some(v) + } else { + None + }; // The pfx arrives as a Buffer/TypedArray (binary DER) or a string; // a string-conversion would mangle the DER bytes, so read the raw // view when one exists. @@ -109,15 +120,6 @@ impl SecureContext { if pfx_bytes.is_empty() { return Err(global.throw(format_args!("PFX certificate argument is mandatory"))); } - // The passphrase is optional; the C side treats NULL as "". - let pass_owned: Option> = if args.len() > 1 && !args[1].is_undefined_or_null() { - let p = args[1].to_slice(global)?; - let mut v = p.slice().to_vec(); - v.push(0); - Some(v) - } else { - None - }; let mut out_key: *mut core::ffi::c_char = core::ptr::null_mut(); let mut out_cert: *mut core::ffi::c_char = core::ptr::null_mut(); let mut out_ca: *mut core::ffi::c_char = core::ptr::null_mut(); @@ -158,6 +160,7 @@ impl SecureContext { let message = match reason { "key" => "Unable to load private key from PFX data", "cert" => "Unable to load certificate from PFX data", + "mac" => "PFX MAC verification failed - is the passphrase correct?", _ => "Unable to load PFX certificate", }; return Err(global.throw(format_args!("{message}")));