net,tls: port Node.js net/tls compatibility tests and fix the gaps they surface — half-open/reset/write semantics, server TLSSocket wrap, session/keylog, SNICallback/ALPNCallback, pfx, OpenSSL error shapes, addCACert, local binding (+305 tests) - #31155
Conversation
|
Updated 5:07 PM PT - Jun 16th, 2026
❌ @cirospaciari, your commit ce06dce has 1 failures in
🧪 To try this PR locally: bunx bun-pr 31155That installs a local version of the PR into your bun-31155 --bun |
|
Found 4 issues this PR may fix:
🤖 Generated with Claude Code |
9302345 to
f36f956
Compare
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
src/js/node/tls.ts:671-673— Node only validatesSNICallbackfor server-side TLSSockets (thevalidateFunctioncall in_tls_wrap.jsis inside theif (options.isServer)block — SNI callbacks are inherently server-only), but this check runs unconditionally, sotls.connect({ ..., SNICallback: {} })ornew tls.TLSSocket(sock, { SNICallback: 42 })throwsERR_INVALID_ARG_TYPEin Bun while Node silently ignores it. One-token fix sincethis.isServeris set just above:if (this.isServer && options.SNICallback != null). Very narrow trigger (junk server-only option on a client socket), so just a nit.Extended reasoning...
What the bug is
The TLSSocket constructor (src/js/node/tls.ts:671-673) now does:
this.isServer = !!options.isServer; if (options.SNICallback != null) { validateFunction(options.SNICallback, "options.SNICallback"); }
This validates
SNICallbackregardless of whether the socket is server- or client-side. In Node.js, the equivalent check inTLSSocket.prototype._init(lib/_tls_wrap.js / lib/internal/tls/wrap.js) lives inside theif (options.isServer && options.SNICallback && ...)block — SNI callbacks are conceptually server-only (they let a server pick a certificate based on the client's SNI extension), so Node only type-checks the option when wrapping a server socket and silently ignores it on clients.Code path that triggers it
const tls = require('tls'); const net = require('net'); // Either of these throws ERR_INVALID_ARG_TYPE in Bun, no-op in Node: new tls.TLSSocket(new net.Socket(), { SNICallback: {} }); tls.connect({ port: 443, host: 'example.com', SNICallback: 42 });
For
tls.connect, the options object flows intonew TLSSocket(options)with noisServerkey, sothis.isServerisfalse, but the unconditional check on the next line still runsvalidateFunction({}, ...)and throws.Why existing tests don't catch it
The newly-ported
test-tls-snicallback-error.js(verbatim from upstream) only exercisestls.createServer({ SNICallback })andnew tls.TLSSocket(sock, { isServer: true, SNICallback })— both server-side — which is itself evidence that Node only validates on the server path. TheServerconstructor in this PR does its ownSNICallbackcheck (tls.ts:911-913), so the test passes without ever reaching the over-broad TLSSocket check on a client socket.Step-by-step proof
Step Node.js Bun (this PR) new tls.TLSSocket(sock, { SNICallback: 42 })(noisServer)_init:options.isServeris falsy → SNICallback branch skipped → no throwctor: this.isServer = false; next line:options.SNICallback != null→validateFunction(42, ...)→ throwsERR_INVALID_ARG_TYPEnew tls.TLSSocket(sock, { isServer: true, SNICallback: 42 })enters isServerbranch →validateFunction(42, ...)→ throwsvalidateFunction(42, ...)→ throws ✅ (matches)Impact
Minor Node-compat divergence in new code from this PR. The trigger is implausible in real code —
SNICallbackis documented as a server-only option, so passing a non-function value to a client socket is essentially garbage input; Bun is just stricter than Node here. No correct program is affected. The inline comment says "matches Node's TLSSocket init" but it's slightly stricter than Node. Filed as a nit since the fix is one token and the check is being added specifically for Node compat.Fix
Gate on
this.isServer, which is set on the line immediately above:if (this.isServer && options.SNICallback != null) { validateFunction(options.SNICallback, "options.SNICallback"); }
04333c4 to
0a25656
Compare
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
src/js/node/tls.ts:651-653—tls.setDefaultCACertificates([])makesgetCACertificates('default')return[]correctly, but actual TLS connections without an explicitcastill trust the full bundled root store — the injectedca: []collapses toca: undefinedat the native layer (handle_file_array()returnsNonefor empty arrays, SSLConfig.rs:338-340), so the per-SSL client attach at openssl.c:677-681 falls back tous_get_shared_default_ca_store(). In Node an empty array clears the defaults so such connections fail verification; here the user requests zero-trust and silently gets full-trust. Worth listing as a known limitation alongside the other deferredsetDefaultCACertificatesitems (a real fix needs a native 'empty store' sentinel); the ported-precedence-empty.js/-basic.jstests don't exercise this path.Extended reasoning...
What the bug is
The new
tls.setDefaultCACertificates()is implemented as a JS-side override:_defaultCACertificatesOverrideis stored,getCACertificates('default')returns it, andcreateSecureContext()injects it asoptions.cawhen no explicitcais given (tls.ts:651-653). For a non-empty override this works (modulo Bun's pre-existing additive-CA semantics). For an empty override it does not:ca: []is silently dropped on the way into the native SSL_CTX, so the connection ends up using the full bundled Mozilla root store +NODE_EXTRA_CA_CERTS, exactly as ifsetDefaultCACertificateshad never been called. Node's documented behavior is thatsetDefaultCACertificates([])clears the defaults so subsequent connections without their owncafail certificate verification.Code path
tls.setDefaultCACertificates([])→ the validation loop runs zero times →_defaultCACertificatesOverride = [].tls.connect({...})(noca) →TLSSocketctor →createSecureContext(options)._defaultCACertificatesOverride !== undefined && options.ca == nullis true →options = { ...options, ca: [] }.newNativeSecureContext→NativeSecureContext.intern→ bindgen →SSLConfig::from_js→handle_fileforca→handle_file_array():So// SSLConfig.rs:338-340 if elements.is_empty() { return Ok(None); }
result.ca = None— identical toca: undefined.as_usockets()→ctx_opts.ca = NULL,ca_count = 0.us_ssl_ctx_build_raw(openssl.c:543):options.ca && options.ca_count > 0is false.requestCertis set on the connect-timetlsobject (net.ts:1006), not on thecreateSecureContextoptions that built this SSL_CTX, sooptions.request_cert(openssl.c:561) is also false. All three branches skipped → SSL_CTX hasverify_mode == SSL_VERIFY_NONEand no cert store configured.us_internal_ssl_attach(openssl.c:677-681), client path:The per-socket override installs the shared bundled-roots store (Mozilla bundle +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); }
NODE_EXTRA_CA_CERTS, see root_certs.cpp). The connection trusts everything Bun trusts by default.
Why the ported tests don't catch it
test-tls-set-default-ca-certificates-precedence-empty.jssets defaults to[]but then passes a per-connectionca: [fakeStartcomCert], which makesoptions.ca == nullfalse at tls.ts:651 — the override-injection branch is never taken; the test only proves per-connectioncastill works.test-tls-set-default-ca-certificates-basic.js/-array-buffer.jsonly round-trip throughgetCACertificates('default'), which reads the JS-side_defaultCACertificatesOverridearray directly and never builds a native context or opens a connection.
No test in this PR connects without an explicit
caaftersetDefaultCACertificates([])and asserts a verification failure.Step-by-step proof
const tls = require('tls'); tls.setDefaultCACertificates([]); console.log(tls.getCACertificates('default')); // [] ✓ — JS surface correct tls.connect(443, 'example.com', { servername: 'example.com' }, () => { console.log('connected, authorized =', this.authorized); });
Step Node.js Bun (this PR) getCACertificates('default')[][]✅native trust store for the connection empty X509_STORE us_get_shared_default_ca_store()(full bundle) ❌handshake against a public-CA-signed server fails verification ( UNABLE_TO_GET_ISSUER_CERTetc.)succeeds, authorized === true❌The SSL_CTX content-hash digest for
{ca: []}and{ca: undefined}is also identical (both feedca = Noneintocontent_hash()), so the interned context is literally the same object as the no-CA default.Impact / why nit
The failure mode is silently security-relevant — a user who calls
setDefaultCACertificates([])is explicitly asking for zero default trust and gets full default trust instead, with no error or warning. That said:setDefaultCACertificatesis brand-new in this PR (not a regression).- The implementation comment at tls.ts:1285-1287 already acknowledges "Bun has no equivalent native store override, so keep a JS-side override" — the JS-side
ca-injection approach inherently can't express "empty store" because of pre-existing native behavior (handle_file_array's empty→None and the per-SSL client fallback at openssl.c:677-681 are both unchanged by this PR). - The non-empty override has a related pre-existing limitation: openssl.c:547 starts from
us_get_default_ca_store()even whencais supplied, sosetDefaultCACertificates([myCA])trusts bundled+myCA rather than only myCA. The empty case is the starkest instance of a broader "override doesn't restrict trust" gap. - A proper fix requires native changes (sentinel for "install an empty X509_STORE", or removing the per-socket shared-store fallback when
cawas explicitly empty) that are out of scope for this compat PR.
Filed as nit: worth flagging in the PR description's deferred-items list and/or adding a TODO at the override-injection site, since the PR description currently says "
setDefaultCACertificates()implemented" without this caveat.Fix direction
Either:
- Special-case the empty override in
createSecureContextto pass a sentinel that the native layer interprets as "install an empty X509_STORE and set VERIFY_PEER" (so openssl.c:677 doesn't fall back to the shared store), or - Thread an explicit "override default roots" flag through to
us_internal_ssl_attachso it skipsus_get_shared_default_ca_store()when an override (even empty) is active.
Both require native changes; until then, documenting it as a known limitation is the honest option.
-
🟡
src/js/node/net.ts:2403-2407— The new comment says "a valid port takes precedence over path", but the implementation never clearspathafter validatingport— solisten({ port: 0, path: '/tmp/sock' })still falls intokRealListen'sif (path)branch (net.ts:2543) and listens on the unix socket, ignoring port. The path-wins behavior is pre-existing (not a regression), but since this block is rewritten under "Match Node's listen() option normalization" and the portedtest-net-server-listen-options.jsonly exercises{ port: -1, path }(which throws invalidatePortbefore reachingkRealListen), it'd be worth either addingpath = undefined;afterport = port | 0to actually match Node, or dropping the precedence clause from the comment.Extended reasoning...
What the issue is
The rewritten options-object branch of
Server.prototype.listen()adds, at net.ts:2403-2407: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; } else if (isPipeName(path)) {
The comment (and the block's header at line 2397, "Match Node's listen() option normalization") states that a valid
porttakes precedence overpath. In Node that is true —lib/net.jsreturns from the port branch (callinglistenInClusterwith no pipe) before ever reaching the path branch. In Bun, however,pathis read at line 2379 and never cleared in the port branch. Bothportandpathare then passed throughlistenInCluster(..., path, ...)(line 2515) tokRealListen, which checksif (path)first (line 2543) and callsBun.listen({ unix: path }), ignoringportentirely. So in practice path takes precedence over port, the opposite of what the new comment claims.Step-by-step proof
For
net.createServer().listen({ port: 0, path: '/tmp/sock' }):Step Line State 1 2379 path = '/tmp/sock'2 2380 port = 03 2399 port === undefined? no;port === null? no → skip4 2403 typeof port === 'number'→ true, enter port branch5 2406 validatePort(0)→ ok6 2407 port = 0 | 0→0;pathstill'/tmp/sock'7 2515 listenInCluster(..., port=0, ..., path='/tmp/sock', ...)8 2543 kRealListen:if (path)→ true →Bun.listen({ unix: '/tmp/sock' })Result: listens on the unix socket. In Node the same call listens on TCP port 0 (random port).
Why existing tests don't catch it
The newly-ported
test-net-server-listen-options.jsdoes include a port-vs-path precedence assertion:// In listen(options, cb), port takes precedence over path assert.throws(() => { net.createServer().listen({ port: -1, path: common.PIPE }, common.mustNotCall()); }, assertPort());
But
port: -1throwsERR_SOCKET_BAD_PORTinsidevalidatePortat line 2406, beforekRealListenis reached — so it only proves that an invalid port is validated ahead of path, not that a valid port wins at listen time. There is no test for{ port: <valid>, path: <valid> }.Why this is a nit, not a regression
The underlying path-wins behavior predates this PR. The old code's
if (!Number.isSafeInteger(port) || port < 0)guard was also false forport = 0, leavingpathset, and the samekRealListenif (path)check applied. So no user-observable behavior changes here. What is new is:- The comment at line 2405 explicitly claiming Node's precedence, which the implementation doesn't honor.
- The block header at line 2397 ("Match Node's listen() option normalization") implying parity that isn't there for this case.
- The pre-existing doc-comment at line ~2442 ("path Will be ignored if port is specified") was already wrong, but this PR adds a second wrong comment rather than fixing either.
The trigger is also narrow — passing both
portandpathtolisten()is unusual.Fix
One line in the port branch:
if (typeof port === "number" || typeof port === "string") { validatePort(port, "options.port"); port = port | 0; path = undefined; // port takes precedence over path (Node returns early here) } else if (isPipeName(path)) {
Or, if matching Node's precedence is out of scope, drop "a valid port takes precedence over path" from the comment so it doesn't misdocument the implementation.
5ca55ec to
f49ffa0
Compare
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟣
src/bun_core/lib.rs:3347-3352— Pre-existing (already onmainvia #31116, not introduced by this PR): the musl/Android fallbackBun__captureStackTraceat line 3339 is stillpub extern "C" fn(safe) while the glibc/macOS/BSD and Windows definitions arepub unsafe extern "C" fn, so the shared wrapper'sunsafe { Bun__captureStackTrace(...) }here triggers theunused_unsafewarning on aarch64-musl / x64-musl / x64-android / aarch64-android / x64-musl-baseline (the robobun annotations atbun_core/lib.rs:3352in build #56711). It's warn-level only (unused_unsafeis not in the workspace deny list), so it doesn't block the build — just noting since it shows up in this PR's CI output; the one-keyword fix is to mark the fallbackunsafetoo (with a# Safetydoc) so all three cfg variants match.Extended reasoning...
What the issue is
Bun__captureStackTracehas three cfg-gated definitions insrc/bun_core/lib.rs:cfg line signature glibc / macOS / *BSD ~3233 pub unsafe extern "C" fnWindows ~3285 pub unsafe extern "C" fnfallback (musl, Android, …) 3339 pub extern "C" fn← notunsafeThe shared safe wrapper at line 3349-3352 unconditionally wraps the call:
pub fn capture_stack_trace(begin: usize, addrs: &mut [usize]) -> usize { // SAFETY: `addrs.as_mut_ptr()` is writable for `addrs.len()` slots; ... unsafe { Bun__captureStackTrace(begin, addrs.as_mut_ptr(), addrs.len()) } }
On targets where the fallback definition is selected (musl-libc Linux, Android — i.e. anything not in the
#[cfg(any(...))]list above),Bun__captureStackTraceis a safe function, so wrapping a call to it inunsafe { }triggers rustc'sunused_unsafelint at line 3352.Why this is pre-existing, not PR-introduced
Although the
unsafe-marking changes appear in this PR's GitHub diff, that's an artifact of the three-dot diff being computed against a stale merge-base.git log -Sshows theunsafemarkers + wrapperunsafe { }were introduced in commit21db6826("clippy: 45 deny lints + fix 2735 violations across workspace (#31116)"), andgit merge-base --is-ancestor 21db6826 <origin/main>confirms that commit is already on main. A two-dotgit diff main..HEAD -- src/bun_core/lib.rsshows no change toBun__captureStackTraceorcapture_stack_tracefrom this PR. The same warning fires onmainbuilds; robobun'sparseAnnotations(scripts/utils.mjs:2662) scrapes botherror:andwarning:lines from build output regardless of provenance, so it appears in build #56711's annotation list alongside other ambient noise likeclang++: argument unused during compilation: '-no-pie'.Why it's a warning, not a build failure
unused_unsafeis warn-by-default in rustc and is not in the PR's[workspace.lints.rust]deny list (which only promotesdead_code,unused_imports,unused_variables,unused_mut,unused_assignments,unused_macros,unreachable_code,unreachable_patterns). There is no#![deny(unused_unsafe)]inbun_core/lib.rsand no-D warningsinscripts/build/rust.ts. So this surfaces as a compiler warning, not a hard error — robobun's "5 failures" count for build #56711 doesn't include it (the actual failures are the threebuild-cppmusl entries plus the FreeBSDunreachable_pubitems).Step-by-step proof
- Compile
bun_corewith--target x86_64-unknown-linux-musl(oraarch64-linux-android, etc.). - cfg resolution:
target_os = "linux"buttarget_env = "musl"(not"gnu"), and not macOS/BSD/Windows → the#[cfg(not(any(...)))]fallback at line 3339 is selected. - The fallback is
pub extern "C" fn Bun__captureStackTrace(begin: usize, out: *mut usize, cap: usize) -> usize { let _ = (begin, out, cap); 0 }— nounsafekeyword, so it's a safe function (it never dereferencesout, so this is technically correct). capture_stack_traceat line 3352 evaluatesunsafe { Bun__captureStackTrace(...) }. The callee is safe → theunsafeblock contains no unsafe operations.- rustc emits
warning: unnecessary \unsafe` block→#[warn(unused_unsafe)]` (default level). - robobun build #56711 lists exactly this:
src/bun_core/lib.rs#L3352 - unnecessary \unsafe` blockon 🐧 aarch64-musl, x64-musl, x64-android, aarch64-android, x64-musl-baseline — the precise set of targets that match the fallback's#[cfg(not(...))]` gate.
Addressing the refutation
One reviewer argued for refuting on three grounds, two of which I agree with and have folded into the framing above: (a) "warning ≠ build failure" — correct; the original "fails build-rust" claim was overstated, and this comment is filed accordingly. (b) "not from this PR" — correct; verified via
git merge-base --is-ancestorand the empty two-dot diff, hence filed as pre-existing. (c) "the fallback being safe is arguably correct" — also true (it ignoresoutentirely), but that just shifts where the fix goes: either mark the fallbackunsafefor signature consistency, or#[allow(unused_unsafe)]the wrapper with a note that one cfg variant is safe. Either resolves the warning; the former keeps the three definitions interchangeable.Impact
None functionally — a warn-level lint on five non-tier-1 targets. Flagged only because it shows up in this PR's CI annotations and is a one-keyword fix; entirely fine to defer to whichever PR next touches the
unused_unsafefamily inbun_core.Fix
/// # Safety /// `out` must be writable for `cap` `usize` slots (or null/`cap == 0`). /// This fallback never dereferences `out`; `unsafe` is for signature parity /// with the other cfg-gated definitions so the shared wrapper's `unsafe { }` /// is not flagged `unused_unsafe` on musl/Android. #[unsafe(no_mangle)] pub unsafe extern "C" fn Bun__captureStackTrace(begin: usize, out: *mut usize, cap: usize) -> usize { let _ = (begin, out, cap); 0 }
- Compile
…test (#34163) ## Problem `test/js/node/tls/node-tls-cert.test.ts` went red on `:darwin: 26 aarch64` in [build 72915](https://buildkite.com/bun/bun/builds/72915): ``` error: expect(received).toBeLessThan(expected) Expected: < 12582912 Received: 21839872 ✗ server-side getPeerCertificate() should not leak ``` The same assertion also flaked in `node-tls-getpeercert-leak.test.ts` on `:darwin: 14 x64` in [build 72900](https://buildkite.com/bun/bun/builds/72900) (21008384 bytes, passed on retry). ## Cause The `server-side getPeerCertificate() should not leak` test exists twice: #29881 landed it as the standalone `node-tls-getpeercert-leak.test.ts`, and #29916 (merged a few hours later) appended an identical copy to `node-tls-cert.test.ts`. Both copies baseline RSS after four 5k-call warmup rounds and assert less than 12MB of growth afterwards. On the macOS tart runners the allocator's high-water mark can climb by ~21MB over that window with no leak present, while a probe on `darwin-test-arm64-3` with the current canary shows RSS flat within ~1MB once it has had a couple more rounds to settle, and the full test file passes 8/8 on that box. The copy inside `node-tls-cert.test.ts` is the worse of the two because it runs after 30 other TLS tests in the same process; in build 72915 the standalone file passed on the same commit in the same lane. There is no regression in the underlying `SSL_get_peer_certificate`/`computeRaw` paths: the growth is the same for 5 and 10 measurement rounds, which rules out the per-call leak the test guards against. ## Fix * Remove the duplicate from `node-tls-cert.test.ts` and leave a pointer to the standalone file. * In `node-tls-getpeercert-leak.test.ts`: * apply `skipIf(isDebug)` (the refinement #31155 had applied only to the duplicate) so the ASAN quarantine cannot mask the signal on debug builds, * lengthen the warmup from 4 to 8 rounds so RSS has settled before the baseline sample, * lengthen the measured window from 10 to 15 rounds, * raise the release threshold from 12MB to 32MB (40MB under the release ASAN lane). The unpatched BIO/X509 leak is ~800 bytes per call; over the 75k calls now measured that is ~60MB of growth, so the regression the test exists to catch remains well above the new threshold, while the ~21MB noise ceiling observed on the macOS runners sits comfortably below it. ## Verification ``` $ USE_SYSTEM_BUN=1 bun test test/js/node/tls/node-tls-getpeercert-leak.test.ts (pass) server-side getPeerCertificate() should not leak [5150.55ms] 1 pass 0 fail $ USE_SYSTEM_BUN=1 bun test test/js/node/tls/node-tls-cert.test.ts 27 pass 3 todo 0 fail $ bun bd test test/js/node/tls/node-tls-getpeercert-leak.test.ts (skip) server-side getPeerCertificate() should not leak ``` Ran the full `node-tls-cert.test.ts` eight times on `darwin-test-arm64-3` (macOS 26) against canary `cc0c1e835`; 8/8 pass. <!-- robobun:evidence:begin --> --- **[stamp-90s]** gate passed · iteration 8 · 2 files touched <details><summary>passes on PR (with fix)</summary> ```console Test-only change. Debug/ASAN (expected pass): $ bun bd test 'test/js/node/tls/node-tls-cert.test.ts' 'test/js/node/tls/node-tls-getpeercert-leak.test.ts' $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/js/node/tls/node-tls-cert.test.ts test/js/node/tls/node-tls-getpeercert-leak.test.ts info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05) info: component rust-src is up to date info: checking for self-update (current version: 1.29.0) bun test v1.4.0 (81bb980) test/js/node/tls/node-tls-getpeercert-leak.test.ts: (skip) server-side getPeerCertificate() should not leak test/js/node/tls/node-tls-cert.test.ts: (pass) complete cert chains sent to peer. [576.13ms] (pass) complete cert chains sent to peer, but without requesting client's cert. [162.21ms] (todo) Request cert from TLS1.2 client that doesn't have one. (pass) Typical configuration error, incomplete cert chains sent, we have to know the peer's subordinate CAs in order to verify the peer. [107.76ms] (pass) Typical configuration error, incomplete cert chains sent, we have to know the peer's subordinate CAs in order to verify the peer. But using multi-PEM [102.96ms] (pass) Typical configuration error, incomplete cert chains sent, we have to know the peer's subordinate CAs in order to verify the peer. But using multi-PEM in an array [97.10ms] (pass) Fail to complete server's chain [83.14ms] (pass) Fail to complete client's chain. [103.92ms] (pass) rejects an unverifiable client certificate by default when requestCert is true [247.07ms] (pass) explicit rejectUnauthorized: false still admits an unverified client certificate [79.04ms] (pass) Fail to find CA for server. [151.43ms] (pass) Server sent their CA, but CA cannot be trusted if it is not locally known. [69.40ms] (pass) Server sent their CA, wrongly, but its OK since we know the CA locally. [83.05ms] (todo) Confirm client support for "BEGIN TRUSTED CERTIFICATE". (todo) Confirm server support for "BEGIN TRUSTED CERTIFICATE". (pass) Confirm client support for ... (truncated) Exit: 0 ``` </details> <details><summary>diff hotspot</summary> ``` test/js/node/tls/node-tls-cert.test.ts | 150 ++++++--------------- test/js/node/tls/node-tls-getpeercert-leak.test.ts | 127 +++++++++-------- 2 files changed, 107 insertions(+), 170 deletions(-) ``` </details> **gate history** · 3 passed · 1 rejected · iteration 8 <details><summary>evidence per changed file</summary> ``` file reads edits tests test/js/node/tls/node-tls-cert.test.ts 5 6 0 test/js/node/tls/node-tls-getpeercert-leak.test.ts 2 2 0 ``` </details> <!-- robobun:evidence:end -->
Brings node:net and node:tls compatibility in line with Node by porting upstream tests verbatim and fixing the native gaps they expose.
305 verbatim upstream tests added.
Behavior changes
socket.end()now half-closes (FIN) instead of full-closing, so a server's response after a clientend()is delivered before close.socket.resetAndDestroy(),server.close({ resetConnections }), write-after-end / write-after-destroy errors, and the post-write callback contract match Node.read ECONNRESETshape when an error listener is attached and a clean EOF has not already been delivered; a codeless close error that carries an errno derives itscodefrom it. A reset that lands after the exchange already ended cleanly stays a graceful close.net.connect({ localAddress, localPort })binds before connecting on every connect path including deferred DNS resolution.'session'and'keylog'events, end-to-end through the native handler-slot chain; the--tls-keylogflag.code/library/function/reasonproperties (ERR_SSL_<REASON>,ERR_OSSL_<LIB>_<REASON>).secureContext.context.addCACert()extends the full default trust set (bundled roots,NODE_EXTRA_CA_CERTS, system CAs when enabled) on the context's own store, and chain verification then uses that store;tls.createSecureContext()returns a context that owns its SSL_CTX exclusively so a CA appended to one cannot affect another (the internal connect/listen paths keep the per-digest cache).tls.setDefaultCACertificates()applies on every secure-context construction path (plaintls.connect(),addContext,setSecureContext), not just the publiccreateSecureContext().SNICallbackandALPNCallbackserver dispatches, resolved per connection with Node's semantics;tlsSocket.setKeyCert();ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS.PSK+HIGH,!aNULL,@SECLEVEL, …) is accepted/rejected the way BoringSSL evaluates it; a mixed EC/RSA multi-identity configuration is rejected with Node's decomposedKEY_TYPE_MISMATCH.pfxoption: PKCS#12 blobs (single or array, per-entry passphrases) are parsed into key/cert with Node's error messages; CAs embedded in the bundle extend the trust set (they are not treated as an explicitcareplacement).TLSSocketwrapping of an accepted socket andtls.connect({ socket: duplex })over a generic Duplex, includingconnectingparity and synchronous teardown of the wrapped duplex on destroy.netperf_hooks observer,options.handle,pause()/unref()accounting,SocketAddress/BlockList parity, theautoSelectFamilyflag plumbing.Known limitations / follow-ups
SNICallbacknow suspends the handshake (BoringSSL select-certificate retry) until its callback resolves - the upstreamtest-tls-sni-option.jspasses; synchronous callbacks behave as before.server.close()waits longer than it should in that edge case; (2) fixing it by dispatching the handshake failure from the close path requires first aligningtlsHandshakeError's no-listener behavior with Node's silent-destroy semantics, otherwise routine client-initiated mid-handshake teardowns (h2 connection management) surface as spurious ECONNRESET errors.SNICallbackthat reports an error, returns something that is not a SecureContext, or throws aborts the handshake (synchronously or asynchronously): the connection is dropped without a TLS alert and the server emits'tlsClientError'with the callback's error, matching Node.setKeyCert()from insideALPNCallbackis too late under BoringSSL's TLS 1.3 (the credential is already chosen); calling it fromSNICallbackworks.tls.DEFAULT_MIN/MAX_VERSIONare now honored at context-construction time (assignment through the module exports works the way Node's does), and a TLS socket that ends first keeps reading the peer's in-flight data - BoringSSL has no TLS half-close, soend()defers the close_notify (flushing pending session tickets first) and half-closes at the TCP level instead.test-net-perf_hooks.jsis intermittently divergent on Ubuntu 25.04 (dual-stacklocalhostresolution).us_socket_write_check_error) that fails the pending write and closes the socket instead.'error'listener swallowsECONNRESETtransport teardown noise (destroying the session quietly) instead of crashing the process the way Node's EventEmitter contract would; all other unobserved errors still surface.Fixes #28638
Fixes #28641
Fixes #26418
Fixes #20642
Origin (consolidated from #31148)
What
Ports 11
net/tlstests from the Node.js test suite intotest/js/node/test/parallel/(verbatim) and fixes the runtime divergences they surfaced.Tests added (verbatim from upstream Node)
net:
test-net-pause-resume-connecting,test-net-server-keepalive,test-net-server-nodelay,test-net-socket-setnodelay,test-net-connect-memleaktls:
test-tls-basic-validations,test-tls-buffersize,test-tls-client-reject,test-tls-net-socket-keepalive,test-tls-secure-session,test-tls-connect-memleakFixes
net.Socket/net.ServerkeepAlive,noDelay,allowHalfOpenandhighWaterMark; theSocketconstructor honorsoptions.handle.allowHalfOpen(defaultfalsecloses on peer FIN so'close'fires;truekeeps the writable side open), matching Node/libuv.pause()is honored while a socket is still connecting.setNoDelay()forwards to the handle;ServercoercesnoDelaywithBoolean()likekeepAlive._writedefers its callback to the next tick only for TLS sockets (the SSL engine batches, sobufferSize/writableLengthreflect the queued bytes); plain TCP completes synchronously so a tightwrite()loop backpressures at the kernel rather than the JShighWaterMark.tlsvalidateSecureContextOptions(ciphers / passphrase / ecdhCurve / min-max version / ticketKeys / sessionTimeout) and a simplifiedconvertALPNProtocols.'session'event.Errors
validateBufferreports"must be an instance of Buffer, TypedArray, or DataView"to match Node. Two existing tests that asserted the old wording are re-synced to upstream.Comments that mirror Node behavior link the corresponding upstream source.
Testing
Validated locally with the debug build before pushing:
test-{net,http,tls}-*parallel sweep: 0 failures.test/js/node/http/node-http.test.ts(incl. HTTP server security tests): 78 pass, 0 fail.Notes
The strictly Node-correct half-close
_final(shutdown()rather than$end()) is left for a follow-up: it exposes a uWS TLS-handshake edge case where a successful handshake immediately followed by aclose_notifyis reported asECONNRESET. Until that's addressed,_finaluses$end().This PR consolidates the original test-porting branch (
claude/port-node-net-tls-tests, #31148) and the follow-up branch (claude/port-node-net-tls-tests-2); both branches now point to the same squashed content.