Skip to content

Hardening round 11: input validation, bounds checks, lifetimes - #33072

Merged
Jarred-Sumner merged 41 commits into
mainfrom
claude/security-round-11
Jul 4, 2026
Merged

Hardening round 11: input validation, bounds checks, lifetimes#33072
Jarred-Sumner merged 41 commits into
mainfrom
claude/security-round-11

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

What

A hardening and robustness pass across the runtime: input validation, bounds checking, protocol-state handling, and object-lifetime correctness in ~200 files. It contains 122 individual fixes and ~190 new tests (171 new test/it blocks, several parameterized, across 79 existing test files). No new API is introduced; every behavioral change below has a test unless explicitly noted, and each is aligned with Node, the relevant RFC/spec, or the upstream reference implementation.

Potentially breaking / behavior-visible changes

Read this section first. Everything else in the PR preserves behavior for valid inputs.

  • Bun.serve request.url is only synthesized from a structurally valid Host. For every HTTP/1.x request (Bun.serve and node-compat servers alike), a Host value that is empty or contains bytes outside uri-host [":" port] (RFC 3986 authority: alphanumerics, .-:_~%[] and sub-delims) is never used as the authority of the synthesized request.url; request.url falls back to the request target (e.g. /path) and the request is still served. No request is rejected on the basis of the Host field value, and a valid Host still round-trips into request.url exactly. Why: request.url should never carry an authority that cannot come back out of new URL().
  • fetch rejects request lines it cannot legally serialize. A URL path/host (or, for proxied requests, the full href) containing a control character, space, or DEL now fails with InvalidURL before any bytes are written (RFC 9112 request-line grammar; normal fetch() input is percent-encoded by the URL parser and unaffected). Also: a redirect whose Location resolves to a non-http(s) scheme now fails with UnsupportedRedirectProtocol (Fetch spec, matches undici); a 101 arriving on the pre-tunnel leg of a proxied request is treated as an unrequested upgrade; connections whose identity was accepted by a per-request checkServerIdentity callback are never entered into or taken from the keep-alive pool.
  • An own __proto__ key from data files and macros is printed as a computed key. The json, jsonc, json5, toml, yaml, and CSS-module loaders — and objects returned from Bun macros — now emit ["__proto__"]: ... so importing such data yields an own __proto__ property (like JSON.parse) instead of a prototype assignment. Who notices: only code importing data with a __proto__ key; matches esbuild's JSON loader and Node semantics.
  • node:url legacy url.parse lookup tables no longer inherit from Object.prototype (both node:url and the browser fallback), the hostless/slashed lookups use the lowercased protocol, and url.parse(s, true).query is a null-prototype object (empty query included). All three match Node's lib/url.js exactly. Who notices: code doing query.hasOwnProperty(...) or parsing schemes named like toString:.
  • WebSocket client: missing negotiated subprotocol fails the handshake. Per RFC 6455 §4.1, if new WebSocket(url, ["a"]) requested subprotocols and the server's 101 omits Sec-WebSocket-Protocol, the connection now closes with 1002 instead of opening with ws.protocol === "". Matches browsers, ws, and undici. Connections that request no subprotocol are unaffected.
  • HTTP/2 (client and server) enforces RFC 9113 message framing. Trailer blocks must carry END_STREAM and no pseudo-headers; content-length must be 1*DIGIT, non-duplicated, and equal to the DATA actually received (CONNECT exempt) — violations get RST_STREAM(PROTOCOL_ERROR) instead of being delivered. With maxSessionMemory exceeded, new peer streams are refused with REFUSED_STREAM (retryable), and reset streams promptly release their native state — Node/nghttp2 parity throughout. The all-streams teardown helper now throws a TypeError for a non-numeric error code instead of coercing it per stream.
  • node:http2 HTTP/1 fallback (allowHTTP1) frames responses like Node. Header-name matching is case-insensitive; HEAD and close-delimited responses don't get an auto Transfer-Encoding: chunked/terminating chunk; writeHead now throws ERR_HTTP_INVALID_STATUS_CODE / ERR_INVALID_CHAR like Node's ServerResponse. Re-entrant sendTrailers() raises ERR_HTTP2_TRAILERS_ALREADY_SENT in the same order Node does.
  • node:http(s) proxy CONNECT endpoint is validated with validateHeaderValue in release builds (previously a debug-only assertion), so an invalid host/port surfaces as the same error Node throws.
  • Glob: walking through a self-referential directory symlink completes. With followSymlinks, a link that resolves to one of its own live ancestors is descended exactly once (like find -L, glibc fts, node-glob); sibling/cousin links to the same target are still all visited. One pre-existing test changed: it previously asserted the walk failed with ENAMETOOLONG after the path grew past the limit; it now asserts the scan completes.
  • Resolver: an exports/imports target whose expansion would exceed the OS path limit is a normal resolution error (Invalid module specifier / Invalid package target, as Node models it) instead of a hard failure.
  • Shell: template arrays nested deeper than 100 levels throw a clear error instead of recursing without bound; an interpolated string equal to if/then/elif/else/fi is treated as data, never as a reserved word (POSIX: reserved words are only recognized literally); $.escape now quotes strings containing tab, CR, or ? (word delimiters / glob metacharacters).
  • bun pack / bun publish include/exclude matches npm-packlist. With a "files" field, the non-overridable defaults (.git, .npmrc, node_modules, lockfiles) are now applied inside the files traversal too; conversely .hg moved to the overridable default-ignore list, so "files" can re-include it — exactly npm's split.
  • bun upgrade verifies the downloaded artifact against the digest the GitHub Releases API reports for that asset, and fails with a retryable error on mismatch. If the API reports no (or an unrecognized) digest, behavior is unchanged.
  • install: an integrity string carrying several space-separated digests (legal SSRI) is now parsed correctly and verified against the strongest algorithm present (see Deviations); a stored bun.lockb with a non-0/1 byte in a boolean slot fails validation instead of being reinterpreted; lifecycle scripts for registry packages always come from the installed package.json (never from lockfile bytes), matching what Bun writes and what npm does; isolated installs apply the same name/alias shape validation as hoisted installs; bin links reached through a subdirectory get the same resolved-containment check the dotted forms already had (npm only links files inside the package folder).
  • node:fs: mode arguments are no longer masked to 0o777 (setuid/setgid/sticky pass through to the syscall, like Node); copyFile/cp create the destination with the source's permission bits (libuv parity); on Windows, cp copies directory junctions/symlinks via the unprivileged-create + junction-fallback helpers and rewrites \\?\UNC\ targets to \\server\share form (libuv parity), so copying a tree with junctions works without elevation; on macOS the clonefile/openat paths use NOFOLLOW so the copy matches the lstat classification (dereference:false).
  • Web plumbing observable from JS: record conversion (new Headers(obj), fetch init, URLSearchParams, …) snapshots the key list once and re-resolves keys mutated by a converter, exactly as Web IDL specifies (deleted keys skipped, replaced values re-read) — released Bun/Node/WebKit order preserved; TextDecoder.decode over a SharedArrayBuffer or resizable buffer view snapshots the bytes first; consuming a Blob/Response body no longer empties other objects sharing the same byte store (transfer only when sole owner); deeply nested serialized arrays in structuredClone data hit the same recursion cap objects already had; the SIMD decodeURIComponent fast path decodes non-ASCII input as UTF-8 (with U+FFFD for ill-formed sequences) instead of throwing/garbling.
  • N-API / V8 API: napi_create_arraybuffer returns zeroed memory (Node contract); napi_get_typedarray_info/napi_get_dataview_info report the view's real byte_offset; v8::String::Utf8Length returns the exact byte count WriteUtf8 will produce for ill-formed UTF-16; v8::Number::New canonicalizes NaN payloads.
  • Dev-only endpoints check Host/Origin: the inspector (bun --inspect) HTTP/WebSocket endpoint applies its Host/Origin checks before the /json* discovery routes and rejects non-matching DNS-name Host values with 400 (Node inspector semantics); the bake/dev-server internal routes require an allowed Host and same-origin for the error-report/sourcemap endpoints; internal HMR pub/sub topics are namespaced so user publish/subscribe topic strings can never collide with them.
  • markdown: reference-link expansion is charged against md4c's exact output budget (16 × min(input, 64 KiB) scale); once exhausted, further references degrade to literal bracketed text — no error — exactly as md4c does.
  • Misc small behavior corrections: checkPrime validates the candidate before the options (Node's order); HKDF rejects non-secret KeyObjects with ERR_CRYPTO_INVALID_KEY_OBJECT_TYPE (current Node); Ed25519 sign/verify with a wrong-length key errors/returns false; X25519 JWK import honors kty/crv/use/key_ops/ext; SPKAC helpers return false/empty for empty or whitespace-only input (Node); CookieMap.delete of a __Host-/__Secure- cookie emits Secure so browsers accept the expiry; postgres escapeIdentifier rejects embedded NUL (pg parity); valkey/redis pending commands reject with "Connection closed" on disconnect and out-of-band push frames never consume an unrelated command's promise (RESP3); semver strings consisting only of v/=/whitespace parse as * (node-semver); Bun.wrapAnsi measures rows whose seam joins grapheme clusters (combining marks/ZWJ/VS16) the way npm wrap-ansi does; bash/zsh completions handle script names containing : and other special characters; the Docker images verify (not just decode) the release checksum signature.

Changes by area

  • HTTP server (uWS / Bun.serve / node:http)packages/bun-uws/HttpParser.h, packages/bun-usockets, src/runtime/webcore/Request.rs, src/runtime/server: the request.url Host handling above (URL synthesis in Request.rs only — the HTTP parser's Host handling is unchanged and every request is served); CONNECT requests are framed as an opaque tunnel regardless of Transfer-Encoding/Content-Length (RFC 9110 §9.3.6); TLS socket relocation updates the loop's spill/last-error owner pointers so bookkeeping never points at a moved socket; the bake-only route additionally requires an allowed Host.
  • fetch / HTTP client / webcoresrc/http/lib.rs, src/http/ssl_config.rs, src/runtime/webcore/{Request,Blob,TextDecoder}.rs, src/jsc/bindings/webcore/*, src/jsc/bindings/decodeURIComponentSIMD.cpp, src/url/lib.rs, src/runtime/api/BunObject.rs: everything in the highlights, plus: write_request failures propagate their real error instead of being reported as out-of-memory; partial-header/1xx short reads no longer re-feed already-consumed bytes to the parser; SSLConfig now actually applies secureOptions and the client-renegotiation limit/window it was already accepting; URLSearchParams no longer drops a pair whose value has a malformed percent sequence (WHATWG: never drop, decode lazily); AbortSignal native listeners deregistered by an earlier abort callback are not invoked with a stale context; the compression helpers (Bun.gzipSync et al.) read the options object before coercing the input buffer (so a getter can't invalidate the captured slice) and no longer register a deallocator for an empty result's dangling sentinel pointer (an invalid free at GC time under debug allocators).
  • node compatsrc/runtime/node/*, src/js/node/*, src/node-fallbacks/url.js, src/jsc/ipc.rs, src/runtime/socket: everything in the highlights, plus: fs path arguments from typed arrays are always pinned for the call's duration; BlockList structured-clone carries only an opaque per-instance nonce resolved through a live table (round-trip unchanged); IPC advanced-mode frame lengths are range-checked before span arithmetic, serialization failures leave no partial frame in the send queue, and a received fd is closed if its message fails to parse; TLS-upgrade initialData is copied to an owned buffer before use; node:wasi interprets rights bitfields as unsigned u64 (per the ABI) and no longer reports success for a path_open that threw internally; the inspector/debugger endpoint changes above.
  • HTTP/2 & WebSocket clientsrc/runtime/api/bun/h2/connection.rs, h2_frame_parser.rs, src/http_jsc/*: everything in the highlights, plus: 1xx interim responses are not misclassified as trailers; refused streams still HPACK-decode the discarded block (RFC 9113 §4.3) and advance last_stream_id so pipelined RST_STREAMs don't become connection errors; the frame parser no longer holds an exclusive stream reference across calls back into JS (options/header getters, toString coercions) — engine-side stream eviction is deferred until the dispatch unwinds; the trailers failure path still ends the stream with FRAME_SIZE_ERROR + graceful GOAWAY so in-flight streams stay retryable; the deflate plumbing gains a per-VM slot (no behavior change yet).
  • install / pack / bunx / upgrade / createsrc/install/*, src/runtime/cli/{pack,create,upgrade}_command.rs, src/semver, packages/bun-release, packages/bun-vscode: everything in the highlights, plus: SSRI option suffixes (?...) are stripped from digest payloads; a GitHub dependency whose resolved ref would not form a single well-formed folder name is refused with a clear error (real refs/SHAs always pass); the trusted-dependency lookup moved off the extraction worker thread (no user-visible change); bun create's package.json rewrite uses the CLI arena so the parsed AST outlives its uses; the npm installer package validates archive entry paths stay inside the destination; the VS Code lockfile preview escapes interpolated text and the debug adapter's session id comes from crypto.randomBytes.
  • resolver / bundler / parsers / macros / sourcemap / markdownsrc/resolver, src/bundler, src/parsers/{json,json5,yaml}.rs, src/ast/e.rs, src/js_parser/lexer.rs, src/js_parser_jsc/Macro.rs, src/jsc/RuntimeTranspilerStore.rs, src/jsc/bindings/BunPlugin.cpp, src/sourcemap, src/md, src/paths, src/standalone_graph: the __proto__, resolver-limit, and markdown items above, plus: a native-plugin onLoad source buffer now has exactly one owner (its free callback was registered twice); onResolve callback lists are snapshotted (GC-visible) before user callbacks run, so a callback registering more plugins can't perturb the in-progress dispatch; barrel-import scheduling copies its alias seeds instead of holding references into a map the BFS mutates; embedded bytecode caches are handed to ResolvedSource as a genuinely owned allocation; the lazy sourcemap decompression cache became OnceLock-based (shared-reference safe); the lexer's SIMD long-string fast path advances past scanned bytes (removes a quadratic re-scan on unterminated literals); is_parent_or_equal uses a true prefix check instead of substring containment.
  • shell / glob / CLI / wrapAnsisrc/shell_parser, src/runtime/shell, src/glob/GlobWalker.rs, completions/bun.{bash,zsh}, src/jsc/bindings/wrapAnsi.cpp: the highlights above; the glob walker's followed-link tracking is scoped to the live ancestor chain (DAG revisits still enumerate); wrapAnsi also caches row widths so the seam fix comes with fewer full-row rescans.
  • cryptosrc/jsc/bindings/{ncrypto.cpp,node/crypto/*,webcrypto/*}: the highlights above, plus: the sign/verify job copies signature bytes out of the JS view before the async job runs; ECDH.convertKey and prepareAsymmetricKey capture buffer spans only after argument coercions that can run user JS; deserialized CryptoKeys re-validate that the algorithm matches the key class (and an empty key payload is rejected); an OOM while encoding returns after throwing.
  • sql / valkey / s3src/sql, src/sql_jsc, src/js/internal/sql, src/runtime/valkey_jsc, src/valkey, src/s3_signing: the highlights above, plus: postgres CopyData payload length is computed per the wire protocol (was one byte short) and PortalSuspended/Copy* messages are consumed instead of desynchronizing the stream; MySQL zero-length AuthSwitchRequest/LocalInfileRequest packets are clean protocol errors instead of a length underflow; prepared-statement caches are keyed by the full statement name, not a 64-bit hash (a hash hit is verified by equality); the distributed-transaction name is type-checked; the S3 region used to synthesize a host must be host-safe and endpoint parsing is index-safe on odd endpoint strings.
  • JSC bindings / N-API / V8 / sqlite / miscsrc/jsc/bindings/{napi.cpp,v8/*,ZigException.cpp,ZigGlobalObject.cpp,CookieMap.cpp,sqlite/JSSQLStatement.cpp}, src/jsc/rare_data.rs, src/runtime/webview: the N-API/V8 items above; stack-trace population skips out-of-range frame indices instead of asserting; the native microtask trampoline is hidden from stack traces; bun:sqlite detects a database closed re-entrantly from inside a bind coercion and throws "Database has closed"; the macOS webview bridge type-checks the objects a page posts to its internal message handler.
  • dev server / bake / build & CIsrc/runtime/bake/*, dockerhub/*, .github/workflows/update-vendor.yml: the dev-endpoint gating and HMR topic namespacing above; the dev-server terminal error report blanks non-UTF-8 bytes (not just encoded C1); the React SSR flight-data inliner escapes the fully decoded string instead of per-chunk (a boundary-split escape character could previously produce a malformed inline script); Docker images use gpg --verify; the vendor-update workflow passes matrix values through env:.

Deviations / decisions

  • Integrity: strongest-single-digest verification. When an integrity field carries several digests, Bun verifies the strongest supported algorithm present; when several digests of that same algorithm are present, the first is the one verified. npm/ssri accepts a match on any digest of the chosen algorithm — keeping the full set needs plumbing through the manifest cache/lockfile types and is left for a follow-up.
  • No response-decompression size cap in fetch. A per-response decompressed-body limit was implemented and then deliberately removed from this PR ("Keep fetch response decompression unbounded"): Node imposes none, and any cap is a behavior change for legitimate large responses. The net diff has no decompression change.
  • markdown reference expansion degrades instead of erroring, matching md4c exactly (see highlights). No error is ever surfaced.
  • Record conversion keeps the specification's per-property order[[GetOwnProperty]]/Get interleaved with value conversion, so a toString that mutates a sibling property is observed and a deleted one skipped, the same as released Bun, Node, and WebKit. The property table is never held across user code.
  • Dead code removed because these changes made it unreachable: cache::Entry.external_free_function (plus Entry::new and the free branch of Entry::deinit) and AlreadyBundled::bytecode_slice; the bun_wyhash dependency of sql_jsc is dropped.
  • Deeper, behavior-visible sibling work in the same areas was split into its own PRs so it can be reviewed on its own terms: node:https: apply the full TLS option set when creating a Server #33054 (node:https TLS option set), websocket: do not hold an exclusive client reference across user callbacks #33055 (websocket dispatch re-entrancy), resolver: take the per-entry lock when rewriting cached directory entries #33056 (FileSystemRouter/resolver entry locking), child_process: honor uid and gid spawn options #33060 (child_process uid/gid), node:http: enforce server headersTimeout and requestTimeout #33061 (node:http server headers/request timeouts). Nothing from those PRs is claimed here.
  • The changes to CI workflow files, shell completion scripts, Dockerfiles, and the VS Code extension have no automated-test harness; a few lifetime/ordering corrections have no deterministic observation from JS (they are covered by the existing suites and the sanitizer jobs) and are noted as such instead of shipping a non-asserting test.

How it was verified

  • bun bd (full debug build) and cargo check clean with zero warnings; bun run rust:check-all passes 10/10 targets (the change set includes Windows- and macOS-gated code); clippy lints raised on the touched files were addressed.
  • ~190 new regression tests in existing test files (171 test/it blocks across 79 files, several parameterized). Each was verified to fail with USE_SYSTEM_BUN=1 bun test <file> and pass with bun bd test <file> (except the handful noted above with no JS-observable assertion); the touched suites were run in full to confirm no regressions.
  • HTTP/2 changes were exercised against a dedicated h2 conformance suite (test/js/node/http2/h2-conformance.test.ts) and Node's own http2 tests; the node:http Host behavior was checked against Node's conformance test for accepted host values; the record-conversion ordering was checked against WebKit/Node observable order.
  • rustfmt / clang-format / prettier / oxlint clean over the changed files.

@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator
Updated 9:58 PM PT - Jul 3rd, 2026

@Jarred-Sumner, your commit b42fbd3 has 1 failures in Build #68280 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33072

That installs a local version of the PR into your bun-33072 executable, so you can run:

bun-33072 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 5 issues this PR may fix:

  1. Bun vulnerable to prototype pollution when using the nedb package #24475 - PR makes url.parse results use Object.create(null), directly preventing the prototype pollution vector described
  2. fs.glob differs from node and incorrectly follows symlinks #29699 - PR adds symlink cycle detection in the glob walker, directly fixing infinite loops on cyclic directory symlinks
  3. Panic in debug on invalid unicode escape sequence #30825 - PR fixes lexer cursor advancement on unterminated escape sequences, addressing the panic on invalid unicode escapes
  4. Please issue a CVE for HTTP Request Smuggling #30921 - PR hardens request-line and Host header validation against HTTP request smuggling vectors
  5. Jose non-extractable CryptoKey cannot be exported as a JWK #19629 - PR fixes X25519 JWK import to honor ext/key_ops fields, fixing the extractable flag being ignored

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #24475
Fixes #29699
Fixes #30825
Fixes #30921
Fixes #19629

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(install): guard against overlong PAX paths in tarball extraction #31160 - Also hardens PAX path bounds checking in tarball extraction (TarballStream.rs, libarchive/lib.rs)
  2. node:http2: enforce RFC 9113 message framing on received responses #33004 - Also enforces RFC 9113 HTTP/2 messaging rules (trailer validation, content-length vs DATA check) in h2/connection.rs and h2_frame_parser.rs

🤖 Generated with Claude Code

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find any bugs, but this is a 200-file hardening pass touching security-sensitive paths (HTTP parsing, crypto, fs traversal, installer integrity, structured-clone deserialization), so it warrants a human reviewer.

Extended reasoning...

Overview

This PR is a broad hardening sweep across ~200 files: input validation and bounds checks in the HTTP/1 and HTTP/2 parsers, fetch/proxy request-line validation, package-installer path/tarball/integrity handling, lockfile deserialization, fs recursive copy symlink handling, crypto key validation (HKDF, WebCrypto OKP/RSA/Raw, structured-clone key revalidation), SQL/Valkey wire-protocol length checks, glob symlink-cycle detection, shell/markdown/lexer quadratic-input fixes, S3 region/host validation, inspector Host/Origin gating, and assorted lifetime/ordering fixes in Rust and C++ (AbortSignal native callbacks, h2 frame-parser stream lifetimes, BlockList structured-clone nonce table, native-plugin double-free removal). It also touches CI workflows, Dockerfiles (gpg --decrypt--verify), and shell completions.

Security risks

Nearly every area here is security-relevant by design: request-smuggling defenses (Host validation, CONNECT precedence over Transfer-Encoding/Content-Length), redirect-scheme enforcement, decompression-bomb caps, tarball path-traversal and bin-link containment, integrity multi-hash selection, structured-clone recursion/length bounds, inspector DNS-rebinding checks, and N-API/V8 value handling. The changes are defensive (tightening validation), but any regression in these spots is high-impact, and several change observable behavior for previously-accepted-but-malformed inputs.

Level of scrutiny

High. Each change is individually small and the PR ships ~185 regression tests, but the aggregate surface is large, spans many independent subsystems, and includes behavior-visible tightening (e.g., 400 on Host with //@, glob self-symlink semantics, url.parse query prototype, mode_from_js no longer masking to 0o777, WebSocket subprotocol strictness). The diff was also truncated in my view (~447KB total), so I have not seen every hunk.

Other factors

No prior human reviews on the timeline yet, and the build is still in progress. The bug-hunting pass found nothing, but given the breadth, the security sensitivity, and the explicit behavior changes called out in the description, this should get human eyes rather than a bot approval.

@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR updates host/request validation, HTTP/2 stream accounting, installer safety, bundler ownership, SQL protocol handling, shell and URL parsing, and several runtime helpers. It also adds regression tests covering the new validation and framing behavior.

Changes

Tooling and install

Layer / File(s) Summary
Workflow, completions, and Docker verify
.github/workflows/update-vendor.yml, completions/bun.bash, completions/bun.zsh, dockerhub/*/Dockerfile
The vendor workflow now passes package inputs through environment variables. Bash and Zsh completions filter candidate lists more strictly and use array-backed expansion. Dockerhub builds now verify SHASUMS256.txt.asc instead of decrypting it.
Install path safety and integrity
packages/bun-release/src/npm/install.ts, src/install/..., src/runtime/cli/upgrade_command.rs
Archive extraction, bin-target containment, trusted dependency tracking, lockfile script handling, integrity parsing, and upgrade digest verification are updated across the installer and upgrade paths.
Install helper access and trusted dependency plumbing
src/install/PackageInstaller.rs, src/install/PackageManager/..., src/install/isolated_install.rs
Alias validation is made crate-visible and reused for install-folder checks, while trusted-dependency state is threaded into local tarball and extracted tarball tasks.
Install tests
test/cli/install/*
Install regressions cover tarball URLs, lockfile scripts, tarball integrity forms, bin traversal, upgrade digest checks, semver parsing, and create-command behavior.

HTTP, networking, and server

Layer / File(s) Summary
HTTP host and target validation
packages/bun-uws/src/..., src/http/..., src/js/internal/debugger.ts, src/runtime/bake/..., src/runtime/server/..., src/runtime/webcore/Request.rs, src/http_jsc/websocket_client/...
Host-header validation is threaded through uWS parsing and the Rust server stack. Request targets are validated before sending. Redirects require HTTP-like protocols. WebSocket upgrade handling rejects missing subprotocol headers when protocols were requested. DevServer and inspector host/origin checks are tightened.
HTTP/2 stream state and re-entrancy
src/runtime/api/bun/h2/connection.rs, src/runtime/api/bun/h2_frame_parser.rs
HTTP/2 request handling now tracks trailers, content-length, and received body bytes, rejects mismatches with PROTOCOL_ERROR, and adds dispatch-depth guarding to defer legacy stream cleanup while JS callbacks are active.
HTTP and networking tests
test/js/node/http*, test/js/node/http2*, test/cli/inspect/*, test/js/bun/http/*, test/bake/dev/*
Tests exercise Host validation, CONNECT tunneling, HTTP/1 fallback framing, HTTP/2 framing and lifecycle, inspector and dev-server access control, and redirect behavior.

Bundler, resolver, glob, SQL, shell, and JS compat

Layer / File(s) Summary
Bundler, resolver, sourcemap, and glob bookkeeping
src/bundler/..., src/resolver/..., src/sourcemap/..., src/standalone_graph/..., src/js_parser_jsc/Macro.rs, packages/bun-vscode/src/features/lockfile/lockfile.style.ts
Bundler source ownership changes, barrel alias seeding, AlreadyBundled ownership, sourcemap OnceLock caching, package-resolution path-length limits, symlink reporting, and glob traversal ancestry all change together.
SQL protocol handling
src/sql_jsc/..., src/sql/..., src/valkey/..., src/runtime/valkey_jsc/valkey.rs
MySQL and Postgres prepared-statement storage switches to string-keyed lookup. Postgres COPY messages are consumed, MySQL auth-switch/local-infile decoders add underflow guards, and Valkey push routing changes to subtype-based consumption and in-flight-only rejection.
Shell, URL, markdown, WASI, and related JS helpers
src/shell_parser/parse.rs, src/runtime/shell/shell_body.rs, src/runtime/api/BunObject.rs, src/js/node/url.ts, src/node-fallbacks/url.js, src/js/node/wasi.ts, src/md/links.rs, src/md/parser.rs, src/js/internal/sql/postgres.ts, src/js/internal/sql/shared.ts, src/url/lib.rs, src/runtime/cli/pack_command.rs, src/runtime/cli/create_command.rs, src/js_parser/lexer.rs, src/parsers/json_lexer.rs
Shell keyword recognition becomes interpolation-aware, shell template arrays enforce depth limits, compression argument coercion is centralized, URL parsing uses null-prototype tables and query objects, WASI rights are coerced as unsigned 64-bit values, markdown reference output is budgeted, route values stay raw, and additional validation is added in the parser and CLI helpers.

Runtime, crypto, and WebCore

Layer / File(s) Summary
Runtime and WebCore helper changes
src/jsc/bindings/webcore/AbortSignal.*, src/jsc/bindings/webcore/JSDOMConvertRecord.h, src/jsc/bindings/wrapAnsi.cpp, src/jsc/bindings/decodeURIComponentSIMD.cpp, src/runtime/node/node_fs.rs, src/runtime/node/types.rs, src/runtime/webcore/Blob.rs, src/runtime/webcore/TextDecoder.rs, src/jsc/bindings/v8/..., src/jsc/ipc.rs, src/runtime/webview/..., src/jsc/bindings/sqlite/JSSQLStatement.cpp, src/jsc/bindings/CookieMap.cpp, src/jsc/bindings/ZigException.cpp, src/jsc/bindings/ZigGlobalObject.cpp, src/runtime/bake/bun-framework-react/ssr.tsx, src/runtime/bake/DevServer/ErrorReportRequest.rs, src/runtime/node/net/BlockList.rs, src/runtime/socket/socket_body.rs, src/s3_signing/credentials.rs, src/semver/Version.rs
AbortSignal, JSDOM conversion, wrapAnsi, decodeURIComponentSIMD, node_fs copy behavior, Node type coercion, Blob/TextDecoder ownership, V8 and IPC helpers, ObjC/WebView checks, SQLite/CookieMap/ZigException, React SSR, error sanitization, BlockList clone identity, TLS upgrade data handling, S3 signing, and semver parsing all change in targeted ways.
Crypto and NAPI hardening
src/jsc/bindings/webcore/SerializedScriptValue.cpp, src/jsc/bindings/webcrypto/*, src/jsc/bindings/ncrypto.cpp, src/jsc/bindings/node/crypto/*, src/runtime/napi/napi_body.rs, src/jsc/bindings/napi.cpp, packages/bun-debug-adapter-protocol/src/debugger/*, packages/bun-vscode/src/features/debug.ts
CryptoKey deserialization, Ed25519/X25519 validation, HKDF key-type checks, SPKAC empty-input handling, prime candidate initialization, NAPI typed-array byte offsets, ArrayBuffer initialization, and random ID generation are updated.
Runtime crypto and helper tests
test/js/node/crypto/*, test/js/web/crypto/*, test/napi/*, test/v8/*, test/js/web/encoding/*, test/js/web/fetch/*, test/js/bun/util/*, test/js/bun/jsc/*, test/js/bun/net/*, test/js/bun/spawn/*, test/js/bun/sqlite/*, test/js/bun/webview/*, test/js/bun/wasm/*, test/js/valkey/*
Tests cover CryptoKey deserialization, HKDF and Ed25519/X25519 validation, NAPI offsets and zeroed ArrayBuffers, V8 UTF-8 length and NaN handling, shared-buffer decoding, fetch and WebView behavior, wrapAnsi, decodeURIComponentSIMD, Valkey routing, socket TLS upgrade, spawn IPC, SQLite close handling, and WASI rights/path-open handling.

Possibly related PRs

  • oven-sh/bun#29765: Both PRs modify the HTTP/2 frame parser to handle re-entrant JS safely.
  • oven-sh/bun#29932: Both PRs touch packages/bun-usockets/src/context.c’s us_socket_adopt path.
  • oven-sh/bun#30385: Both PRs adjust how the HTTP client’s response_message_buffer is handled during proxy/tunnel response processing.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the PR’s main hardening focus.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description includes the required purpose and verification sections with detailed content, albeit under non-template heading names.

Comment @coderabbitai help to get the list of available commands.

alii
alii previously requested changes Jun 29, 2026

@alii alii left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went through this area by area. Most of it is solid and a real improvement — the CONNECT reorder, the h2 framing checks, the lockfile/integrity tightening, and the pack/.git hardening are all good fixes. But there's one change I think can't ship in its current shape, and a cluster of items where the hardening diverges from the reference implementation (npm/ssri, md4c, esbuild, Web IDL), regresses a working path, or ships its load-bearing branch with zero test coverage.


Blocker

1. The parser-level Host-charset 400 fires for node:http and rejects requests Node delivers — with no node:http coverage

The new check in packages/bun-uws/src/HttpParser.h (~line 895 old) is gated only on !req->ancientHttp && requireHostHeader — and node:http wires requireHostHeader = true by default (src/js/node/_http_server.ts:2632:889 setServerCustomOptionsNodeHTTP.cppserver_body.rsApp.h). Node v22 (llhttp) returns 200 and delivers req.headers.host for Host: a b, Host: example.com/path, Host: user@example.com, raw non-ASCII, and Host: a"b; Bun 1.3.14 matches Node on all five. The new RFC 3986 authority charset rejects all five with a connection-level 400 before the 'request' event — so Express/Fastify apps behind sloppy proxies/health-checkers break with no userland recourse (the only knob, requireHostHeader: false, also disables the presence check, and that option's Node semantics are presence-only).

The check also omits the !isConnectRequest && !req->getHeader("upgrade").data() exemption the adjacent missing-Host check carries (whose own comment explains Node dispatches those via 'upgrade'/'connect' before enforcing Host), so WebSocket upgrades and CONNECT with odd Hosts now die at the parser too. All six new tests in test/js/bun/http/request-smuggling.test.ts are Bun.serve-only.

Suggestion: don't gate the charset check on requireHostHeader. Either (a) scope the parser-level 400 to Bun.serve via a separate uws flag that node:http sets false — defensible there because Bun.serve synthesizes request.url from Host, and this PR already validates Host at the URL-synthesis layer (Request.rs), which covers Bun.serve anyway; or (b) if a parser-level reject stays, mirror the upgrade/CONNECT exemption and match llhttp's actual charset (reject control bytes only), not the RFC 3986 set. Either way, add node:http tests pinning parity with Node on the cases above.

(Related: item 20 below — the two Host validators and how they tie together.)


Should fix

2. Integrity: only the FIRST digest of the strongest algorithm is kept — order-dependent divergence from ssri/SRI

src/install/integrity.rs: if parsed.tag.0 > strongest.tag.0 (strictly greater) discards later same-algorithm entries, and the single 64-byte digest slot makes them unrecoverable at verify time (verify_by_tag). npm's ssri matches ANY digest of the strongest algorithm (checkDatafind), per W3C SRI §3.3.4. So sha512-A sha512-B where the tarball matches B passes npm and hard-fails Bun, and the outcome depends on entry order — which the spec makes irrelevant. The new tests cover only mixed-algorithm strings; there's no sha512-A sha512-B case. (To be fair: pre-PR, multi-entry strings decayed to Tag::UNKNOWN and verification was silently skipped, so this is a real security improvement — it just over-tightens.)

Suggestion: keep all entries sharing the strongest algorithm; Streaming::init can hold a small bounded set (entries are bounded by string length) and verify compares against each. No lockfile format change needed. Add the two-same-algo test either way — and if pick-first is kept deliberately, note the deviation from npm in the description.

3. The glob symlink-cycle guard's --filter path is both untested and quadratic

  • Coverage: the default-true ENTRY_KIND_FOLLOWS_SYMLINKS branch is live ONLY for DirEntryAccessor, whose sole instantiation is bun --filter (src/runtime/cli/filter_arg.rs:37). Every new cycle test (test/js/bun/glob/scan.test.ts, path-length.test.ts) goes through Bun.Glob = GlobWalker<SyscallAccessor>, where should_descend_resolved_dir constant-folds to return true — so ~50 lines of new traversal logic (dupe_z, DirEntryAccessor::statat's relative join, the Err(_) => true fail-open, the ancestor prefix check) are dead in every tested configuration. test/cli/run/filter-workspace.test.ts has no symlink case.
  • Cost: record_followed_link only pushes, never truncates; is_followed_link_ancestor scans the whole Vec per descent. On the DirEntryAccessor path this records EVERY directory and adds an uncached stat(2) + two heap allocations per descended directory — O(D²) over directory count for --filter-shaped walks in monorepos, on a cache-backed accessor whose whole point is avoiding syscalls. The resolver cache already knows symlink-ness (Entry.cache().symlink), so the stat recomputes cached information.

Suggestion: (a) snapshot followed_links.len() in each WorkItem at push and truncate on pop — the workbuf is LIFO so that's exactly the live ancestor chain; per-descent cost drops to O(depth) and the textual prefix check can go. (b) Surface the cache's symlink knowledge through AccessorDirEntry (an is_symlink() hook) so the walker only stats actual symlinks. (c) Add a --filter test with a self-referential directory symlink in a workspace — pre-fix behavior is infinite descent/ENAMETOOLONG, easy to prove with USE_SYSTEM_BUN=1.

4. validate_request_target rejects CR/LF/SP but not TAB and the rest of C0

src/http/lib.rs (~1124) checks only \r, \n, space. The guard is genuinely load-bearing — registry manifest tarball URLs, redirect Location (re-parsed with lenient URL::parse), and proxy-env URLs all bypass WHATWG normalization, as this PR's own tests show. But picohttpparser admits TAB in header values (vendor/picohttpparser/picohttpparser.c:166), so a server-controlled Location: /a\tb puts a raw TAB into the next request line — and RFC 9112 §3 lenient recipients split on whitespace including TAB. The PR description says "reject control characters"; the implementation rejects three bytes.

Suggestion: reject any byte <= 0x20 plus 0x7f (matches the scope of Node's ERR_UNESCAPED_CHARACTERS, same O(n) cost). Add \t/\x0b siblings next to the existing \n/space tests, plus a redirect-Location-with-TAB fetch test.

5. CONNECT-before-TE reorder: correct, but the two branches that actually moved are untested

The reorder closes a real smuggle path (old code chunk-decoded a CONNECT body then parsed subsequent bytes as a pipelined request). But the only new test is CONNECT + Content-Length: 0. CONNECT + Transfer-Encoding: chunked (raw bytes delivered un-decoded), CONNECT + nonzero CL with trailing GET /smuggled, and CONNECT + TE + CL → 400 (the ordering invariant at HttpParser.h:932-939) are pinned by nothing — a future "CONNECT ignores body framing" cleanup re-opens smuggling with the suite green. Three test-only additions in test/js/node/http/node-http-connect.test.ts cover it; all three match Node v22 behavior.

6. h2 dispatch_depth UAF guard is a comment-enforced global invariant — no assert, no helper, no test

src/runtime/api/bun/h2_frame_parser.rs ~5410: the drain gate is correct today (all six JS-entry wrappers plus the hand-guarded host fns arm enter_dispatch; the receive path is covered by the engine RefCell), but the audit only holds by hand. There are no debug_asserts in the h2 hunks and no test forces the depth>0 deferral — deleting the depth check or any single enter_dispatch() arm leaves the suite green. The next host fn that copy-pastes the prevailing unsafe { &mut *stream } pattern with an options getter reintroduces the exact UAF.

Suggestion: (1) a re-entrancy regression test: h2 over a JS-fed duplex, pass writeStream/request an options object whose getter synchronously feeds completion frames back into read() — removing the guard then crashes under ASAN. (2) Make the invariant structural: a guard-RAII helper that hands out the &mut Stream only inside an armed scope, replacing the scattered raw derefs.

7. Windows cpSync junction routing: right semantics, wrong symlink API — EPERM on non-elevated machines, zero Windows tests

The new reparse-point routing in node_fs.rs (~8299) correctly copies junctions/dir-symlinks as links (matching Node dereference:false and Bun's own async path). But it routes into the pre-existing branch at node_fs.rs:9102 that calls raw CreateSymbolicLinkW with only SYMBOLIC_LINK_FLAG_DIRECTORY — omitting SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE, which the in-tree sys::symlink_w/symlink_or_junction helpers (src/sys/lib.rs:8708+) already implement per the libuv pattern. Result: cpSync of a tree containing junctions (npm/pnpm/bun node_modules layouts) goes from succeeding pre-PR (dereference-copy) to EPERM on default non-elevated Windows, including Developer Mode where Node succeeds. The only new cp test is test.skipIf(!isLinux); Windows CI can create junctions unprivileged, so nothing catches this.

Suggestion: use the existing sys::symlink_w/symlink_or_junction helper (junction fallback is the highest-fidelity unprivileged copy), and add a Windows test in cp.test.ts: create a junction, cpSync recursive, assert the destination is a link to the original target.

8. The 4GiB decompression cap is wired to a drained buffer — different invariant per mode, corrupt-gzip error on trip

All three decoders check the LIVE output Vec length (src/zlib/lib.rs:1258, src/brotli/lib.rs:364, src/zstd/lib.rs:559), but that Vec aliases FetchTasklet.response_buffer, which is reset on every progress callback. In streaming mode the counter resets each drain — one socket read can't reach 4GiB through gzip's ~1032:1 ratio, while bomb output accumulates uncapped in scheduled_response_buffer. In one-shot mode it's a hard total-body cap Node/undici don't have: a legitimate >4GiB-decompressed download now fails with an error indistinguishable from corrupt gzip, no escape hatch. The security property is weakest exactly where the threat is and strictest where legitimate traffic lives.

Suggestion: (1) track cumulative decompressed bytes in the Decompressor itself (survives drains), or cap scheduled_response_buffer where accumulation actually happens; (2) a dedicated error (DecompressedBodyTooLarge) distinct from corrupt-data; (3) consider making the limit configurable (BUN_CONFIG_MAX_DECOMPRESSED_BODY_SIZE, following the max-header-size precedent).

9. url.ts: lookups hardened, but the three protocol tables still inherit from Object.prototype — and one divergence needs no pollution at all

The PR null-protos url.parse's outputs but leaves unsafeProtocol/hostlessProtocol/slashedProtocol (src/js/node/url.ts:78-99) as plain literals. Checked against Node three ways: resolveObject with protocol:'constructor' hits Object.prototype.constructor with NO pollution (Bun drops the host; Node keeps it); Object.prototype['evil:']=true changes parse structure; Object.prototype['weird:']=true defeats the unsafeProtocol guard and skips the XSS auto-escape loop. Node hardened these exact tables (SafeSet).

Suggestion: add __proto__: null to the three literals — three one-line edits in a file this PR already touches — plus a resolveObject-'constructor' regression test and one pollution test. Side note: Bun's slashedProtocol omits ws/wss, which Node includes.

10. md4c budget: matches neither upstream's formula nor — more importantly — its failure mode

Upstream md4c is min(16*size, 1MB) (md4c.c:7216) and on exhaustion renders refs as literal TEXT — parse succeeds (mity/md4c#238). This PR ships (16*size).max(1MB).min(16MB) (src/md/parser.rs:246, a shape that never existed upstream) and THROWS (src/md/links.rs:548,581MarkdownObject.rs:62). Charges accrue per use, so a ~30KB changelog with one long-URL definition referenced a few thousand times exceeds the floor — md4c/cmark/GitHub all render it, Bun.markdown.html() throws. The new test enshrines the divergent throw, and the PR description's "upstream md4c output budget" is wrong on both constants and semantics.

Suggestion: match md4c's degradation — zero the budget and return Ok(None) at the two charge sites (identical guards already sit adjacent), delete the new ParserError variant. Keep the looser clamp with an honest comment, or restore the exact upstream formula. Update the test to assert unlinked-text degradation, not a throw.

11. h2 refused-stream: benignity of pipelined frames hangs on an uncommented, untested invariant

All five frame types on a refused id are benign today, but RST_STREAM tolerance works ONLY because the last_stream_id advance stayed outside the !refused gate (connection.rs:797-801). The obvious-looking future cleanup — folding that block into the adjacent refusal-gated arm — turns pipelined RST_STREAM on a refused id into GOAWAY(PROTOCOL_ERROR), tearing down every live stream exactly when the server is under memory pressure. The sole refusal test sends no pipelined frames.

Suggestion: extend the maxSessionMemory test to pipeline DATA/WINDOW_UPDATE/PRIORITY/RST_STREAM behind the refused HEADERS in one write, assert no GOAWAY and that a subsequent stream completes; add a CONTINUATION variant asserting HPACK state stays in sync; one-line comment on the last_stream_id advance naming why it must stay refusal-independent.

12. __proto__ printer fix is at the wrong layer — printer-global flag instead of esbuild's per-property marker; the macro serializer is a live missed sibling

The was_lazy_export gate (src/js_printer/lib.rs:~4762) has to be re-wired at every printer entry point — this PR patches the THIRD such site (print_common_js), which is the fragility demonstrating itself. esbuild fixes this at the parser layer: json_parser.go:147-152 sets the computed-property flag on the "__proto__" property itself. And the flag approach misses src/js_parser_jsc/Macro.rs:791-796: a macro returning JSON.parse('{"__proto__":{...}}') serializes into the user's tree (flag false) as a prototype-setting literal — same bug class, unfixed, even though was_originally_macro exists right there.

Suggestion: set Property::IsComputed on "__proto__" string keys where the data parsers (json/json5/jsonc/toml/yaml) build EObject properties (small shared helper), and in the Macro.rs serialization. The printer flag then disappears and every current and future print path is correct.

13. Valkey: is_reply_kind is the only thing keeping psubscribe acks from desyncing the pipeline — and it's untested

The push-routing half is fine and tested. But SUBSCRIPTION_PUSH_MESSAGES covers only message/subscribe/unsubscribe (valkey_protocol.rs:805-809), so psubscribe/punsubscribe/ssubscribe/sunsubscribe acks rely solely on the new is_reply_kind() to consume their promise pair. Delete it or misspell a kind (there's a b"UNPSUBSCRIBE" typo precedent in ValkeyCommand.rs:170) and psubscribe's promise never settles while every subsequent pipelined reply resolves the wrong command — the exact desync class this PR fixes — with the suite green. Also: the b"subscribe"|b"unsubscribe" arms inside is_reply_kind are unreachable (those go through the push set).

Suggestion: one mock-server test sending a >3 psubscribe push ack followed by the next command's reply; assert both settle correctly. Drop the unreachable arms or derive the set from SUBSCRIPTION_PUSH_MESSAGES so there's one source of truth.

14. Record fast-path snapshot diverges from Web IDL/Node/Chrome/Safari/old-Bun — and the new tests codify it undisclosed

JSDOMConvertRecord.h: values are collected via getDirect BEFORE any Converter<V>::convert runs; the spec (and the untouched slow path, and upstream WebKit, which has no fast path at all) interleaves Get with conversion. A toString that mutates a sibling property now sees stale values and includes deleted properties — in new Headers(obj), URLSearchParams, fetch, WebSocket. Node returns second:"replaced" with third excluded; the new headers.test.ts asserts second:"second", third:"third". Worse, behavior now depends on hidden-class state (literal → fast path → snapshot; getter-bearing → slow path → spec order). Not mentioned in the Behavior notes.

Suggestion: keep the safety fix, preserve spec order — snapshot only the identifiers inside forEachProperty (no user JS runs there), convert interleaved with a per-property structure() comparison, falling back to the slow path on transition. Or mirror upstream WebKit and drop the fast path. Either way, flip the two new tests to assert spec-interleaved results.

15. bun pm pack: pre-include exclusion applies the full overridable default-ignore list, dropping files npm includes — tests bake the divergence in

The new is_excluded(..., &[]) call in iterate_included_project_tree (pack_command.rs) makes the entire DEFAULT_IGNORE_PATTERNS list a hard exclude that explicit "files" entries can't reach — can_override is dead with the empty slice. npm 10.9.3: files:["lib",".gitignore","bunfig.toml","npm-debug.log",".DS_Store"] packs all of those and excludes only the documented non-overridable set (.git/.npmrc/lockfiles). The two new bun-pack tests assert the opposite.

Suggestion: restrict the pre-include check to the can_override=false set (plus ROOT_DEFAULT_IGNORE_PATTERNS at depth 1) — that keeps the real hardening wins (.git/.npmrc/lockfile leak via "files", no traversal into .git) while preserving npm's documented override rule. Update the two tests.

16. wrapAnsi lastRowWidth cache assumes additive widths across the append boundary — grapheme clustering makes that false

The deleted recompute (rows.last().width() every iteration) became lastRowWidth = rowLength + wordLen, but GraphemeState::width() (stringWidth.cpp:455-490) deliberately overrides codepoint sums for clusters (keycap, RI pairs, ZWJ, VS16). On shipped Bun: Bun.stringWidth("ab ⃣xx") = 6 while the additive parts sum to 7 — the space clusters with a word-initial U+20E3. So a change billed as pure perf changes wrap decisions near the column limit for word-initial combining marks/keycap/VS16 — real decomposed-Unicode text. Only perf tests were added.

Suggestion: fall back to a full recompute whenever the appended word's first codepoint isn't a guaranteed cluster-break class (one fusedClassify check; preserves the ASCII fast path exactly). Add a differential equivalence test over the trim×wordWrap×hard matrix with ANSI + tabs + word-initial combining marks.

17. invalid_specifier_if_too_long! — the only guard against a release-build panic on multi-* pattern targets — is dead under both new tests

The pre-PR panic is real (slice-bounds panic in bun_core::replace writing into the fixed PathBuffer) and the two-site coverage in resolve_target is complete (package_json.rs:2612,2726; imports funnels through the same fn). But for a single * the coarse pre-check always fires first; the macro is live ONLY for multi-* targets — and both new tests use an 8,192-byte target that trips the pre-check before any replace. Delete both macro invocations and the suite stays green, leaving a user-reachable panic (DoS via package.json).

Suggestion: add a test sized to pass the pre-check but blow up on expansion — e.g. exports: {"./*": "./" + "*/".repeat(100) + "x"} with a few-hundred-byte specifier (under macOS's 1024-byte MAX_PATH_BYTES). Verify it panics with USE_SYSTEM_BUN=1. Add an imports sibling, and assert the error code instead of a bare catch.

18. The is_empty branch in leak_list_into_uint8array silently fixes a reachable pre-PR invalid free — undisclosed, untested for that reason

Pre-PR, Bun.gunzipSync(Bun.gzipSync("")) registered mi_free_ctx with a dangling NonNull::dangling() ctx freed at GC (BunObject.rs → array_buffer.rs → ArrayBuffer destructor; under ASAN it's free(0x1) → abort). The PR fixes this as an unlabeled side effect of a refactor billed as deduplication — Bun.gc appears zero times in the diff, and the only tests reaching empty output fail pre-PR for an unrelated ordering reason. Nothing prevents the next refactor from reintroducing it.

Suggestion: regression test next to the new zstd.test.ts block — empty gzip/deflate roundtrips, drop refs, loop Bun.gc(true); aborts pre-PR under ASAN/debug, passes post-PR. One sentence in the Behavior notes naming the fixed GC-time invalid free so the branch reads as load-bearing, not speculative.


Consider

19. http2 allowHTTP1 fallback: flat-array fix is correct and overdue, but it half-implements the NUL-sentinel convention

The old destructuring loop emitted garbage since #31584. The new code skips the "\0" pair but discards its "1" (close-delimited) / "2" (no-body) payload semantics that NodeHTTP.cpp:721-733 honors — so HEAD or close-delimited responses through Http2SecureServer({allowHTTP1:true}) get different wire framing than the same handler on plain https. Either implement the two flags (~10 lines mirroring NodeHTTP.cpp) or comment the skip site stating the omission is intentional; a HEAD-framing-parity test through the fallback would pin it.

20. Both Host validators are live but nothing documents why, and the two hand-written charsets have nothing binding them

The Rust check (Request.rs ~904) covers HTTP/1.0 and requireHostHeader=false where the parser check is skipped; the empty-Host divergence (parser accepts per RFC 3986 reg-name, Rust rejects because http:///path isn't synthesizable) is deliberate and load-bearing. Don't merge them across the FFI boundary — instead: short comments at each site naming the other and stating reachability, plus tests for empty Host: on HTTP/1.1 and a charset-parity test so the two sets can't drift silently. Resolve alongside item 1.

21. ServerRouteList param decoding: behavior change is correct but unpinned at the user-visible layer

Raw non-ASCII route-segment bytes now decode as UTF-8/U+FFFD instead of release-build Latin1 mojibake — the right fix, and CookieMap is pinned end-to-end. But req.params is public API and only byte-level bun:internal-for-testing tests exist. One raw-socket test in bun-serve-routes.test.ts against a /:id route would do it: raw UTF-8 bytes → "é", lone 0xE9 → "�".

22. WebSocket missing-protocol enforcement is the PR's most user-visible compat break, and it's absent from the Behavior notes

Clients requesting subprotocols now fail with close 1002 "Missing client protocol" when the server omits the echo (previously connected with protocol === ""), and protocol-stripping proxies are a known real-world pattern. The implementation checks out (both enforcement halves, the empty-value edge, the 1002 mapping). One sentence in the Behavior notes, no code change.


Checked and held up

For completeness, angles I dug into that turned out fine as shipped: the http.request CONNECT href-validation breadth (live, load-bearing, only rejects RFC-invalid input); the IPC serialize-then-write double buffer (justified by start_message's failure modes); h2 duplicate/HEAD/204 content-length handling and GOAWAY edges (match RFC 9113); lockfile script-byte rejection vs old lockfiles (migration unaffected); certerrors keepalive pool scoping; the relative-Location redirect scheme check; inspector auth ordering; zlib option-coercion error ordering; TextDecoder shared-copy completeness; napi byteOffset consistency; AbortSignal dispatch-list lifetime.

@alii
alii dismissed their stale review June 29, 2026 14:21

Superseded by per-hunk inline review

@alii alii left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went through this area by area (reposted from the earlier single-comment review as per-hunk threads so they can be resolved independently). Most of it is solid and a real improvement — the CONNECT reorder, the h2 framing checks, the lockfile/integrity tightening, and the pack/.git hardening are all good fixes. 22 inline threads: 1 tagged [blocker] (the parser-level Host-charset 400 breaks node:http parity with Node and has no node:http coverage), 17 [should-fix] (mostly: divergence from the reference implementation — npm/ssri, md4c, esbuild, Web IDL — or load-bearing branches with zero coverage, or regressions on a working path), 4 [consider].

Angles I dug into that turned out fine as shipped, for completeness: http.request CONNECT href-validation breadth (live, load-bearing, only rejects RFC-invalid input); the IPC serialize-then-write double buffer (justified by start_message's failure modes); h2 duplicate/HEAD/204 content-length handling and GOAWAY edges (match RFC 9113); lockfile script-byte rejection vs old lockfiles (migration unaffected); certerrors keepalive pool scoping; the relative-Location redirect scheme check; inspector auth ordering; zlib option-coercion error ordering; TextDecoder shared-copy completeness; napi byteOffset consistency; AbortSignal dispatch-list lifetime.

Comment thread packages/bun-uws/src/HttpParser.h Outdated
Comment thread src/install/integrity.rs
let mut strongest = Integrity::default();
for entry in buf.split(|c: &u8| c.is_ascii_whitespace()) {
let parsed = Self::parse_entry(entry);
if parsed.tag.0 > strongest.tag.0 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[should-fix] Strictly-greater keeps only the FIRST entry of the strongest algorithm — order-dependent divergence from ssri/SRI.

Later same-algorithm entries are discarded, and the single 64-byte digest slot makes them unrecoverable at verify time (verify_by_tag). npm's ssri matches ANY digest of the strongest algorithm (checkDatafind), per W3C SRI §3.3.4. So sha512-A sha512-B where the tarball matches B passes npm and hard-fails Bun, and the outcome depends on entry order — which the spec makes irrelevant. The new tests cover only mixed-algorithm strings; there's no sha512-A sha512-B case. (To be fair: pre-PR, multi-entry strings decayed to Tag::UNKNOWN and verification was silently skipped, so this is a real improvement — it just over-tightens.)

Suggestion: keep all entries sharing the strongest algorithm; Streaming::init can hold a small bounded set (entries are bounded by string length) and verify compares against each — no lockfile format change needed. Add the two-same-algo test either way, and if pick-first is kept deliberately, note the deviation from npm in the PR description.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still unaddressed in the fix batch — no change to integrity.rs and no two-same-algo test (the new bun-install.test.ts block is the tarball-URL control-char test for the other thread). The ask stands: match ssri by keeping all digests of the strongest algorithm and verifying against each, or test + document pick-first as a deliberate divergence from npm.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bump — this is now the only thread untouched across all three fix waves (no code change, no test, no reply). Restating the ask: Integrity::parse keeps only the FIRST digest of the strongest algorithm, so sha512-A sha512-B where the tarball matches B fails in Bun but passes npm/ssri (any-match over the strongest set, per W3C SRI §3.3.4) — and the outcome depends on entry order, which the spec makes irrelevant. Either keep all strongest-algorithm digests and verify against each (Streaming::init can hold a small bounded set; no lockfile change needed), or add the two-same-algo test asserting pick-first and note the divergence in the PR description. The latter is fine if it's a deliberate call — it just needs to be visible.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@robobun Do we need to make a followup?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, opened #33700 to track it.

On scope: holding a bounded set only in Streaming::init doesn't quite reach on its own. By the time Streaming::init runs, tarball.integrity is already the single 65-byte Integrity read back from PackageVersion (binary manifest cache) or Meta (lockfile); the raw SSRI string was discarded at Integrity::parse in npm.rs:2587 / bun.lock.rs / migration.rs / pnpm.rs / yarn.rs. So the manifest-cache side needs to carry the extra digests (format bump), which is the shape Jarred described above. The text lockfile can stay single-digest since we'd write the one that matched.

Happy to pick it up as the follow-up PR if you want; say the word on #33700.

Comment thread src/glob/GlobWalker.rs Outdated
Comment thread src/http/lib.rs
Comment thread packages/bun-uws/src/HttpParser.h
Comment thread src/runtime/api/BunObject.rs
Comment thread src/js/node/http2.ts Outdated
Comment thread src/runtime/webcore/Request.rs
Comment thread src/jsc/bindings/decodeURIComponentSIMD.cpp
Comment thread src/http_jsc/websocket_client/WebSocketUpgradeClient.rs
@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Caution

Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted.

Error details
{}

Comment thread test/js/node/v8/capture-stack-trace.test.js Outdated
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

Thanks for the depth here — every item was re-verified against the reference you named (ssri, md4c, esbuild, Web IDL, real Node, npm) before anything changed. Point by point:

  1. Addressed before this round in 045c812 (see the earlier reply): the parser-level check is now a Bun.serve-only uws flag that node:http sets false, with node:http tests in request-smuggling.test.ts pinning the Host values Node accepts.

  2. Confirmed against ssri 10.0.6 (checkDatamatch compares every digest of the strongest algorithm; order irrelevant), but the prescribed fix doesn't fit in integrity.rs: Integrity (one tag + one 64-byte slot) is an on-disk type in both the binary manifest cache (npm.rs, version-guarded byte copy) and the lockfile (Package/Meta.rs; padding_checker.rs pins size 65), and by the time Streaming::init/ExtractTarball::extract run the original string is gone — so "hold a set in Streaming::init" needs the digest set plumbed from the two parse sites through enqueue to the verifiers (or a format bump). I'd rather land that as its own change than fold an install-format/plumbing change into this PR at this point — I'll take it as a follow-up; meanwhile the pick-first deviation from npm is now called out in the description, and I did not add a test that enshrines it.

  3. All three parts done (GlobWalker.rs, resolver/lib.rs): followed_links is snapshot-on-push / truncate-on-pop (the workbuf is LIFO, so the Vec is exactly the live ancestor chain → O(depth) membership, no textual prefix scan, no per-directory path copy), AccessorDirEntry gained an is_symlink() hook fed from the resolver cache (entry.cache().symlink) so the --filter path only stats actual directory symlinks, and filter-workspace.test.ts got "self-referential directory symlink in a workspace does not loop" (released bun: the matched script runs 41× through the alias chain; fixed: twice). A new cousin-symlink test in scan.test.ts guards the truncation against over-pruning.

  4. validate_request_target now rejects any byte <= 0x20 plus 0x7f (all four call sites), with \t/\x0b manifest-tarball-URL tests next to the existing \n/space ones (both fail on released bun). One correction to the premise: a redirect Location cannot deliver a raw TAB to the request line — all three redirect branches run the Location through WebKit's WHATWG join/parse before the lenient URL::parse, which strips TAB (verified on released bun and this branch: Location: /a\tb → second request line GET /ab HTTP/1.1), and picohttpparser rejects the rest of C0 + DEL in header values outright. So the redirect tests added pin the normalization and the malformed-response rejection for VT/SOH/DEL; the lenient-parsed registry/tarball URL is the live vector and carries the regression tests.

  5. Added all three to node-http-connect.test.ts: CONNECT + TE:chunked (framing bytes tunneled raw, never chunk-decoded), CONNECT + nonzero CL with a trailing GET /smuggled (everything tunneled, nothing parsed as a request), CONNECT + TE + CL → 400 with no connect event. Each asserts the exact tunneled bytes plus the empty request log; checked against real node (llhttp) — outputs match — and the first two fail on released bun (bytes arriving after the 200 were still fed through the body decoder).

  6. Both suggestions taken. (1) Test: "re-entrant read() from a trailer-value toString does not free the in-use stream" (h2-conformance.test.ts) — a trailer value whose toString feeds RST_STREAM+PING back into read() over a JS duplex; with the depth check locally removed the child dies with an ASAN heap-use-after-free in send_trailers, with it the test passes. (2) Structural: enter_stream_dispatch() returns a GuardedStream whose &mut Stream cannot outlive the armed depth; it is now the single unsafe { &mut *ptr } for the four hand-armed host fns (net unsafe blocks −3). The remaining raw derefs are in the frame handlers that never run user JS while borrowing; funneling those too is a bigger refactor I left out.

  7. Switched the reparse arm to the in-tree helpers — sys::symlink_or_junction for directory links, sys::symlink_w for file links — both carry SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE plus the libuv junction fallback; that was the last CreateSymbolicLinkW call site outside sys/. Added the Windows test you asked for in cp.test.ts (junction → cpSync recursive → destination is a link to the original target). This box is Linux, so the Windows arm was verified with cargo check/clippy --target x86_64-pc-windows-msvc and the test is Windows-only by design.

  8. The cap now counts cumulative decompressed output inside each streaming decoder (total_out in the zlib/brotli/zstd streaming state, which lives for the whole response and survives every drain), and trips a dedicated DecompressedBodyTooLarge error code distinct from the corrupt-data errors. Mechanism proven by temporarily lowering the constant to 64 KiB: both streaming and buffered modes reject with that code; with it restored, the full encoding matrix decodes. Your third point (a configurable limit) is the one piece left open — it is also the only way to make the trip CI-testable — happy to add BUN_CONFIG_MAX_DECOMPRESSED_BODY_SIZE here if you want it in this PR rather than a follow-up.

  9. __proto__: null added to all three tables. Your three probes reproduce exactly against node v26 vs released bun (including the no-pollution resolveObject divergence via Object.prototype.constructor), and both new tests in url-parse-format.test.js fail on released bun. I left the ws/wss slashedProtocol side note alone — that's a separate behavior change.

  10. Matched md4c on both counts: the budget is the exact upstream formula (16 * min(size, 1 MiB / 16), md4c.c:7216) and exhaustion now zeroes the budget and degrades the reference to literal text (Ok(None) at the two charge sites, same shape as the adjacent nesting guards) — ParserError::RefDefOutputTooLarge and its throw arm are deleted. The test now asserts the 3000-reference document parses, with the first reference linked and later ones rendered as literal [a] text (fails on released bun, which has no budget at all). Description updated.

  11. Added both tests (DATA/WINDOW_UPDATE/PRIORITY/RST_STREAM pipelined behind the refused HEADERS in one write → RST(REFUSED_STREAM), no GOAWAY, a later stream completes; and a HEADERS+CONTINUATION split where the refused CONTINUATION half inserts an HPACK dynamic-table entry that the next stream references by index) plus the one-line comment on the last_stream_id advance. I also ran your "obvious future cleanup" mutation — folding the advance under !refused — and the first test fails with GOAWAY exactly as you predicted.

  12. The live missed sibling is fixed at the layer you describe: Macro.rs now sets Property::IsComputed on a serialized own __proto__ key (esbuild's per-property marker), with a bundler_edgecase test that fails on released bun (the macro's JSON.parse('{"__proto__":…}') really did set the prototype). Completing the re-layering so the printer flag can be deleted is wider than the parsers: TOML builds its properties through E::Object::set/set_rope in ast/e.rs and YAML at ~8 sites in yaml.rs, and doing only json/json5 would leave two redundant mechanisms (every json print path is already covered by the printer flag today). I'd do that as one focused follow-up; say if you'd rather have it in this PR.

  13. is_reply_kind is now derived from SUBSCRIPTION_PUSH_MESSAGES (strip an optional p/s prefix and require the base kind to be subscribe/unsubscribe), the unreachable unprefixed arms are gone, and the mock-server test you asked for is in (>3 psubscribe ack pipelined ahead of a PING; both settle correctly). With the p prefix mutated away the test fails exactly as you predicted — the PING's +PONG resolves the psubscribe promise.

  14. You're right, and it was a regression this branch introduced relative to released Bun (which already had spec order). The fast path now snapshots only identifiers+offsets inside forEachProperty (no user JS runs there), then interleaves Get with conversion: getDirect(offset) is used only while object->structure() still equals the snapshotted structure, and after the first transition it falls back per-key to the generic getOwnPropertySlot/Get steps — lifetime-safe and spec-ordered, no values held across user code. The two tests are flipped to the interleaved expectations (x-second: "replaced", deleted key excluded) — they pass on released bun and fail on the unfixed branch — plus a getter-bearing twin pinning the slow path to the same answer. Noted in the description.

  15. Restored npm's override rule: the pre-include check is now is_unconditionally_excluded (the root-only patterns at depth 1 plus the can_override == false entries), and is_excluded delegates to the same helper so there is one source of truth. Verified against npm 10.9.3 (files: ["lib", ".gitignore", "bunfig.toml", "npm-debug.log", ".DS_Store", …] packs all of those and excludes only .git/.npmrc/lockfiles); both tests updated to that exact entry list.

  16. Confirmed with a differential probe: 96 input/option combinations where the additive cache changed the wrap vs released Bun (word-initial U+20E3 / VS16 / U+0301). The cached-width update now consults the existing grapheme-break engine (Bun__graphemeBreak) for the appended word's first non-ASCII codepoint and falls back to the exact pre-PR full recompute whenever it can fuse with the separator; the ASCII fast path is byte-for-byte what it was. New test.each over the trim×wordWrap×hard matrix × four input shapes, golden outputs taken from released Bun (the oracle here); 24 of the 64 rows fail with the unfixed cache.

  17. Added the two tests you specified: exports: {"./*": "./" + "*/".repeat(100) + "x"} with a ~300-byte specifier (passes the coarse pre-check on every platform including the macOS 1024-byte bound, blows up in the replace) plus the imports (#deep/*) sibling — both assert e.code === "MODULE_NOT_FOUND", and both fail on released bun with the child exiting 134 from the slice-bounds panic. Deleting either macro invocation now fails the corresponding test.

  18. Added the regression test (empty gzip/deflate/zstd roundtrips, results dropped, Bun.gc(true) each iteration, covering both leak_list_into_uint8array callers) and the Behavior-notes sentence naming the GC-time invalid free the is_empty branch removes. One honest caveat: the released non-ASAN build does not trap the bad mi_free, so the abort only manifests on the ASAN/debug lanes — which is where this test will do its job.

  19. Implemented the two sentinel payloads, mirroring NodeHTTP.cpp: "2" → no-body, anything else → close-delimited; the fallback now suppresses the auto Content-Length/Transfer-Encoding injection for either and ends the socket after a close-delimited body (EOF-terminated). Verified against real node over an allowHTTP1 secure server (HEAD: 200 with neither framing header; close-delimited: body written raw, connection closed) and added both tests next to the existing fallback tests in node-http2.test.js — both fail on released bun.

  20. Comments now sit at both sites naming each other and the reachability split (the Rust check is the only validator for HTTP/1.0 and requireHostHeader: false; the empty-Host: divergence is deliberate — the parser accepts a zero-length reg-name, Request.rs won't synthesize http:///path). Tests: the empty-Host-on-HTTP/1.1 pin, plus a charset-parity test.each over every visible ASCII byte sent as Host: a<byte>b over HTTP/1.1 (parser decides) and HTTP/1.0 (the Rust check decides) requiring both accept-sets to equal the RFC 3986 uri-host [":" port] set — so the two tables cannot drift silently.

  21. Added the raw-socket params pair to bun-serve-routes.test.ts on the existing /users/:id route: raw C3 A9"é", lone 0xE9 → U+FFFD. Both fail on released bun ("é" / "é").

  22. Done — your point exactly; the Behavior notes now say it: clients that requested subprotocols close with 1002 "Missing client protocol" when the server omits the echo, instead of opening with protocol === ""; handshakes that requested no subprotocol are unaffected.

Comment thread src/node-fallbacks/url.js
Comment on lines +109 to +123
{
const spkacText = spkacValid.toString('utf8').trimEnd();
const padded = Buffer.alloc(Buffer.byteLength(spkacText) + 1);
padded.write(spkacText);
const zeroLengthView = padded.subarray(0, 0);
assert.strictEqual(Certificate.verifySpkac(zeroLengthView), false);
assert.strictEqual(Certificate.exportPublicKey(zeroLengthView), '');
assert.strictEqual(Certificate.exportChallenge(zeroLengthView), '');

for (const input of [Buffer.alloc(0), Buffer.from(' \n\r\t'), '', ' \n\r\t']) {
assert.strictEqual(Certificate.verifySpkac(Buffer.from(input)), false);
assert.strictEqual(Certificate.exportPublicKey(input), '');
assert.strictEqual(Certificate.exportChallenge(input), '');
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 This adds a Bun-authored regression block to test/js/node/test/parallel/test-crypto-certificate.js, but test/js/node/test/parallel/CLAUDE.md says "These are Node.js compatibility tests not written by Bun, so we cannot modify these tests" — local additions here will conflict with or be silently lost on the next upstream Node.js test sync. The assertions themselves are fine; just move the block to a Bun-owned file (e.g. test/js/node/crypto/crypto-certificate.test.ts).

Extended reasoning...

What this is. The PR's regression-test commit (13aff18) adds a 15-line block at test/js/node/test/parallel/test-crypto-certificate.js:109-123 covering the new empty/whitespace-only SPKAC input handling in ncrypto.cpp (VerifySpkac / ExportPublicKey / ExportChallenge). The block constructs a zero-length view, an empty buffer, and whitespace-only inputs and asserts that all three SPKAC helpers return the empty/false result instead of passing a zero-length pointer to NETSCAPE_SPKI_b64_decode.

Why placement matters. test/js/node/test/parallel/CLAUDE.md:7 reads, verbatim: "These are Node.js compatibility tests not written by Bun, so we cannot modify these tests". This directory mirrors nodejs/node/test/parallel and is periodically re-synced from upstream; git log confirms it is rarely touched outside vendor updates, so the convention is enforced in practice, not merely aspirational. A locally-added block in a vendored file is exactly what the next update-vendor / re-sync will clobber or turn into a merge conflict — and because the file is run as a standalone script (exits 0 on success) rather than through the Bun test runner, there is no describe/it scaffolding to make a 3-way merge trivial.

Step-by-step.

  1. test/js/node/test/parallel/CLAUDE.md exists and prohibits local edits to files in this directory.
  2. Upstream nodejs/node/test/parallel/test-crypto-certificate.js does not contain the lines 109-123 block — it ends after the "Test static methods" / stripLineEndings section. The new block is Bun-authored (commit 13aff18 "Add regression tests across the touched subsystems").
  3. On the next upstream sync, one of two things happens: (a) the file is overwritten wholesale and the regression test silently disappears, leaving the ncrypto.cpp if (length == 0) return ...; guards untested; or (b) the sync produces a textual conflict at lines 108-124 that someone has to resolve by hand.
  4. Either outcome is the failure mode the directory's CLAUDE.md exists to prevent.

Why nothing else covers it. Every other regression test in this PR's test commit lives in a Bun-owned file (test/js/node/crypto/*.test.ts, test/js/bun/..., etc.); this is the only edit under test/js/node/test/parallel/. There is no existing test/js/node/crypto/crypto-certificate.test.ts, so the assertions have nowhere else to land without creating one — which is presumably why they ended up here.

Impact. No runtime impact — the assertions are correct and pass. This is a test-organization / maintainability issue: the regression coverage for the ncrypto.cpp SPKAC empty-input guards is at risk of being lost on the next vendor sync, and the edit violates an explicit, documented project convention.

Fix. Move lines 109-123 verbatim into a new Bun-owned file, e.g. test/js/node/crypto/crypto-certificate.test.ts:

import { expect, test } from "bun:test";
import { Certificate } from "node:crypto";

test("Certificate SPKAC helpers handle empty and whitespace-only input", () => {
  for (const input of [Buffer.alloc(0), Buffer.from(" \n\r\t"), "", " \n\r\t"]) {
    expect(Certificate.verifySpkac(Buffer.from(input))).toBe(false);
    expect(Certificate.exportPublicKey(input)).toBe("");
    expect(Certificate.exportChallenge(input)).toBe("");
  }
});

and revert test/js/node/test/parallel/test-crypto-certificate.js to its upstream content.

Comment on lines +353 to 356
if (i > 0) controller.write("'" + toSingleQuote(decoded) + "');__bun_f.push(");
controller.write('Uint8Array.from(atob("');
for (; i < chunks.length; i++) {
const chunk = chunks[i];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 Pre-existing: the multi-chunk base64 fallback this catch block falls through to (line 358) writes base64.slice(1, -1) — stripping the first and last character of every chunk's base64 encoding before concatenating them inside one atob("…"), which is undecodable on the client (and even without the slice, concatenating per-chunk btoa() outputs is only valid when every prior chunk's byte length is a multiple of 3). The single-chunk sibling at line 326 does this correctly with no slice; since this PR already rewrites line 353 in the same catch block, it'd be a one-line fix to also drop the .slice(1, -1) and base64-encode the concatenated remaining bytes once.

Extended reasoning...

What the bug is. In writeManyFlightScriptData's catch branch — the very block this PR refactors at line 353 — the per-chunk base64 fallback loop at line 358 does controller.write(base64.slice(1, -1)). btoa() returns a plain base64 string with no surrounding delimiters (the " quotes are written separately on lines 354 and 360), so .slice(1, -1) strips the first and last data character of every chunk's encoding before concatenating them into a single atob("…") call on the client. The first character of a base64 encoding is always real data, never padding, so this is unconditionally wrong. The single-chunk sibling writeSingleFlightScriptData does the same operation at line 325–326 with no slice, confirming this is a bug rather than an intentional delimiter-strip.

Step-by-step proof. Take two buffered RSC chunks A = Uint8Array.of(0xFF, 0x61, 0x62) and B = Uint8Array.of(0x63, 0x64), with the streaming decoder constructed at line 144 as new TextDecoder('utf-8', { fatal: true }):

  1. drainRscChunks calls writeManyFlightScriptData([A, B], decoder, controller) (line 219). chunks.length === 2, so the early return at line 339 does not fire.
  2. Loop iteration i = 0: decoder.decode(A, {stream:true}) throws (0xFF is never valid UTF-8). Control enters the catch with i === 0, decoded === "".
  3. Line 353 (i > 0) is false, so the catch writes Uint8Array.from(atob(" (line 354).
  4. Iteration i = 0: btoa(String.fromCodePoint(0xFF, 0x61, 0x62)) === "/2Fi"; .slice(1, -1)"2F" written.
  5. Iteration i = 1: btoa(String.fromCodePoint(0x63, 0x64)) === "Y2Q="; .slice(1, -1)"2Q" written.
  6. Line 360 closes with "),m=>m.codePointAt(0))</script>. The client receives atob("2F2Q"), which decodes to bytes [0xD8, 0x5D, 0x90] — nothing like the original [0xFF, 0x61, 0x62, 0x63, 0x64]. The inline flight payload is corrupted; React's client flight reader fails.

Even with the .slice(1, -1) removed there is a second layer of breakage: concatenating independent btoa() outputs into one atob() input is only valid when every non-final chunk's byte length is a multiple of 3 (otherwise the prior chunk emits = padding mid-string, which atob rejects). In the example above, chunk B is 2 bytes → "Y2Q=", and atob("/2FiY2Q=") happens to work only because B is last; swap the order and atob("Y2Q=/2Fi") throws.

Why nothing prevents it. The catch path is reachable: the shared decoder used at line 219 is constructed with { fatal: true } (line 144), so decode(chunk, {stream:true}) throws on any invalid-UTF-8 byte sequence in the RSC payload (React Flight can carry binary typed-array rows; the surrounding code's own comment at line 351 — "Since this is rare" — acknowledges the path exists). And chunks.length === 1 doesn't always short-circuit: rscChunks accumulates via push at line 249 whenever HTML is mid-flow, so multiple chunks routinely arrive before drainRscChunks runs. The PR's own change at line 353 sits one line above the broken loop in the same catch block, so this is squarely "code the PR interacts with".

Impact. When the catch path is hit with ≥2 buffered chunks, the inline __bun_f.push(Uint8Array.from(atob('…'))) script the SSR stream embeds is undecodable on the client — either atob throws (mid-string =), or it decodes to garbage bytes that React's flight client rejects. Hydration of that part of the page fails. Rare (RSC payloads are React-generated UTF-8; reaching this needs binary content in the flight stream), but a hard correctness fault when it does.

Fix. Mirror the single-chunk path: base64-encode the concatenated remaining bytes once, with no slice. E.g.

const remaining = Buffer.concat(chunks.slice(i));
const base64 = btoa(String.fromCodePoint(...remaining));
controller.write(`Uint8Array.from(atob("${base64}"),m=>m.codePointAt(0))</script>`);

(or accumulate into one Uint8Array and call btoa once). This is a pre-existing bug the PR did not introduce — flagging it because the PR already rewrites the line immediately above it in the same function.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 41

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
src/runtime/shell/shell_body.rs (1)

999-1026: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Escape keyword fragments, not only complete keywords.

Exact checks miss cases like $`i${"f"} true; then echo x; fi` or $`el${"if"} ...` where an interpolated fragment combines with literal text into an if-clause keyword. The interpolated value should be routed through append_js_str_ref when it can participate in forming if, else, elif, then, or fi, or the builder needs adjacent-literal context before deciding.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/shell/shell_body.rs` around lines 999 - 1026, The keyword
escaping check in append_utf8 only detects complete tokens, so interpolated
fragments that combine with adjacent text can still form shell control keywords.
Update the logic around needs_escape_utf8_ascii_latin1 and
IfClauseTok::from_text in shell_body.rs so append_js_str_ref is used whenever
the current fragment can contribute to forming if, else, elif, then, or fi with
neighboring content, or make the builder inspect adjacent-literal context before
choosing the UTF-8 fast path.
test/js/bun/util/filesystem_router.test.ts (1)

685-711: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Precompute the collision instead of searching for it in the test.

This 600k-iteration search is what forces the new 60_000 timeout, and test/js/bun/** explicitly avoids per-test timeouts. Check in a known colliding pair (or generate it once offline) so the regression stays deterministic and fast. As per coding guidelines, test/js/bun/** should not add explicit per-test timeout arguments.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/bun/util/filesystem_router.test.ts` around lines 685 - 711, The
filesystem router collision test currently brute-forces a hash collision with a
long loop and adds an explicit per-test timeout, which should be avoided in
test/js/bun/**. Replace the search in the Bun.FileSystemRouter test with a
precomputed known colliding pair (or a fixed fixture generated offline) so the
test is deterministic and fast. Keep the existing assertions around route
matching and non-matching behavior, but remove the 60_000 timeout and the
runtime collision search logic.

Source: Coding guidelines

src/runtime/webview/WebViewHost.cpp (1)

717-732: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Validate the outer bunConsole payload before sending Objective-C selectors.

Lines 717-720 still call objc::NSString(type).toWTF() and NSArray::count() before any guard on type or args. A page can call webkit.messageHandlers.bunConsole.postMessage(...) with arbitrary bridged objects, so this can still crash on unrecognized selector before the per-element check runs. As per coding guidelines, "Validate untrusted input BEFORE any processing, allocation, or side effect" and "Assume userland is hostile on security-relevant paths."

Suggested fix
-    WTF::CString typeC = objc::NSString(type).toWTF().utf8();
+    if (!objc::Ref(type).isKindOf(objc::NSString::cls)) type = nullptr;
+    if (!objc::Ref(args).isKindOf(objc::NSArray::cls)) args = nullptr;
+    WTF::CString typeC = objc::NSString(type).toWTF().utf8();
     uint32_t typeLen = static_cast<uint32_t>(typeC.length());
     objc::NSArray arr(args);
     uint32_t argCount = args ? static_cast<uint32_t>(arr.count()) : 0;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/webview/WebViewHost.cpp` around lines 717 - 732, The outer
bunConsole payload is still being dereferenced before validation in
WebViewHost::postMessage handling, so arbitrary bridged objects can trigger
Objective-C selector crashes. Add an upfront guard for both type and args before
calling objc::NSString(type).toWTF() or NSArray::count(), and bail out early
unless the payload is the expected NSString/NSArray shape. Keep the existing
per-element checks in the loop, but make the initial payload validation happen
first so no processing or allocation occurs on untrusted input.

Source: Coding guidelines

src/runtime/bake/DevServer/HmrSocket.rs (1)

124-176: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Unsubscribe branch is unreachable — uWS never unsubscribes dropped topics.

The else if on Line 168 repeats the exact if condition from Line 126 (new_bits.contains(bit) && !self.subscriptions.contains(bit)), so the ws.unsubscribe(&field.uws_topic()) you updated on Line 172 can never run. on_unsubscribe (Line 323) only adjusts visualizer counters and does not call ws.unsubscribe, so when a client drops a topic the socket stays subscribed at the uWS layer and keeps receiving that topic's messages even though self.subscriptions is updated. The in-tree comment already marks this as likely a bug.

🐛 Proposed fix to the unsubscribe condition
-                    } else if new_bits.contains(bit) && !self.subscriptions.contains(bit) {
-                        // Note: this `else if` condition is identical to the `if`
-                        // above and is therefore unreachable; likely a bug
-                        // (intended: `!new && old` → unsubscribe).
+                    } else if !new_bits.contains(bit) && self.subscriptions.contains(bit) {
                         let _ = ws.unsubscribe(&field.uws_topic());
                     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/bake/DevServer/HmrSocket.rs` around lines 124 - 176, The
unsubscribe path in HmrSocket::handle_subscription_changes is unreachable
because the `else if` repeats the same condition as the subscribe branch, so
dropped HmrTopic values never call `ws.unsubscribe`. Update that branch to check
for topics present in `self.subscriptions` but absent from `new_bits`, and keep
the unsubscribe call there. Make sure the logic still preserves the existing
subscribe-side hooks for `feature_flags::BAKE_DEBUGGING_FEATURES` and leaves
`self.on_unsubscribe` as counter cleanup only.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@completions/bun.bash`:
- Around line 50-52: The script parsing in the bun.bash completion logic is
trimming using the last colon, which breaks script names that contain
colon-separated commands. Update the completion extraction in the scripts loop
to split on the first ":" in the command text before the regex check, so entries
like dev: bun --hot http://localhost:3000 are preserved and added to
package_json_compreply.

In `@packages/bun-uws/src/HttpParser.h`:
- Around line 707-713: `isValidHostFieldValue()` currently only checks allowed
bytes and can accept malformed host strings like extra colons or non-numeric
ports. Update the host validation logic in `HttpParser::isValidHostFieldValue`
to enforce the actual `uri-host [ ":" port ]` structure, including proper
bracketed IPv6 handling, rejecting invalid unbracketed colon placement, and
requiring a numeric port when present. Mirror the same structural validation in
`Request::is_valid_host_header` so malformed authorities cannot be used to
synthesize invalid `Request.url` values.

In `@src/glob/GlobWalker.rs`:
- Around line 967-973: The `GlobWalker` follow-link path is passing `Stat` by
value into `check_followed_link`, which triggers the clippy gate because `Stat`
is too large to copy eagerly. Update the `check_followed_link` call site in
`GlobWalker` to borrow the `Stat` for validation, then only copy/clone the
accepted link when assigning `followed_link = Some(link)`. Apply the same
pattern in the other affected `GlobWalker` match arms that use
`check_followed_link` so the borrow-only check is consistent.

In `@src/install/lockfile.rs`:
- Around line 3317-3321: The in_trusted_dependencies helper currently returns
true from the truncated hash lookup alone, which can incorrectly trust a
colliding package name. Update the trusted dependency check in
lockfile::in_trusted_dependencies to verify the stored exact name bytes,
matching the fail-closed behavior already used by has_trusted_dependency, so
only an exact name match can be treated as trusted before extraction logic runs.

In `@src/js_printer/lib.rs`:
- Line 8301: The dev-server lazy-export path is still missing the
`was_lazy_export` state, so `print_dev_server_module` can emit
`ast.has_lazy_export` through `print_expr` without the protection that
`print_common_js` already gets. Update the dev-server printer to wire
`tree.has_lazy_export` into the same `was_lazy_export` flag on the relevant
printer state, using the `print_dev_server_module` flow and the shared printer
setup around `printer.was_lazy_export`, so both code paths handle data-file
`__proto__` consistently.

In `@src/jsc/bindings/CookieMap.cpp`:
- Around line 197-200: The cookie prefix check in CookieMap::findOrCreateCookie
delete-tombstone handling is too permissive because it uses case-insensitive
matching for the "__Secure-" and "__Host-" prefixes. Update the secure-flag
detection in the CookieMap logic to use exact case-sensitive prefix checks so
only cookies with the canonical prefixes are treated as special, ensuring the
tombstone created via Cookie::create preserves the correct Secure attribute for
names like "__secure-id".

In `@src/jsc/bindings/decodeURIComponentSIMD.cpp`:
- Around line 22-31: The current decode path in decodeURIComponentSIMD appends
each literal run immediately, which breaks mixed byte sequences split between
raw bytes and %XX bytes. Update the shared decoding flow around appendLiteralRun
and the percent-decoding loop to keep a pending byte buffer for the active run,
append both literal bytes and decoded percent bytes into that buffer, and only
UTF-8-decode once per combined run before writing to the StringBuilder. Make the
same change in the other affected decode helpers so byte callers like mixed raw
0xC3 plus %A9 are emitted as one merged UTF-8 sequence instead of separate
replacement characters.

In `@src/jsc/bindings/node/crypto/JSECDHConstructor.cpp`:
- Around line 90-100: The key buffer obtained in JSECDHConstructor::construct
via getArrayBufferOrView is only kept as a raw view, but later coercions like
JSECDH::getFormat and curveValue.toWTFString can run JS and invalidate it. Root
the JSArrayBufferView (or copy its bytes into owned storage) before those calls,
then read the span only after liveness is guaranteed so the temporary buffer
cannot be reclaimed mid-construction.

In `@src/jsc/bindings/webcrypto/CryptoKeyOKP.cpp`:
- Around line 148-157: Update the shared OKP allowlist so X25519 is accepted
consistently across all consumers. In CryptoKeyOKP, extend isValidOKPAlgorithm()
to return true for X25519 as well as Ed25519, since
SerializedScriptValue::readOKPKey() depends on it before performing its
curve/algorithm cross-check. Keep the validation logic in sync with the new
X25519 branch so structured-cloned OKP keys are not rejected.

In `@src/md/parser.rs`:
- Around line 191-199: The budget check in charge_ref_def_output is too strict
because it rejects exact-fit reference definitions; update the comparison so a
reference whose combined dest_len and title_len exactly matches
max_ref_def_output is accepted, while still zeroing the budget and returning
false only when the combined size exceeds the remaining allowance.

In `@src/runtime/api/BunObject.rs`:
- Around line 2376-2406: The libdeflate success paths still manually convert a
Vec into a Uint8Array and register global_deallocator, which bypasses the
empty-Vec safety in leak_list_into_uint8array. Update the libdeflate branches in
BunObject::leak_list_into_uint8array callers to reuse
leak_list_into_uint8array(global_this, list) for successful decompression
results so empty outputs are handled without a dangling pointer or GC crash.

In `@src/runtime/bake/bun-framework-react/ssr.tsx`:
- Around line 342-349: The streaming decode path in ssr.tsx leaves the final
buffered UTF-8 bytes unflushed, so the combined payload can be truncated when
the last chunk ends mid-sequence. Update the chunk-combining logic in the
decoder loop to flush the TextDecoder state with a final decode after processing
the chunks, and make sure the fallback path that uses base64 also includes any
buffered tail bytes. Use the existing decoder and controller.write flow to keep
the output consistent.

In `@src/runtime/bake/DevServer/ErrorReportRequest.rs`:
- Around line 570-575: The comment in ErrorReportRequest should be shortened to
fit the 3-line limit while keeping the key behavior note. Trim the explanatory
text near the UTF-8 handling logic so the guidance stays concise, and preserve
only the essential distinction between well-formed UTF-8 continuation bytes and
the raw-byte fallback behavior in the report body.

In `@src/runtime/cli/upgrade_command.rs`:
- Line 73: The stable release checksum handling is currently failing open
because parse_asset_digest() can collapse bad metadata into Integrity::default()
and the extraction path later skips verification for unsupported/default tags.
Update the upgrade flow so stable release assets must carry a real digest and
refuse extraction when it is missing or malformed, while keeping the canary path
explicitly exempt only when canary metadata does not provide a digest. Use the
existing parse_asset_digest() and the asset verification/extraction logic in
upgrade_command.rs to ensure every stable path requires Some(digest) before
proceeding.

In `@src/runtime/webcore/Blob.rs`:
- Around line 3166-3175: The transfer path in Blob’s `get_array_buffer_transfer`
/ `get_bytes_transfer` is incorrectly falling back to the clone branch because
the local `_store` reference keeps the store from appearing uniquely owned.
Update the ownership check around the `self.store().is_some_and(|s|
!s.has_one_ref())` guard, or restructure the transfer-only flow so it does not
hold the extra ref before `to_array_buffer(..., Lifetime::Transfer)` /
`take_store()`. Ensure the uniquely-owned in-memory blob can still take the
transfer path instead of always calling `to_array_buffer_view_with_bytes::<{
Lifetime::Clone }, TYPED_ARRAY_VIEW>`.

In `@src/runtime/webcore/TextDecoder.rs`:
- Around line 286-294: The shared/resizable fast path in TextDecoder’s input
handling is copying the entire backing buffer instead of preserving the
JSUint8Array view. Update the match arm in TextDecoder.rs so the owned copy is
created from the typed array’s view data (respecting byteOffset and byteLength)
rather than array_buffer.slice(), and keep the non-shared path using
uint8array.slice() as-is.

In `@src/s3_signing/credentials.rs`:
- Line 1119: The checked bucket slice fallback in the endpoint parsing logic is
being evaluated eagerly by `unwrap_or`, triggering the Clippy warning. Update
the slice handling in the `credentials.rs` logic that returns from
`endpoint.get(...)` to use a lazy fallback instead, and apply the same change in
the other matching slice call referenced by the review so the fallback slice is
only computed when needed.

In `@src/shell_parser/parse.rs`:
- Around line 1915-1919: The if-clause token detection in if_clause_tok_at only
checks is_interpolated_position(range.start), so mixed literal/interpolated
spans can still be misclassified as if/elif/then/fi. Update if_clause_tok_at to
reject any TextRange that overlaps js_string_ranges across the entire range
before calling IfClauseTok::from_text, using the existing if_clause_tok_at and
is_interpolated_position logic as the entry point.

In `@src/sql_jsc/postgres/PostgresSQLConnection.rs`:
- Around line 2974-2976: The COPY handling in PostgresSQLConnection::reader is
only skipping CopyInResponse and CopyBothResponse, which can leave the
server-side COPY state open and pin the connection. Update the unsupported COPY
input-mode paths in the message dispatch to actively abort the operation by
either rejecting and closing the connection or sending CopyFail followed by Sync
after reader.skip_message() consumes the response, using the existing
PostgresSQLConnection and reader flow to locate the fix.

In `@src/sql_jsc/postgres/PostgresSQLQuery.rs`:
- Around line 681-685: The updated comments in
PostgresSQLQuery::get_or_put-style code paths are too long and exceed the repo’s
3-line limit. Shorten both affected comment blocks to at most three lines each
while preserving the same invariant and intent, and keep the wording concise
around the JsCell::with_mut / raw slot ptr borrow-lifetime explanation.

In `@test/cli/inspect/inspect.test.ts`:
- Around line 318-330: The inspector test helper exits on the "Listening:"
banner before the websocket URL is actually parsed, which can race on chunked
stderr output. In the stderr-reading loop in inspect.test.ts, keep collecting
chunks and only break once the URL parsed via new URL(line) yields a ws/wss
protocol, using the existing url variable as the observable condition instead of
the banner text. This should be fixed in the metadataInspectee.stderr reader
logic that currently checks stderr.includes("Listening:").

In `@test/cli/install/bun-install-tarball-integrity.test.ts`:
- Around line 721-727: The test currently reads bun.lock before confirming the
install succeeded, which can mask a real bun install failure with ENOENT. In
bun-install-tarball-integrity.test.ts, update the flow around the Promise.all
result to assert exitCode (and any failure state) before calling
file(join(String(dir), "bun.lock")).text(), using the existing subprocess
variables proc, exitCode, and lockContent to keep the failure source clear.

In `@test/cli/install/bun-pack.test.ts`:
- Around line 1065-1070: The bun-pack tests currently only verify that a few
expected tarball entries exist, so they can miss accidentally packed sensitive
files. Update the assertions in the bun-pack test cases around the tarball
entries checks to explicitly verify that excluded paths such as .git, .npmrc,
and package-lock.json are not present, using the existing tarball.entries data
in the same test blocks. Keep the references anchored on the bun-pack test
assertions so the fix applies to both affected cases.

In `@test/cli/install/symlink-path-traversal.test.ts`:
- Around line 717-724: The install test is reading node_modules/.bin before
asserting the bun install result, so a failed install can throw from readdir and
hide the real stdout/stderr/exitCode failure. In symlink-path-traversal.test.ts,
move the process-result check for exitCode (and its logging) ahead of the .bin
directory read, using the existing install result variables and the
readdir(join(..., ".bin")) assertion afterward so the test surfaces the actual
regression first.

In `@test/js/bun/http/bun-serve-routes.test.ts`:
- Around line 83-90: The raw-socket promise in bun-serve-routes.test.ts only
resolves on `end` and rejects on `error`, so premature connection shutdown can
hang the test. Update the socket handling around `Promise.withResolvers`,
`net.connect`, and the `socket.on(...)` listeners to reject on `close` (and
`abort` if applicable) as well, and make sure settlement is guarded so only the
first terminal event wins. This should make the fixture fail fast with a useful
error instead of timing out.

In `@test/js/bun/http/request-smuggling.test.ts`:
- Around line 1396-1406: The body assertions in the Host echo tests are flaky
because sendRawRequest() returns on the first data chunk before the full HTTP
response is received. Update the 200-path cases to use
sendRawRequestUntilClose() (or otherwise buffer using Content-Length/Connection:
close) in this request-smuggling test block so the echoed Host body is fully
read before asserting.
- Around line 1562-1593: The host-byte matrix in the request-smuggling test is
treating bare [ and ] as valid generic Host bytes, which incorrectly blesses
malformed authorities. Update the matcher logic around checkByte/isHostByte in
this test so brackets are excluded from the accepted reg-name byte set and only
covered where they are genuinely valid as IPv6 literal delimiters. Keep the
expected HTTP/1.1 vs HTTP/1.0 assertions aligned with the actual parser behavior
and req.url normalization for valid Host inputs only.

In `@test/js/bun/md/md-edge-cases.test.ts`:
- Around line 1121-1123: Tighten the link-cap assertion in the markdown
edge-case test so it catches regressions earlier; the current upper bound in the
resolved link count check is too loose. Update the expectation around the
html.match(/<a href=/g) count in md-edge-cases.test.ts to use a much smaller,
fixture-appropriate limit while keeping the existing lower-bound sanity check.

In `@test/js/bun/shell/bunshell.test.ts`:
- Around line 159-165: The shell test in bunshell.test.ts only round-trips tab
and question-mark interpolation, so it does not actually verify the
carriage-return case it names. Update the test in the quoted-values case to
include a\rb in the $`echo ...` execution and assert its output alongside the
existing checks, using the same test and $.escape coverage so the round-trip
path for carriage return is exercised too.

In `@test/js/bun/spawn/spawn.ipc.test.ts`:
- Around line 71-74: The IPC test in the spawn handler currently resolves as
soon as the 33rd message is received, so it can miss duplicate or extra
deliveries; update the `ipc` callback in `spawn.ipc.test.ts` to assert the exact
message count by rejecting or failing immediately when `messages.length` exceeds
33, while still resolving only when it reaches exactly 33. Use the existing
`messages`, `resolve`, and `reject` flow in the `ipc(message)` handler so the
test enforces the strongest invariant around delivery count.

In `@test/js/bun/util/wrapAnsi.test.ts`:
- Around line 488-505: The large-input subprocess checks in wrapAnsi.test.ts are
hiding native diagnostics by piping stderr and never using it. Update the
Bun.spawn calls in the affected wrapAnsi test cases so stderr remains visible,
ideally by switching to stderr: "inherit" or by explicitly asserting/logging the
collected stderr alongside stdout and exitCode. Make the change in the
subprocess setup around the large-input Bun.wrapAnsi cases so crash/overflow
regressions surface useful output.

In `@test/js/bun/wasm/wasi.test.js`:
- Around line 63-85: The WASI path_open failure test is only checking FD 4,
which can miss leaks if allocation changes. In the wasi.test.js case around
path_open() and FD_MAP, capture the current FD_MAP size or keys before calling
wasi.wasiImport.path_open, then assert that the FD table is unchanged after the
EEXIST result. Keep the existing sentinel and memory assertions, but replace the
hardcoded descriptor probe with a direct before/after comparison of FD_MAP.

In `@test/js/node/crypto/crypto.key-objects.test.ts`:
- Around line 355-366: The createPrivateKey detached-buffer regression
assertions are too broad because bare toThrow() accepts any failure, not just
the detached ArrayBuffer path. Update the affected expectations in
crypto.key-objects.test.ts around createPrivateKey (including the similar block
later in the file) to assert the specific thrown error shape, such as the exact
error code or message produced by the detachment failure, so the test proves the
intended path rather than generic DER parsing errors.

In `@test/js/node/fs/cp.test.ts`:
- Around line 637-677: The test in the fs cp watcher flow can hang because
`allCreated` is only resolved by file events and never rejected if the child
process exits early. Update the `fs.watch`/`Promise.withResolvers` setup to
route `proc.exited` (and watcher shutdown/error paths) into `onWatchError`
unless `modeAtCreation.size` has reached `destNames.length`, so the awaited
`Promise.all` fails fast instead of timing out.

In `@test/js/node/http/node-http-connect.test.ts`:
- Around line 289-325: The CONNECT test helpers are only resolving on the
happy-path events, so regression cases can hang until the outer timeout. Update
the `Promise.withResolvers(...)` flows in the CONNECT test blocks around
`proxyServer.on("connect")` and the `net.connect(...)` client handlers to reject
on unexpected `close`/missing-event paths as well as `error`, and make sure any
early disconnect or absent `connect`/`data` sequence fails the promise instead
of დარჩening pending. Apply the same failure wiring to the other CONNECT test
cases referenced by the shared helper pattern so every awaited condition has a
corresponding reject path.

In `@test/js/node/http2/h2-conformance.test.ts`:
- Around line 756-825: Remove the per-test timeout override from the Bun
subprocess regression test by updating the test case defined in
h2-conformance.test.ts and the named test that exercises re-entrant read() via
sendTrailers; keep the existing assertions and fixture behavior unchanged, but
rely on the file-level runner timeout instead of passing a local 30_000 timeout.

In `@test/js/node/http2/node-http2.test.js`:
- Around line 2955-2985: `requestOverHttp1()` only rejects on request errors or
after the response ends, so response-side aborts/errors can hang the test until
timeout. Update the helper to wire the response’s failure events, alongside the
existing request `"error"` handling, so any mid-body abort or stream error
immediately rejects the promise. Keep the change localized to
`requestOverHttp1()` and preserve the existing resolve path for normal `"end"`
completion.

In `@test/js/node/test/parallel/test-crypto-certificate.js`:
- Around line 109-122: The SPKAC regression assertions were added to the
vendored Node test suite, which should remain an upstream mirror. Remove the new
coverage from Certificate.verifySpkac, Certificate.exportPublicKey, and
Certificate.exportChallenge in the Node test and relocate the same
zero-length/whitespace input checks into a Bun-owned crypto test file so the
upstream-synced test stays unchanged.

In `@test/js/valkey/reliability/resp-nesting-depth.test.ts`:
- Around line 5-79: The RESP parsing in countRespCommands and
createMockRedisServer is too eager because it counts commands from each data
chunk without buffering incomplete frames. Update the socket handling to keep a
per-socket buffer, append incoming data across data events, and only
count/consume complete RESP command frames before replying so split
HELLO/PING/PSUBSCRIBE packets are handled correctly.

In `@test/js/web/websocket/websocket-subprotocol-strict.test.ts`:
- Around line 198-208: The WebSocket strict subprotocol test only waits for
close, so unexpected open or error paths can hang the test instead of failing
fast. Update the await setup around WebSocket and the Promise.withResolvers
usage to reject on any unexpected ws.onopen or ws.onerror while still resolving
on ws.onclose, and keep the assertions in the same test around onopenMock and
close code/reason.

In `@test/v8/v8.test.ts`:
- Around line 430-469: The standalone addon setup in
standaloneAddonFiles/buildStandaloneAddon is pulling node-gyp via bun install,
which makes the test depend on the live registry. Remove the external install
step and change the test to use only local, prechecked-in dependencies or
fixtures so buildStandaloneAddon can run hermetically without network access.
Keep the fix focused on the standalone addon test helpers and their package.json
generation.

---

Outside diff comments:
In `@src/runtime/bake/DevServer/HmrSocket.rs`:
- Around line 124-176: The unsubscribe path in
HmrSocket::handle_subscription_changes is unreachable because the `else if`
repeats the same condition as the subscribe branch, so dropped HmrTopic values
never call `ws.unsubscribe`. Update that branch to check for topics present in
`self.subscriptions` but absent from `new_bits`, and keep the unsubscribe call
there. Make sure the logic still preserves the existing subscribe-side hooks for
`feature_flags::BAKE_DEBUGGING_FEATURES` and leaves `self.on_unsubscribe` as
counter cleanup only.

In `@src/runtime/shell/shell_body.rs`:
- Around line 999-1026: The keyword escaping check in append_utf8 only detects
complete tokens, so interpolated fragments that combine with adjacent text can
still form shell control keywords. Update the logic around
needs_escape_utf8_ascii_latin1 and IfClauseTok::from_text in shell_body.rs so
append_js_str_ref is used whenever the current fragment can contribute to
forming if, else, elif, then, or fi with neighboring content, or make the
builder inspect adjacent-literal context before choosing the UTF-8 fast path.

In `@src/runtime/webview/WebViewHost.cpp`:
- Around line 717-732: The outer bunConsole payload is still being dereferenced
before validation in WebViewHost::postMessage handling, so arbitrary bridged
objects can trigger Objective-C selector crashes. Add an upfront guard for both
type and args before calling objc::NSString(type).toWTF() or NSArray::count(),
and bail out early unless the payload is the expected NSString/NSArray shape.
Keep the existing per-element checks in the loop, but make the initial payload
validation happen first so no processing or allocation occurs on untrusted
input.

In `@test/js/bun/util/filesystem_router.test.ts`:
- Around line 685-711: The filesystem router collision test currently
brute-forces a hash collision with a long loop and adds an explicit per-test
timeout, which should be avoided in test/js/bun/**. Replace the search in the
Bun.FileSystemRouter test with a precomputed known colliding pair (or a fixed
fixture generated offline) so the test is deterministic and fast. Keep the
existing assertions around route matching and non-matching behavior, but remove
the 60_000 timeout and the runtime collision search logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a5083408-d425-4996-9d0b-5939e7fcbe8f

📥 Commits

Reviewing files that changed from the base of the PR and between fb24aac and 433d2dd.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (208)
  • .github/workflows/update-vendor.yml
  • completions/bun.bash
  • completions/bun.zsh
  • dockerhub/alpine/Dockerfile
  • dockerhub/debian-slim/Dockerfile
  • dockerhub/debian/Dockerfile
  • dockerhub/distroless/Dockerfile
  • packages/bun-debug-adapter-protocol/src/debugger/adapter.ts
  • packages/bun-debug-adapter-protocol/src/debugger/sourcemap.test.ts
  • packages/bun-release/src/npm/install.ts
  • packages/bun-usockets/src/context.c
  • packages/bun-usockets/src/crypto/openssl.c
  • packages/bun-usockets/src/internal/internal.h
  • packages/bun-uws/src/App.h
  • packages/bun-uws/src/HttpContext.h
  • packages/bun-uws/src/HttpContextData.h
  • packages/bun-uws/src/HttpParser.h
  • packages/bun-vscode/src/features/debug.ts
  • packages/bun-vscode/src/features/lockfile/lockfile.style.ts
  • src/bundler/ParseTask.rs
  • src/bundler/ThreadPool.rs
  • src/bundler/barrel_imports.rs
  • src/bundler/cache.rs
  • src/bundler/transpiler.rs
  • src/glob/GlobWalker.rs
  • src/http/lib.rs
  • src/http/ssl_config.rs
  • src/http_jsc/websocket_client/WebSocketUpgradeClient.rs
  • src/install/PackageInstaller.rs
  • src/install/PackageManager/PackageManagerEnqueue.rs
  • src/install/PackageManager/runTasks.rs
  • src/install/bin.rs
  • src/install/extract_tarball.rs
  • src/install/integrity.rs
  • src/install/isolated_install.rs
  • src/install/lockfile.rs
  • src/install/lockfile/Package.rs
  • src/js/internal/debugger.ts
  • src/js/internal/sql/postgres.ts
  • src/js/internal/sql/shared.ts
  • src/js/node/http2.ts
  • src/js/node/https.ts
  • src/js/node/url.ts
  • src/js/node/wasi.ts
  • src/js_parser/lexer.rs
  • src/js_parser_jsc/Macro.rs
  • src/js_printer/lib.rs
  • src/jsc/RuntimeTranspilerStore.rs
  • src/jsc/bindings/BunPlugin.cpp
  • src/jsc/bindings/CookieMap.cpp
  • src/jsc/bindings/ZigException.cpp
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/decodeURIComponentSIMD.cpp
  • src/jsc/bindings/napi.cpp
  • src/jsc/bindings/ncrypto.cpp
  • src/jsc/bindings/node/crypto/CryptoHkdf.cpp
  • src/jsc/bindings/node/crypto/CryptoPrimes.cpp
  • src/jsc/bindings/node/crypto/CryptoSignJob.cpp
  • src/jsc/bindings/node/crypto/CryptoUtil.cpp
  • src/jsc/bindings/node/crypto/JSECDHConstructor.cpp
  • src/jsc/bindings/node/crypto/KeyObject.cpp
  • src/jsc/bindings/sqlite/JSSQLStatement.cpp
  • src/jsc/bindings/v8/V8Number.cpp
  • src/jsc/bindings/v8/V8String.cpp
  • src/jsc/bindings/webcore/AbortSignal.cpp
  • src/jsc/bindings/webcore/AbortSignal.h
  • src/jsc/bindings/webcore/JSDOMConvertRecord.h
  • src/jsc/bindings/webcore/SerializedScriptValue.cpp
  • src/jsc/bindings/webcrypto/CryptoAlgorithmEd25519.cpp
  • src/jsc/bindings/webcrypto/CryptoKeyOKP.cpp
  • src/jsc/bindings/webcrypto/CryptoKeyRSA.cpp
  • src/jsc/bindings/webcrypto/CryptoKeyRSA.h
  • src/jsc/bindings/webcrypto/CryptoKeyRaw.cpp
  • src/jsc/bindings/webcrypto/CryptoKeyRaw.h
  • src/jsc/bindings/wrapAnsi.cpp
  • src/jsc/ipc.rs
  • src/md/links.rs
  • src/md/parser.rs
  • src/node-fallbacks/url.js
  • src/parsers/json_lexer.rs
  • src/paths/resolve_path.rs
  • src/resolver/lib.rs
  • src/resolver/package_json.rs
  • src/runtime/api/BunObject.rs
  • src/runtime/api/bun/h2/connection.rs
  • src/runtime/api/bun/h2_frame_parser.rs
  • src/runtime/bake/DevServer.rs
  • src/runtime/bake/DevServer/ErrorReportRequest.rs
  • src/runtime/bake/DevServer/HmrSocket.rs
  • src/runtime/bake/bun-framework-react/ssr.tsx
  • src/runtime/bake/dev_server/mod.rs
  • src/runtime/bake/mod.rs
  • src/runtime/cli/create_command.rs
  • src/runtime/cli/pack_command.rs
  • src/runtime/cli/upgrade_command.rs
  • src/runtime/napi/napi_body.rs
  • src/runtime/node/net/BlockList.rs
  • src/runtime/node/node_fs.rs
  • src/runtime/node/types.rs
  • src/runtime/server/mod.rs
  • src/runtime/server/server_body.rs
  • src/runtime/shell/shell_body.rs
  • src/runtime/socket/socket_body.rs
  • src/runtime/valkey_jsc/valkey.rs
  • src/runtime/webcore/Blob.rs
  • src/runtime/webcore/Request.rs
  • src/runtime/webcore/TextDecoder.rs
  • src/runtime/webview/ObjCRuntime.cpp
  • src/runtime/webview/ObjCRuntime.h
  • src/runtime/webview/WebViewHost.cpp
  • src/s3_signing/credentials.rs
  • src/semver/Version.rs
  • src/shell_parser/parse.rs
  • src/sourcemap/Mapping.rs
  • src/sourcemap/lib.rs
  • src/sql/mysql/protocol/AuthSwitchRequest.rs
  • src/sql/mysql/protocol/LocalInfileRequest.rs
  • src/sql/postgres/protocol/CopyData.rs
  • src/sql/postgres/protocol/NewReader.rs
  • src/sql_jsc/Cargo.toml
  • src/sql_jsc/mysql/JSMySQLConnection.rs
  • src/sql_jsc/mysql/MySQLConnection.rs
  • src/sql_jsc/mysql/MySQLQuery.rs
  • src/sql_jsc/postgres/PostgresSQLConnection.rs
  • src/sql_jsc/postgres/PostgresSQLQuery.rs
  • src/standalone_graph/StandaloneModuleGraph.rs
  • src/url/lib.rs
  • src/uws_sys/App.rs
  • src/uws_sys/libuwsockets.cpp
  • src/valkey/valkey_protocol.rs
  • test/bake/dev/bundle.test.ts
  • test/bake/dev/hot.test.ts
  • test/bake/dev/html.test.ts
  • test/bake/dev/production.test.ts
  • test/bundler/bundler_barrel.test.ts
  • test/bundler/bundler_browser.test.ts
  • test/bundler/bundler_edgecase.test.ts
  • test/bundler/bundler_loader.test.ts
  • test/bundler/native-plugin.test.ts
  • test/bundler/transpiler/runtime-transpiler.test.ts
  • test/cli/inspect/inspect.test.ts
  • test/cli/install/bun-create.test.ts
  • test/cli/install/bun-install-tarball-integrity.test.ts
  • test/cli/install/bun-install.test.ts
  • test/cli/install/bun-lockb.test.ts
  • test/cli/install/bun-pack.test.ts
  • test/cli/install/bun-upgrade.test.ts
  • test/cli/install/isolated-install.test.ts
  • test/cli/install/semver.test.ts
  • test/cli/install/symlink-path-traversal.test.ts
  • test/cli/run/filter-workspace.test.ts
  • test/cli/run/run-quote.test.ts
  • test/js/bun/cookie/cookie-map.test.ts
  • test/js/bun/glob/path-length.test.ts
  • test/js/bun/glob/scan.test.ts
  • test/js/bun/http/bun-serve-routes.test.ts
  • test/js/bun/http/decodeURIComponentSIMD.test.ts
  • test/js/bun/http/proxy-stress-errors.test.ts
  • test/js/bun/http/request-smuggling.test.ts
  • test/js/bun/http/serve.test.ts
  • test/js/bun/jsc/bun-jsc.test.ts
  • test/js/bun/md/md-edge-cases.test.ts
  • test/js/bun/net/socket.test.ts
  • test/js/bun/plugin/plugins.test.ts
  • test/js/bun/resolve/resolve.test.ts
  • test/js/bun/s3/s3-list-encode-overflow.test.ts
  • test/js/bun/shell/bunshell.test.ts
  • test/js/bun/spawn/spawn.ipc.bun-node.test.ts
  • test/js/bun/spawn/spawn.ipc.test.ts
  • test/js/bun/sqlite/sqlite.test.js
  • test/js/bun/util/filesystem_router.test.ts
  • test/js/bun/util/wrapAnsi.test.ts
  • test/js/bun/util/zstd.test.ts
  • test/js/bun/wasm/wasi.test.js
  • test/js/bun/webview/webview.test.ts
  • test/js/node/crypto/crypto-oneshot.test.ts
  • test/js/node/crypto/crypto-random.test.ts
  • test/js/node/crypto/crypto.key-objects.test.ts
  • test/js/node/crypto/hkdf-callback-null.test.ts
  • test/js/node/fs/cp.test.ts
  • test/js/node/fs/fs-mkdir.test.ts
  • test/js/node/fs/fs.test.ts
  • test/js/node/http/node-http-connect.test.ts
  • test/js/node/http/node-http-proxy-url.test.ts
  • test/js/node/http2/h2-conformance.test.ts
  • test/js/node/http2/node-http2.test.js
  • test/js/node/test/parallel/test-crypto-certificate.js
  • test/js/node/tls/node-tls-server.test.ts
  • test/js/node/url/url-parse-format.test.js
  • test/js/node/url/url-parse-query.test.js
  • test/js/node/v8/capture-stack-trace.test.js
  • test/js/sql/sql-helpers-validation.test.ts
  • test/js/sql/sql-mysql-auth-short-nonce.test.ts
  • test/js/sql/wire-frames.test.ts
  • test/js/sql/wire-frames.ts
  • test/js/valkey/reliability/connection-failures.test.ts
  • test/js/valkey/reliability/resp-nesting-depth.test.ts
  • test/js/web/crypto/web-crypto.test.ts
  • test/js/web/encoding/text-decoder.test.js
  • test/js/web/fetch/blob-cow.test.ts
  • test/js/web/fetch/fetch-redirect.test.ts
  • test/js/web/fetch/fetch.tls.test.ts
  • test/js/web/fetch/headers.test.ts
  • test/js/web/url/url.test.ts
  • test/js/web/websocket/websocket-subprotocol-strict.test.ts
  • test/napi/napi-app/standalone_tests.cpp
  • test/napi/napi.test.ts
  • test/v8/v8.test.ts
💤 Files with no reviewable changes (3)
  • src/sql_jsc/Cargo.toml
  • src/bundler/cache.rs
  • src/bundler/transpiler.rs

Comment thread completions/bun.bash Outdated
Comment thread src/glob/GlobWalker.rs Outdated
Comment thread src/install/lockfile.rs
Comment thread src/js_printer/lib.rs Outdated
);
// `defer { if (generate_source_map) printer.source_map_builder.line_offset_tables.deinit(opts.allocator); }`
// — no longer needed: see `print_ast` above.
printer.was_lazy_export = tree.has_lazy_export;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Wire was_lazy_export through the dev-server lazy-export printer too.

Line 8301 only covers print_common_js; print_dev_server_module still prints ast.has_lazy_export via print_expr without enabling this flag, so data-file __proto__ can still be emitted as a prototype-setting literal on that path.

Proposed fix
-                    self.print_expr(
+                    let previous_was_lazy_export = self.was_lazy_export;
+                    self.was_lazy_export = true;
+                    self.print_expr(
                         Expr {
                             data: *lazy,
                             loc: body_stmts[0].loc,
                         },
                         Level::Comma,
                         ExprFlagSet::empty(),
                     );
+                    self.was_lazy_export = previous_was_lazy_export;

As per coding guidelines, “Fix the whole class in the same PR.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
printer.was_lazy_export = tree.has_lazy_export;
let previous_was_lazy_export = self.was_lazy_export;
self.was_lazy_export = true;
self.print_expr(
Expr {
data: *lazy,
loc: body_stmts[0].loc,
},
Level::Comma,
ExprFlagSet::empty(),
);
self.was_lazy_export = previous_was_lazy_export;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js_printer/lib.rs` at line 8301, The dev-server lazy-export path is still
missing the `was_lazy_export` state, so `print_dev_server_module` can emit
`ast.has_lazy_export` through `print_expr` without the protection that
`print_common_js` already gets. Update the dev-server printer to wire
`tree.has_lazy_export` into the same `was_lazy_export` flag on the relevant
printer state, using the `print_dev_server_module` flow and the shared printer
setup around `printer.was_lazy_export`, so both code paths handle data-file
`__proto__` consistently.

Source: Coding guidelines

Comment on lines +197 to +200
bool secure = name.startsWithIgnoringASCIICase("__Secure-"_s) || name.startsWithIgnoringASCIICase("__Host-"_s);

// Add the new cookie
auto cookie_exception = Cookie::create(name, ""_s, domain, path, 1, false, CookieSameSite::Lax, false, std::numeric_limits<double>::quiet_NaN(), false);
auto cookie_exception = Cookie::create(name, ""_s, domain, path, 1, secure, CookieSameSite::Lax, false, std::numeric_limits<double>::quiet_NaN(), false);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Treat cookie prefix matching as case-sensitive.

__Secure- and __Host- are exact cookie-name prefixes. Using startsWithIgnoringASCIICase here makes ordinary names like __secure-id look prefixed, so the delete tombstone can be emitted with the wrong Secure attribute and miss the original cookie.

Suggested fix
-    bool secure = name.startsWithIgnoringASCIICase("__Secure-"_s) || name.startsWithIgnoringASCIICase("__Host-"_s);
+    bool secure = name.startsWith("__Secure-"_s) || name.startsWith("__Host-"_s);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
bool secure = name.startsWithIgnoringASCIICase("__Secure-"_s) || name.startsWithIgnoringASCIICase("__Host-"_s);
// Add the new cookie
auto cookie_exception = Cookie::create(name, ""_s, domain, path, 1, false, CookieSameSite::Lax, false, std::numeric_limits<double>::quiet_NaN(), false);
auto cookie_exception = Cookie::create(name, ""_s, domain, path, 1, secure, CookieSameSite::Lax, false, std::numeric_limits<double>::quiet_NaN(), false);
bool secure = name.startsWith("__Secure-"_s) || name.startsWith("__Host-"_s);
// Add the new cookie
auto cookie_exception = Cookie::create(name, ""_s, domain, path, 1, secure, CookieSameSite::Lax, false, std::numeric_limits<double>::quiet_NaN(), false);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/bindings/CookieMap.cpp` around lines 197 - 200, The cookie prefix
check in CookieMap::findOrCreateCookie delete-tombstone handling is too
permissive because it uses case-insensitive matching for the "__Secure-" and
"__Host-" prefixes. Update the secure-flag detection in the CookieMap logic to
use exact case-sensitive prefix checks so only cookies with the canonical
prefixes are treated as special, ensuring the tombstone created via
Cookie::create preserves the correct Secure attribute for names like
"__secure-id".

Comment on lines +2955 to +2985
function requestOverHttp1(port, headers) {
const { promise, resolve, reject } = Promise.withResolvers();
const request = https.request(
{
host: "localhost",
port,
path: "/",
agent: false,
ca: TLS_CERT.cert,
headers: { connection: "close", ...headers },
},
async response => {
try {
let body = "";
response.setEncoding("utf8");
response.on("data", chunk => (body += chunk));
await new Promise(done => response.on("end", done));
resolve({
statusCode: response.statusCode,
statusMessage: response.statusMessage,
headers: response.headers,
body,
});
} catch (err) {
reject(err);
}
},
);
request.on("error", reject);
request.end();
return promise;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Reject response-side failures in requestOverHttp1().

This helper only settles on request error or response end. If the fallback path aborts or errors the response mid-body, these tests hang until the outer timeout instead of failing immediately.

As per coding guidelines, await conditions and wire failure events to reject.

Suggested fix
 function requestOverHttp1(port, headers) {
   const { promise, resolve, reject } = Promise.withResolvers();
   const request = https.request(
@@
     async response => {
       try {
         let body = "";
         response.setEncoding("utf8");
+        response.on("error", reject);
+        response.on("aborted", () => reject(new Error("response aborted")));
         response.on("data", chunk => (body += chunk));
         await new Promise(done => response.on("end", done));
         resolve({
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function requestOverHttp1(port, headers) {
const { promise, resolve, reject } = Promise.withResolvers();
const request = https.request(
{
host: "localhost",
port,
path: "/",
agent: false,
ca: TLS_CERT.cert,
headers: { connection: "close", ...headers },
},
async response => {
try {
let body = "";
response.setEncoding("utf8");
response.on("data", chunk => (body += chunk));
await new Promise(done => response.on("end", done));
resolve({
statusCode: response.statusCode,
statusMessage: response.statusMessage,
headers: response.headers,
body,
});
} catch (err) {
reject(err);
}
},
);
request.on("error", reject);
request.end();
return promise;
function requestOverHttp1(port, headers) {
const { promise, resolve, reject } = Promise.withResolvers();
const request = https.request(
{
host: "localhost",
port,
path: "/",
agent: false,
ca: TLS_CERT.cert,
headers: { connection: "close", ...headers },
},
async response => {
try {
let body = "";
response.setEncoding("utf8");
response.on("error", reject);
response.on("aborted", () => reject(new Error("response aborted")));
response.on("data", chunk => (body += chunk));
await new Promise(done => response.on("end", done));
resolve({
statusCode: response.statusCode,
statusMessage: response.statusMessage,
headers: response.headers,
body,
});
} catch (err) {
reject(err);
}
},
);
request.on("error", reject);
request.end();
return promise;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/node/http2/node-http2.test.js` around lines 2955 - 2985,
`requestOverHttp1()` only rejects on request errors or after the response ends,
so response-side aborts/errors can hang the test until timeout. Update the
helper to wire the response’s failure events, alongside the existing request
`"error"` handling, so any mid-body abort or stream error immediately rejects
the promise. Keep the change localized to `requestOverHttp1()` and preserve the
existing resolve path for normal `"end"` completion.

Source: Coding guidelines

Comment on lines +109 to +122
{
const spkacText = spkacValid.toString('utf8').trimEnd();
const padded = Buffer.alloc(Buffer.byteLength(spkacText) + 1);
padded.write(spkacText);
const zeroLengthView = padded.subarray(0, 0);
assert.strictEqual(Certificate.verifySpkac(zeroLengthView), false);
assert.strictEqual(Certificate.exportPublicKey(zeroLengthView), '');
assert.strictEqual(Certificate.exportChallenge(zeroLengthView), '');

for (const input of [Buffer.alloc(0), Buffer.from(' \n\r\t'), '', ' \n\r\t']) {
assert.strictEqual(Certificate.verifySpkac(Buffer.from(input)), false);
assert.strictEqual(Certificate.exportPublicKey(input), '');
assert.strictEqual(Certificate.exportChallenge(input), '');
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Keep Bun-specific coverage out of upstream-synced Node tests.

test/js/node/test/parallel/ is the vendored Node suite. Adding local assertions here breaks upstream diffability and makes future syncs harder; please move this SPKAC regression coverage into a Bun-owned crypto test and leave the upstream port unchanged.

As per coding guidelines, test/js/node/test/parallel/ files are official Node.js tests that we cannot modify. Based on learnings, these files should remain immutable upstream mirrors.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/node/test/parallel/test-crypto-certificate.js` around lines 109 -
122, The SPKAC regression assertions were added to the vendored Node test suite,
which should remain an upstream mirror. Remove the new coverage from
Certificate.verifySpkac, Certificate.exportPublicKey, and
Certificate.exportChallenge in the Node test and relocate the same
zero-length/whitespace input checks into a Bun-owned crypto test file so the
upstream-synced test stays unchanged.

Sources: Coding guidelines, Learnings

Comment on lines +5 to +79
/**
* Count the number of complete RESP commands in a buffer.
* Each command starts with '*' (array) followed by the element count.
* We count top-level '*' markers that begin a new command frame.
*/
function countRespCommands(data: Buffer): number {
const str = data.toString();
let count = 0;
let pos = 0;
while (pos < str.length) {
if (str[pos] === "*") {
count++;
// Skip past this command: find the array length line
const crlfIdx = str.indexOf("\r\n", pos);
if (crlfIdx === -1) break;
const arrayLen = parseInt(str.substring(pos + 1, crlfIdx), 10);
if (isNaN(arrayLen) || arrayLen < 0) break;
// Skip past arrayLen bulk-string elements (each is $<len>\r\n<data>\r\n)
let elemPos = crlfIdx + 2;
for (let i = 0; i < arrayLen; i++) {
if (elemPos >= str.length || str[elemPos] !== "$") break;
const lenEnd = str.indexOf("\r\n", elemPos);
if (lenEnd === -1) break;
const bulkLen = parseInt(str.substring(elemPos + 1, lenEnd), 10);
if (isNaN(bulkLen) || bulkLen < 0) break;
elemPos = lenEnd + 2 + bulkLen + 2; // skip $<len>\r\n<data>\r\n
}
pos = elemPos;
} else {
pos++;
}
}
return count;
}

/**
* Creates a minimal mock Redis server that parses incoming RESP command
* frames. The first command (HELLO handshake) gets +OK; each subsequent
* command receives the next crafted payload (the last one is repeated when
* there are more commands than payloads). Handles the case where multiple
* commands arrive in a single TCP chunk.
*/
function createMockRedisServer(payload: Buffer | Buffer[]): Promise<{ server: net.Server; port: number }> {
const payloads = Array.isArray(payload) ? payload : [payload];
return new Promise((resolve, reject) => {
const server = net.createServer(socket => {
let commandsSeen = 0;

socket.on("data", (data: Buffer) => {
const numCmds = countRespCommands(data);
for (let i = 0; i < numCmds; i++) {
if (commandsSeen === 0) {
// Respond to HELLO handshake with a simple OK
socket.write("+OK\r\n");
} else {
// Each subsequent command gets the next crafted payload
socket.write(payloads[Math.min(commandsSeen - 1, payloads.length - 1)]);
}
commandsSeen++;
}
});

socket.on("error", () => {
// Ignore socket errors from client disconnecting
});
});

server.listen(0, "127.0.0.1", () => {
const addr = server.address() as net.AddressInfo;
resolve({ server, port: addr.port });
});

server.on("error", reject);
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Buffer RESP frames across data events before counting commands.

This helper counts a command as soon as it sees *, even if the frame is truncated, and it only looks at the current chunk. A single HELLO/PING/PSUBSCRIBE split across TCP packets will therefore get a reply before the full command arrives, which makes the new routing tests pass or fail for the wrong reason. Keep a per-socket buffer and only consume complete RESP frames.

As per coding guidelines, "Buffer raw socket/stdout chunks to the protocol's framing before asserting."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/valkey/reliability/resp-nesting-depth.test.ts` around lines 5 - 79,
The RESP parsing in countRespCommands and createMockRedisServer is too eager
because it counts commands from each data chunk without buffering incomplete
frames. Update the socket handling to keep a per-socket buffer, append incoming
data across data events, and only count/consume complete RESP command frames
before replying so split HELLO/PING/PSUBSCRIBE packets are handled correctly.

Source: Coding guidelines

Comment on lines +198 to +208
const { promise: closePromise, resolve: resolveClose } = Promise.withResolvers<CloseEvent>();

const ws = new WebSocket(`ws://localhost:${server.port}`, ["chat", "echo"]);
const onopenMock = mock(() => {});
ws.onopen = onopenMock;
ws.onclose = close => resolveClose(close);

const close = await closePromise;
expect(close.code).toBe(1002);
expect(close.reason).toBe("Missing client protocol");
expect(onopenMock).not.toHaveBeenCalled();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Reject unexpected open/error paths while waiting for the close.

This promise only resolves on close, so an implementation that surfaces the regression as error or an unexpected open can leave the test hanging until the file timeout instead of failing immediately.

Suggested fix
-    const { promise: closePromise, resolve: resolveClose } = Promise.withResolvers<CloseEvent>();
+    const { promise: closePromise, resolve: resolveClose, reject: rejectClose } =
+      Promise.withResolvers<CloseEvent>();

     const ws = new WebSocket(`ws://localhost:${server.port}`, ["chat", "echo"]);
     const onopenMock = mock(() => {});
-    ws.onopen = onopenMock;
-    ws.onclose = close => resolveClose(close);
+    ws.onopen = () => {
+      onopenMock();
+      rejectClose(new Error("unexpected open"));
+    };
+    ws.onerror = () => rejectClose(new Error("unexpected error"));
+    ws.onclose = resolveClose;

As per coding guidelines, "Await the actual observable condition" and "Wire EVERY failure event (error, close, abort, process exit) to reject the awaited promise."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { promise: closePromise, resolve: resolveClose } = Promise.withResolvers<CloseEvent>();
const ws = new WebSocket(`ws://localhost:${server.port}`, ["chat", "echo"]);
const onopenMock = mock(() => {});
ws.onopen = onopenMock;
ws.onclose = close => resolveClose(close);
const close = await closePromise;
expect(close.code).toBe(1002);
expect(close.reason).toBe("Missing client protocol");
expect(onopenMock).not.toHaveBeenCalled();
const { promise: closePromise, resolve: resolveClose, reject: rejectClose } =
Promise.withResolvers<CloseEvent>();
const ws = new WebSocket(`ws://localhost:${server.port}`, ["chat", "echo"]);
const onopenMock = mock(() => {});
ws.onopen = () => {
onopenMock();
rejectClose(new Error("unexpected open"));
};
ws.onerror = () => rejectClose(new Error("unexpected error"));
ws.onclose = resolveClose;
const close = await closePromise;
expect(close.code).toBe(1002);
expect(close.reason).toBe("Missing client protocol");
expect(onopenMock).not.toHaveBeenCalled();
🧰 Tools
🪛 ast-grep (0.44.0)

[warning] 199-199: Avoid insecure (ws://) WebSocket connections; use the encrypted wss:// scheme.
Context: new WebSocket(ws://localhost:${server.port}, ["chat", "echo"])
Note: [CWE-319] Cleartext Transmission of Sensitive Information.

(insecure-websocket-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/web/websocket/websocket-subprotocol-strict.test.ts` around lines 198
- 208, The WebSocket strict subprotocol test only waits for close, so unexpected
open or error paths can hang the test instead of failing fast. Update the await
setup around WebSocket and the Promise.withResolvers usage to reject on any
unexpected ws.onopen or ws.onerror while still resolving on ws.onclose, and keep
the assertions in the same test around onopenMock and close code/reason.

Source: Coding guidelines

Comment thread test/v8/v8.test.ts
Comment on lines +430 to +469
function standaloneAddonFiles(targetName: string, addonCpp: string, runJs: string) {
return {
"package.json": JSON.stringify({
name: `${targetName}-test`,
version: "1.0.0",
devDependencies: { "node-gyp": "~11.2.0" },
}),
"binding.gyp": JSON.stringify({
targets: [
{
target_name: targetName,
sources: ["addon.cpp"],
cflags: ["-Wno-deprecated-declarations"],
cflags_cc: ["-Wno-deprecated-declarations"],
xcode_settings: {
OTHER_CFLAGS: ["-Wno-deprecated-declarations"],
OTHER_CPLUSPLUSFLAGS: ["-Wno-deprecated-declarations"],
},
},
],
}),
"addon.cpp": addonCpp,
"run.js": runJs,
};
}

async function buildStandaloneAddon(cwd: string) {
{
await using install = spawn({
cmd: [bunExe(), "install", "--ignore-scripts"],
cwd,
env: bunEnv,
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
});
const exitCode = await install.exited;
if (exitCode !== 0) {
throw new Error(`install failed: ${exitCode}`);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Don’t fetch node-gyp from the live registry during the test run.

Creating a temp package.json and then running bun install here makes this suite depend on external npm availability, which breaks the repo’s hermetic-test requirement and can cause unrelated CI failures.

As per coding guidelines, "Tests must be hermetic and leave nothing behind. Never contact external network hosts or live registries."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/v8/v8.test.ts` around lines 430 - 469, The standalone addon setup in
standaloneAddonFiles/buildStandaloneAddon is pulling node-gyp via bun install,
which makes the test depend on the live registry. Remove the external install
step and change the test to use only local, prechecked-in dependencies or
fixtures so buildStandaloneAddon can run hermetically without network access.
Keep the fix focused on the standalone addon test helpers and their package.json
generation.

Source: Coding guidelines

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review continued from previous batch...

Comment thread packages/bun-uws/src/HttpParser.h Outdated
Comment on lines +707 to +713
static inline bool isValidHostFieldValue(std::string_view host) {
for (unsigned char c : host) {
if (!isHostFieldValueByte(c)) {
return false;
}
}
return true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Validate Host structure, not just allowed characters.

isValidHostFieldValue() accepts any allowed byte in any position, so values like Host: example.com:abc or Host: a:b:c pass Line 936 even though they are not uri-host [ ":" port ]. This also mirrors into Request::is_valid_host_header, so Bun.serve can still synthesize invalid Request.url values from malformed authorities. Add structural checks for bracketed IPv6, unbracketed colon placement, and numeric ports, then mirror the same logic in Request.rs. As per coding guidelines, "Validate untrusted input BEFORE any processing, allocation, or side effect."

Also applies to: 933-937

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bun-uws/src/HttpParser.h` around lines 707 - 713,
`isValidHostFieldValue()` currently only checks allowed bytes and can accept
malformed host strings like extra colons or non-numeric ports. Update the host
validation logic in `HttpParser::isValidHostFieldValue` to enforce the actual
`uri-host [ ":" port ]` structure, including proper bracketed IPv6 handling,
rejecting invalid unbracketed colon placement, and requiring a numeric port when
present. Mirror the same structural validation in
`Request::is_valid_host_header` so malformed authorities cannot be used to
synthesize invalid `Request.url` values.

Source: Coding guidelines

@alii

alii commented Jun 30, 2026

Copy link
Copy Markdown
Member

Went through the 15 fix commits against the 22 review threads. 12 threads verified and resolved (Host-charset blocker via the validateHostHeaderValue split, request-target C0 rejection, CONNECT framing tests, decompression-cap removal, url null-protos, md degradation, refused-stream conformance, valkey derivation, expansion-guard tests, GC invalid-free tests, route-param pinning, WS behavior note). 10 left open with replies — the headline ones:

  • integrity.rs is the only thread with no response at all — pick-first on same-strength digests still diverges from ssri, untested.
  • Two fixes have residuals that reintroduce the bug class they fix: the http2 fallback writer still desyncs on HEAD + user Transfer-Encoding: chunked (stray 0\r\n\r\n on a keep-alive connection), and cpSync of a junction with a UNC target now creates a silently dangling link (the utf8 round-trip strips \\?\UNC\ into a relative path).
  • The T6 sweep missed one live coercion-while-borrowed site: emit_error_to_all_streams runs user valueOf per stream holding a raw &mut Stream.
  • The new cpSync junction test can't catch its own regression on CI — buildkite-agent runs as LocalSystem, which holds the symlink privilege.

On the red CI: two of the three failures are this branch's. The glob rewrite recreated the deny-level large_types_passed_by_value lint (check_followed_link takes the 144-byte Stat by value, GlobWalker.rs:2009), and the s3 or_fun_call errors at credentials.rs:1119/:1132 predate the review but are on this branch's clippy run. The Format job's autofix can't push because the branch is behind main on docs files — needs a main merge. The Buildkite failure looks like the known musl flake.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

Re the integrity thread (review item 2) — replying here since it's the one thread without a direct response:

The maintainer's call is to keep strongest-algorithm-first, single-digest verification for now. The blocker isn't the comparison loop: the parsed Integrity is a fixed-size, on-disk type (it lives inside the binary manifest-cache entry and the lockfile's package metadata), so holding every digest of the strongest algorithm means a bigger variable-size type plus a manifest-cache format-version bump. That's a contained but not surgical change, and on inspection of the registry data we could find no package whose integrity carries two digests of the same algorithm (npm publishes exactly one per algorithm), so the divergence is theoretical until someone hand-edits a lockfile. It's documented in the PR description as a deviation from ssri, and I'm happy to do the multi-digest type + cache bump as its own follow-up PR if you think it's worth carrying.

@Jarred-Sumner
Jarred-Sumner force-pushed the claude/security-round-11 branch from 433d2dd to 45dde10 Compare June 30, 2026 02:36
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

Thanks for the second pass — everything below is on the branch. The branch is now rebased onto current main (your CI/format point), and the lint job's exact invocation (cargo clippy --workspace --no-deps) is clean, as are cargo fmt --check, clang-format against origin/main, prettier, and bun run rust:check-all (10/10 targets).

1. Integrity (multiple digests of one algorithm) — answered separately in that thread with the maintainer's decision; nothing further here.

2. Glob symlink cycles / clippycheck_followed_link and is_followed_link_cycle now take &Stat (GlobWalker.rs:2009, body FollowedLink { stat: *target }), the shape you suggested; cargo clippy -p bun_glob --no-deps and the workspace run are both clean. The truncate-to-live-ancestor bookkeeping, the accessor symlink gate, and the --filter self-referential-symlink test from the first round are unchanged.

3. h2 emit_error_to_all_streams + trailers — the read is hoisted above the loop as you asked (h2_frame_parser.rs, emit_error_to_all_streams), but we have to push back on the valueOf half: JSValue::to_u32 goes through JSC__JSValue__toInt64 (bindings.cpp:4231), which never calls ToNumber — on a non-number it trips a debug ASSERT and reinterprets the cell bits in release, and a logging valueOf fed through that exact loop printed zero calls before the abort. So no user JS runs at that line; the real defect was an unvalidated value reaching a number-only accessor, and the hoist is paired with an is_number() check throwing the same error goaway uses (test: h2-conformance.test.ts "session teardown rejects a non-numeric error code instead of reading it per stream", which aborts on the unguarded debug build and reads a garbage rst code on 1.4.0). On the softer points: #sentTrailers is now set before the native call, with one refinement we checked against node v26.3.0 — node sets kSentTrailers only after buildNgHeaderString succeeds, so a thrown header error leaves it unset and a corrected retry works; we therefore mark before the call (a re-entrant sendTrailers from a value coercion now hits ERR_HTTP2_TRAILERS_ALREADY_SENT) and clear the mark again if the native call throws, which can only happen before any frame bytes are written (test: "a sendTrailers validation error does not mark the trailers as sent"). The GuardedStream/enter_stream_dispatch comments claim deferred-free/liveness only ("rewrite_read defers stream frees ... cannot free the stream out from under the borrow"), not exclusivity; we did not add the already-guarded debug_assert because it needs a per-parser set of live guarded pointers — happy to do that as a follow-up if you still want it now that the JS-side ordering closes the named overlap.

4. cpSync Windows reparse targets — you're right on the UNC residual: from_w_path only trims \\?\, so the target came out relative and CreateSymbolicLinkW happily made a dangling link. The reparse arm in node_fs.rs now rewrites \\?\UNC\server\share\… in place to \\server\share\… exactly the way libuv's fs__realpath_handle does (skip 6 units, overwrite the C with \), and UNC targets go straight to symlink_w with the wide target — the junction fallback is skipped for them since libuv's fs__create_junction only accepts drive-letter targets. On the second residual we took a different route than the restricted token: buildkite runs the agent as LocalSystem, so instead the existing junction test now asserts link-target equivalence (readlinkSync absolute + realpathSync(target) === realpathSync(original)), and a new UNC test (\\localhost\<drive>$\… via the loopback admin share, same dependency as the existing windows-path suite) asserts the stored target is absolute, starts with \\, and reads through — all of which fail on the pre-fix build under any token, which is the privilege-independent detection we were after.

5. __proto__ keys from data loaders — finished the move as you asked: the print-time was_lazy_export rewrite and the print_common_js assignment this PR added are both deleted, and the IsComputed flag is set at construction time. We set it where the parsers build the properties (json/json5/yaml plus the E::Object builder helpers TOML uses, via one shared E::own_key_property_flags) rather than in to_lazy_export_ast, which is also esbuild's layer (json_parser.go:147-152) and additionally covers nested objects, .jsonc/.json5, and non-lazy-export consumers of the same parsers; the macro path now uses the same helper. While re-reviewing the diff we found one more lazy-export producer the printer hunk had been covering — E::Object::put, used by the CSS-modules export object — so it sets the flag too. New tests alongside the existing ones: jsonc, json5 identifier key, nested object, TOML inline table, YAML flow mapping, and a .__proto__ { } CSS-module class; all 9 fail on the released build.

6. Record conversion fast path — you're right that the getter test never entered the fast path (an accessor own property fails canPerformFastPropertyEnumeration up front). Added the three fast-path-eligible cases against the node/undici spec path as the oracle (a plain object in undici takes a non-spec shortcut; wrapping it in a Proxy forces the per-key [[GetOwnProperty]] path): a toString that definePropertys a later existing key with a getter, the same with a throwing getter (the RETURN_IF_EXCEPTION after slot.getValue), and a setPrototypeOf-mid-iteration case asserting own-property semantics. With the structure-transition fallback neutered (structureIsUnchanged = true), the first two crash and the third is the only remaining cover for that branch, so the new tests are load-bearing for exactly the code you called out.

7. bun pm pack .hg — confirmed against npm-packlist 10.9.3 and an empirical npm pack --dry-run: the strict set is .git/node_modules/.npmrc/lockfiles only, so .hg is flipped to overridable; the non-overridable entries are now exactly .git and .npmrc (lockfiles stay in the root table) with a comment citing npm-packlist's strict rules as the source of truth. Both files fixtures now carry .hg/.svn/CVS rows; without the flip the .hg rows fail, and both tests fail on the released build.

8. wrapAnsi no-space seam — your hand trace reproduces exactly (the PR branch kept one over-wide row where released Bun wraps). We did deviate from both suggested shapes, with measurements: "dirty when the row is non-empty" is O(n²) on the existing 100k zero-width-escape perf test in the same file (it failed), and "pass the row's real last codepoint" is still wrong when the width-0 row ends in an escape sequence whose ASCII terminator masks a Prepend (stringWidth("؀" + ESC + "[31m" + 👍🏿) === stringWidth("؀👍🏿")). So in the no-space branch the additive width cache is taken only when both the row's last code unit and the word's first codepoint are ASCII (an ASCII/ASCII seam can never form a cross-boundary cluster); any other seam recomputes, matching the always-recompute reference. 24 new matrix rows cover your ؀ case at both column boundaries and the escape-masked variant; expectations were generated from released Bun, and the 16 affected rows fail on the pre-fix branch build.

9. allowHTTP1 fallback writer — all three: the terminating chunk is gated chunked && !noBody (real node, both via plain node:http and via createSecureServer({allowHTTP1:true}) + ALPN http/1.1, writes no 0\r\n\r\n after a keep-alive HEAD with a user Transfer-Encoding: chunked; without the gate the new pipelining test fails with the stray 0\r\n\r\n prefixing the next response); the auto Connection/Keep-Alive block is gated !hasConnection && !closeDelimited (node writes no Connection header when the user removed it on a close-delimited response — that combination can only arise from _removedConnection, since renderNativeHeaders otherwise always pushes an explicit pair); and the existing close-delimited test now asserts connection: close. Tests: the amended close-delimited test plus "no terminating chunk after a keep-alive HEAD with a user-set Transfer-Encoding: chunked" and "omits the Connection header on a close-delimited response when the user removed it".

10. Host parity sweep — added the [0x7f, 0xff] band, sent as Buffer.from(payload, "latin1") so the raw single bytes go on the wire (the helper now always writes latin1; byte-identical for the existing ASCII bands). Both validators are ASCII-only by construction, so the band asserts both reject every high byte; on released Bun the new band reports 258 diffs with the two columns flipping independently, so a single-validator relaxation fails on its own column.

Comment on lines 696 to +711
seen.set(h, candidate);
}
expect(pair).not.toBeNull();
const [routeSegment, attackSegment] = pair!;
expect(attackSegment).not.toBe(routeSegment);
expect(attackSegment.length).toBe(routeSegment.length);
const [routeSegment, collidingSegment] = pair!;
expect(collidingSegment).not.toBe(routeSegment);
expect(collidingSegment.length).toBe(routeSegment.length);

const { dir } = make([`${routeSegment}/[id].tsx`]);
const router = new Bun.FileSystemRouter({
dir,
style: "nextjs",
});

// The genuine segment matches its dynamic route.
expect(router.match(`/${routeSegment}/42`)?.name).toBe(`/${routeSegment}/[id]`);
// A different segment that only collides on (length, 32-bit hash) must not.
expect(router.match(`/${attackSegment}/42`)).toBeNull();
});
expect(router.match(`/${collidingSegment}/42`)).toBeNull();
}, 60_000);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 This hunk is a cosmetic-only refactor (rename attackSegmentcollidingSegment, extract low32) that also (a) deletes the 5-line why-comment recording that Bun.hash.wyhash(s, 0) is the same hash the router stores for static segments — the one fact here not derivable from the test body (root CLAUDE.md:239: "Don't delete existing why-comments in cleanup passes"), and (b) adds a 60_000 per-test timeout where there was none (test/CLAUDE.md:120). Suggest restoring a one-line form of the wyhash-is-the-router's-hash note and dropping the timeout (or shrinking the 600k search if it's actually slow on debug).

Extended reasoning...

What changed. The only functional reason this PR touches filesystem_router.test.ts is the new .params decodes percent escapes test added above this hunk. The changes to this test are pure cosmetics — attackSegment is renamed to collidingSegment, and the inline Number(BigInt.asUintN(32, BigInt(Bun.hash.wyhash(...)))) expression is hoisted into a low32 helper. Riding alongside that cleanup are two unrelated edits: the existing 5-line block comment plus two inline assertion comments are deleted, and the closing }); becomes }, 60_000);.

The deleted comment. Root CLAUDE.md:239 reads "Comments carry only durable non-obvious content … Don't delete existing why-comments in cleanup passes." The removed block was:

// Route segment matching must compare bytes, not just (length, truncated
// 32-bit wyhash). Bun.hash.wyhash(s, 0) is the same hash the router stores
// for static route segments, so a birthday search over a few hundred
// thousand equal-length candidates finds a colliding pair with overwhelming
// probability (expected after ~80k candidates).

The non-obvious load-bearing clause is "Bun.hash.wyhash(s, 0) is the same hash the router stores for static route segments". That is a fact about src/url/lib.rs, not about this test, and it is the only thing that makes Bun.hash.wyhash the correct function to collide here — colliding any other hash would prove nothing. The test name says what (a (length, 32-bit hash) collision must not match); the comment said why this construction works. Without it, a future change to the router's segment-hash function silently turns this test into a no-op (the search would still find a wyhash collision, but the router would no longer be comparing that hash). The two deleted inline comments ("genuine segment matches" / "colliding segment must not") are admittedly narration and fine to drop.

Step-by-step. (1) Diff at lines 684–711 shows no behavioral change to this test — same 600k loop, same fixture, same three assertions. (2) The - lines remove exactly the 5-line block + 2 inline comments. (3) The + lines add only the rename, the low32 helper, and the , 60_000 timeout argument. (4) CLAUDE.md:238 separately says "don't ride file-wide standardization on a focused bugfix", which is what this hunk is.

The added timeout. test/CLAUDE.md:120 says "CRITICAL: Do not set a timeout on tests. Bun already has timeouts", and root CLAUDE.md:191 says "Don't raise per-test timeouts to make a slow test pass; shrink the workload." The workload here did not change (600k candidates before and after), so if the test was within budget before this PR there is no reason to add a timeout now, and if it was slow on debug builds the prescribed remedy is to lower the candidate count (the comment itself noted ~80k expected). One mitigating point the refuting verifier raised: the file already carries a pre-existing 60_000 at line 539 for another heavy-iteration test, so this addition is locally consistent and the no-timeout convention is evidently not strictly enforced in this file. That makes the timeout the weaker half of this finding.

Addressing the refutation. The dissenting verifier argued the deleted block itself violated the 3-line rule (CLAUDE.md:268) and "Regression tests get exactly one comment: the issue URL" (CLAUDE.md:239), so removing it was net-correct. But the "don't delete existing why-comments in cleanup passes" clause sits in the same sentence and specifically governs what to do with pre-existing explanatory comments during a cleanup — the 3-line/issue-URL norms describe what to write for new comments. And the refutation's "Bun.hash.wyhash being visible in the code" mitigation does not actually carry the lost fact: seeing Bun.hash.wyhash on line 685 tells the reader which hash the test computes, not that the router internally uses the same one with seed 0 for segment matching. That coupling to src/ is exactly the kind of "durable non-obvious content" the rule names.

Impact. None at runtime — test-only style/maintainability. The risk is forward-looking: the test's correctness depends on an undocumented coupling to the router's choice of hash function, and the per-test timeout sets a small precedent the convention asks to avoid. Hence nit.

Fix. Restore the why in compressed form (one or two lines is enough — e.g. // Bun.hash.wyhash(s, 0) is the router's static-segment hash; a birthday search over equal-length candidates finds a 32-bit collision in ~80k tries.), and either drop the 60_000 or, if debug-build timing is the concern, lower the 600k search bound instead. The rename and low32 extraction are fine to keep.

Comment thread completions/bun.bash Outdated
Comment thread src/install/lockfile/Package.rs Outdated
}
}
}
if matches!(field, PackageField::Scripts) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete this

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleted in b42fbd3. That block was the only mechanism behind the new "reads a registry package's lifecycle scripts from its package.json, not the binary lockfile" test, so that test is removed with it (a stored lockfile's script strings are used as-is again, as on main today); the neighboring scripts-flag byte validation you didn't flag is unchanged.

)
.expect("unreachable"),
skip_verify: false,
in_trusted_dependencies: this.lockfile.in_trusted_dependencies(name),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleted in b42fbd3 — the extractor never reads that field for tarball-tagged tasks (it always reads the package.json for those), so the lookup at this call site was pointless; it now passes a literal false.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/glob/path-length.test.ts`:
- Around line 87-137: Both glob path-length tests read scanStderr but never
validate it, so add an explicit stderr assertion in the overflow and Linux-only
matched-file cases. Update the test bodies around the Bun.spawn/scanProc checks
to verify stderr is empty or otherwise matches the expected diagnostics,
alongside the existing stdout and exitCode assertions, using the same scanStderr
variable in both tests.

In `@test/js/bun/util/filesystem_router.test.ts`:
- Around line 800-803: The new bunEnv subprocess tests collect stderr but never
verify it, unlike the file’s existing bunExe() convention. Update the relevant
test blocks around the referenced subprocess assertions to explicitly check that
stderr is empty with bunEnv, and apply the same fix in the matching second test
block so both use the established stderr expectation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5479f5f0-3ae2-4e3e-83fe-e501ab9f96c5

📥 Commits

Reviewing files that changed from the base of the PR and between c827a85 and d7ea45d.

📒 Files selected for processing (11)
  • src/bundler/ParseTask.rs
  • src/glob/GlobWalker.rs
  • src/paths/resolve_path.rs
  • src/resolver/lib.rs
  • src/runtime/node/node_fs.rs
  • src/runtime/node/types.rs
  • src/runtime/webcore/Request.rs
  • test/js/bun/glob/path-length.test.ts
  • test/js/bun/http/serve.test.ts
  • test/js/bun/util/filesystem_router.test.ts
  • test/js/node/fs/fs.test.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/glob/path-length.test.ts`:
- Around line 87-137: Both glob path-length tests read scanStderr but never
validate it, so add an explicit stderr assertion in the overflow and Linux-only
matched-file cases. Update the test bodies around the Bun.spawn/scanProc checks
to verify stderr is empty or otherwise matches the expected diagnostics,
alongside the existing stdout and exitCode assertions, using the same scanStderr
variable in both tests.

In `@test/js/bun/util/filesystem_router.test.ts`:
- Around line 800-803: The new bunEnv subprocess tests collect stderr but never
verify it, unlike the file’s existing bunExe() convention. Update the relevant
test blocks around the referenced subprocess assertions to explicitly check that
stderr is empty with bunEnv, and apply the same fix in the matching second test
block so both use the established stderr expectation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5479f5f0-3ae2-4e3e-83fe-e501ab9f96c5

📥 Commits

Reviewing files that changed from the base of the PR and between c827a85 and d7ea45d.

📒 Files selected for processing (11)
  • src/bundler/ParseTask.rs
  • src/glob/GlobWalker.rs
  • src/paths/resolve_path.rs
  • src/resolver/lib.rs
  • src/runtime/node/node_fs.rs
  • src/runtime/node/types.rs
  • src/runtime/webcore/Request.rs
  • test/js/bun/glob/path-length.test.ts
  • test/js/bun/http/serve.test.ts
  • test/js/bun/util/filesystem_router.test.ts
  • test/js/node/fs/fs.test.ts
🛑 Comments failed to post (2)
test/js/bun/glob/path-length.test.ts (1)

87-137: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Assert stderr too, not just stdout/exitCode.

Both new tests collect scanStderr but never assert on it. Since these tests specifically probe buffer-overflow-prone path-joining code, a native diagnostic/ASAN note on stderr (without necessarily flipping the exit code) would be valuable signal that's currently discarded.

💡 Proposed fix (apply to both tests)
-    expect(scanStdout.trim()).toBe("ERR:ENAMETOOLONG");
-    expect(scanCode).toBe(0);
+    expect({ stdout: scanStdout.trim(), stderr: scanStderr, exitCode: scanCode }).toEqual({
+      stdout: "ERR:ENAMETOOLONG",
+      stderr: "",
+      exitCode: 0,
+    });

Apply the analogous change to the OK: assertion in the Linux-only test (113-137).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/bun/glob/path-length.test.ts` around lines 87 - 137, Both glob
path-length tests read scanStderr but never validate it, so add an explicit
stderr assertion in the overflow and Linux-only matched-file cases. Update the
test bodies around the Bun.spawn/scanProc checks to verify stderr is empty or
otherwise matches the expected diagnostics, alongside the existing stdout and
exitCode assertions, using the same scanStderr variable in both tests.
test/js/bun/util/filesystem_router.test.ts (1)

800-803: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Assert stderr for these bunEnv subprocess tests.

stderr is collected but never checked in either new test (also applies to 833-837). Based on learnings, this file's own established convention is to assert expect(stderr).toBe("") for bunExe() subprocesses spawned with bunEnv, since bunEnv reliably suppresses benign ASAN/debug noise.

💡 Proposed fix
-  expect(normalizeBunSnapshot(stdout, String(dir))).toBe("matches 50 builds-ok true");
-  expect({ exitCode, signalCode: proc.signalCode }).toEqual({ exitCode: 0, signalCode: null });
+  expect(normalizeBunSnapshot(stdout, String(dir))).toBe("matches 50 builds-ok true");
+  expect({ stderr, exitCode, signalCode: proc.signalCode }).toEqual({ stderr: "", exitCode: 0, signalCode: null });

Apply the analogous change at 833-837.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

  const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
  expect(normalizeBunSnapshot(stdout, String(dir))).toBe("matches 50 builds-ok true");
  expect({ stderr, exitCode, signalCode: proc.signalCode }).toEqual({ stderr: "", exitCode: 0, signalCode: null });
}, 60_000);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/bun/util/filesystem_router.test.ts` around lines 800 - 803, The new
bunEnv subprocess tests collect stderr but never verify it, unlike the file’s
existing bunExe() convention. Update the relevant test blocks around the
referenced subprocess assertions to explicitly check that stderr is empty with
bunEnv, and apply the same fix in the matching second test block so both use the
established stderr expectation.

Source: Learnings

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

Fixed in the latest push. wordStartsNewCluster and wordSeamIsAscii now skip leading escape sequences (via the existing ANSI::consumeANSI scanner — no second parser) so the seam is classified by the word's first visible codepoint; plain seams don't pay anything extra. Both of your shapes reproduced exactly as you predicted before the fix (the escape-hidden 👍🏿 row reached width 9 in 7 columns; the \x1b[31m⃣bb keycap fused across the space seam) and are in the matrix now along with ANSI-prefixed twins of existing rows — 24 rows added, 12 of which fail if the classifier change is reverted.

On the row side: rowTail is textually blind to a trailing escape, but that branch is only reachable when the row's exact width is 0, and a width-0 visible prefix followed by an ASCII-led word is always width-additive — brute-forced over all 1,081 zero-width codepoints × all ASCII followers with no counterexample, and the non-ASCII-word half of that seam is already forced down the recompute path. I first wrote the row-tail rescan anyway and it turned the 100k-escape long-input test into a timeout, so I kept the proof and dropped the dead mechanism.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/jsc/bindings/wrapAnsi.cpp (1)

150-181: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use the shared ANSI parser in trimLeadingSpaces().

This scanner only recognizes ESC ... m and BEL-terminated OSC. A leading valid sequence like "\x1b[2K foo" or an ST/C1 form leaves m_trimInEscape stuck, so the following spaces never trim and the cached row width stays wrong. Reusing ANSI::isEscapeCharacter() plus ANSI::consumeANSI() (or the existing terminator helper) here would keep trim behavior aligned with the rest of the file.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/bindings/wrapAnsi.cpp` around lines 150 - 181, The trimLeadingSpaces
scanner in wrapAnsi.cpp is using a custom escape-state loop that only handles a
narrow set of ANSI sequences, causing m_trimInEscape and the cached width to get
out of sync for valid leading escapes like CSI or ST/C1 forms. Update the trim
logic to use the shared ANSI parser helpers already used elsewhere in this file,
specifically ANSI::isEscapeCharacter() and ANSI::consumeANSI() (or the existing
terminator helper), so trimLeadingSpaces() recognizes the same escape boundaries
and correctly resumes trimming spaces after any valid ANSI sequence.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/jsc/bindings/wrapAnsi.cpp`:
- Around line 62-70: skipLeadingAnsi() only strips the first ANSI escape, so a
word that begins with multiple adjacent escape sequences can still leave an ESC
at the seam and cause wordStartsNewCluster() and wordSeamIsAscii() to take the
wrong fast path. Update skipLeadingAnsi() in wrapAnsi.cpp to keep consuming
leading ANSI sequences until the first non-escape code unit is reached, and make
sure the seam classification logic uses that fully advanced pointer before
deciding whether to treat the word as ASCII.

---

Outside diff comments:
In `@src/jsc/bindings/wrapAnsi.cpp`:
- Around line 150-181: The trimLeadingSpaces scanner in wrapAnsi.cpp is using a
custom escape-state loop that only handles a narrow set of ANSI sequences,
causing m_trimInEscape and the cached width to get out of sync for valid leading
escapes like CSI or ST/C1 forms. Update the trim logic to use the shared ANSI
parser helpers already used elsewhere in this file, specifically
ANSI::isEscapeCharacter() and ANSI::consumeANSI() (or the existing terminator
helper), so trimLeadingSpaces() recognizes the same escape boundaries and
correctly resumes trimming spaces after any valid ANSI sequence.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f57fba34-418a-453b-a83f-8631c990e9c5

📥 Commits

Reviewing files that changed from the base of the PR and between d7ea45d and 2d73a89.

📒 Files selected for processing (2)
  • src/jsc/bindings/wrapAnsi.cpp
  • test/js/bun/util/wrapAnsi.test.ts

Comment on lines +62 to +70
// A word may begin with ANSI escape sequences whose code units are all ASCII
// (ESC, '[', digits, 'm'), hiding the codepoint that actually lands on the seam.
// Skip them before classifying; a word not starting with ESC never enters the scan.
template<typename Char>
static inline const Char* skipLeadingAnsi(const Char* start, const Char* end)
{
if (start < end && ANSI::isEscapeCharacter(*start))
return ANSI::consumeANSI(start, end);
return start;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Skip all leading ANSI sequences before classifying the seam.

skipLeadingAnsi() only consumes one escape. Inputs like "\x1b[31m\x1b[1m\u20E3bb" still leave the second ESC at the seam, so wordStartsNewCluster()/wordSeamIsAscii() take the additive fast path and keep the stale cached width.

Proposed fix
 template<typename Char>
 static inline const Char* skipLeadingAnsi(const Char* start, const Char* end)
 {
-    if (start < end && ANSI::isEscapeCharacter(*start))
-        return ANSI::consumeANSI(start, end);
+    while (start < end && ANSI::isEscapeCharacter(*start)) {
+        const Char* next = ANSI::consumeANSI(start, end);
+        if (next == start)
+            break;
+        start = next;
+    }
     return start;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// A word may begin with ANSI escape sequences whose code units are all ASCII
// (ESC, '[', digits, 'm'), hiding the codepoint that actually lands on the seam.
// Skip them before classifying; a word not starting with ESC never enters the scan.
template<typename Char>
static inline const Char* skipLeadingAnsi(const Char* start, const Char* end)
{
if (start < end && ANSI::isEscapeCharacter(*start))
return ANSI::consumeANSI(start, end);
return start;
template<typename Char>
static inline const Char* skipLeadingAnsi(const Char* start, const Char* end)
{
while (start < end && ANSI::isEscapeCharacter(*start)) {
const Char* next = ANSI::consumeANSI(start, end);
if (next == start)
break;
start = next;
}
return start;
}
🧰 Tools
🪛 Cppcheck (2.21.0)

[error] 66-66: failed to evaluate #if condition, undefined function-like macro invocation

(syntaxError)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/bindings/wrapAnsi.cpp` around lines 62 - 70, skipLeadingAnsi() only
strips the first ANSI escape, so a word that begins with multiple adjacent
escape sequences can still leave an ESC at the seam and cause
wordStartsNewCluster() and wordSeamIsAscii() to take the wrong fast path. Update
skipLeadingAnsi() in wrapAnsi.cpp to keep consuming leading ANSI sequences until
the first non-escape code unit is reached, and make sure the seam classification
logic uses that fully advanced pointer before deciding whether to treat the word
as ASCII.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

Checked, but this one isn't reachable: ANSI::consumeANSI already consumes chained sequences — its contract (ANSIHelpers.h, above the definition) is "If the end of one sequence is followed by the start of another sequence, this function will consume that one as well, and so on", which is why skipLeadingAnsi doesn't loop. Running the quoted shape ("aa \x1b[31m\x1b[1m\u20E3bb\x1b[39m cc", columns 9) produces the same, correct output as the single-escape row that's already in the matrix.

Comment on lines 169 to +172
// Note: this `else if` condition is identical to the `if`
// above and is therefore unreachable; likely a bug
// (intended: `!new && old` → unsubscribe).
let _ = ws.unsubscribe(&[field as u8]);
let _ = ws.unsubscribe(&field.uws_topic());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 Pre-existing: this PR updates line 172 to the new uws_topic() encoding, but the surrounding else if at line 168 is byte-for-byte identical to the if at line 126 (new_bits.contains(bit) && !self.subscriptions.contains(bit)), so the line being edited is dead code — the in-tree comment at lines 169–171 already says exactly this. Since the PR is in this branch for the topic-encoding sweep anyway, swapping the condition to !new_bits.contains(bit) && self.subscriptions.contains(bit) (what the comment names) would make the edited ws.unsubscribe(...) actually fire when an HMR client drops a topic.

Extended reasoning...

What the bug is. In HmrSocket's SetSubscriptions handler, the if at line 126 and the else if at line 168 carry the identical condition — both are new_bits.contains(bit) && !self.subscriptions.contains(bit). An else if whose condition matches its preceding if is unreachable, so ws.unsubscribe(&field.uws_topic()) on line 172 never runs. The pre-existing inline comment at lines 169–171 already documents this ("this else if condition is identical to the if above and is therefore unreachable; likely a bug (intended: !new && old → unsubscribe)"). This PR's change to this file rewrites the call's argument from &[field as u8] to &field.uws_topic() (the 0xFF-prefixed encoding introduced in dev_server/mod.rs) without touching the dead condition, so it is updating a line that still cannot execute.

Step-by-step proof.

  1. The client sends a SetSubscriptions message; new_bits is built from the payload (lines 119–123).
  2. For each field in HmrTopic::ALL, line 126 evaluates new_bits.contains(bit) && !self.subscriptions.contains(bit).
  3. If that is true, control enters the if body (subscribe) and the else if is never tested.
  4. If that is false, the else if at line 168 evaluates the same expression, which is still false — the body is skipped.
  5. Therefore line 172 is unreachable for every possible (new_bits, self.subscriptions, bit) triple.
  6. The bookkeeping at line 175, self.on_unsubscribe(!new_bits & self.subscriptions), does run — but on_unsubscribe() (lines 323–344) only adjusts the DevServer's debug-visualizer counters and never calls ws.unsubscribe(). Line 172 is the only place a per-topic uWS unsubscription would happen.

Why nothing else prevents it. on_unsubscribe() handles DevServer-side state (the emit_*_visualizer_events counters and the memory-visualizer timer), not the uWS pub/sub topic membership; and self.subscriptions = new_bits on line 176 only updates the local mask. So when a connected HMR client narrows its subscription set (e.g. drops MemoryVisualizer), the socket stays subscribed at the uWS layer to the topic it asked to drop, and DevServer::publish() continues delivering frames for it.

Why this PR interacts with it. The PR's purpose in this file is the HmrTopic::uws_topic() change (giving HMR topics a 0xFF-prefixed name no JS-side subscribe() string can collide with). The diff edits both the subscribe call (line 127) and the unsubscribe call (line 172) for that encoding sweep. The unsubscribe edit sits one line below a comment that names it as a bug and prescribes the fix; root CLAUDE.md:213 ("Fix the whole class in the same PR") applies — though it's reasonable to defer since fixing the condition is a behavior change (clients would actually start getting uWS-level unsubscriptions) orthogonal to a hardening pass.

Impact. Minor: an HMR socket that drops a topic from its subscription set keeps receiving published frames for that topic until disconnect. The local self.subscriptions mask is updated, so re-subscribing later still hits the subscribe arm correctly. This is dev-server-only and the leaked frames are small.

Fix. Swap the else if condition to the inverse named by the comment:

} else if !new_bits.contains(bit) && self.subscriptions.contains(bit) {
    let _ = ws.unsubscribe(&field.uws_topic());
}

and drop the now-redundant comment. The PR's mechanical update of the dead line is the correct thing to do for consistency regardless (if the condition is fixed later, the encoding should already match), so no objection to the PR's change as-is — this is a flag on the documented pre-existing bug it touches.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

Agreed it's dead — the else if repeats the if's condition, so the unsubscribe arm can't fire, exactly as the in-tree comment above it says. This PR only re-encodes the constant on the neighboring line, and flipping that condition is a behavior change to the dev server's HMR subscriptions, so it shouldn't ride along here; it's worth its own small change with a test that drops a topic.

robobun added a commit that referenced this pull request Jul 18, 2026
robobun added a commit that referenced this pull request Jul 18, 2026
dylan-conway pushed a commit that referenced this pull request Jul 20, 2026
…always fires (#33998)

Fixes `test/js/bun/jsc/bun-jsc.test.ts` going red on the debian 13 x64
lane in [build
72010](https://buildkite.com/bun/bun/builds/72010#019f546c-0875-422c-8f22-fa88eac52a1b):

```
215 |     expect(result2.stackTraces.traces.length).toBeGreaterThan(0);
error: expect(received).toBeGreaterThan(expected)
Expected: > 0
Received: 0
✗ bun:jsc > profile can be called multiple times [4.71ms]
```

### Cause

The test profiles `fib(26)` three times at a 50µs sample interval and
asserts each run collected at least one trace. #33072 shrank the
workload from `fib(30)` to `fib(26)` so three runs fit inside the
per-test timeout on slow debug builds. On fast release hardware a
JIT-compiled `fib(26)` can finish inside one 50µs interval (the whole
3-call test completed in 4.71ms there), so the sampler thread simply
never fires during the second call.

This is the same race #28873 addressed by lowering the sample interval;
lowering the instruction count in #33072 reopened it.

### Fix

Loop `fib(18)` for a fixed 10ms of wall-clock per `profile()` call
instead of relying on a single `fib(n)` being slow enough. 10ms is ~200
sample intervals, so the sampler thread is guaranteed time to fire
regardless of JIT tier or how slowly it wakes after `pause()`/`start()`.
On debug builds the loop still exits after ~12ms of JS work (one
`fib(18)` of overshoot), so the test is no slower there than before.

The test still catches the original regression from #25939 (using
`shutdown()` instead of `pause()`): a shut-down sampler returns zero
traces no matter how long the workload runs.

### Verification

- `USE_SYSTEM_BUN=1 bun test test/js/bun/jsc/bun-jsc.test.ts -t 'profile
can be called multiple times'`: 30/30 pass, ~80 traces per call
- `bun bd test test/js/bun/jsc/bun-jsc.test.ts`: 36 pass / 0 fail,
profile test ~1.6s (dominated by the profiler's JSON reporting, same as
before)

This is a timing flake that does not reproduce on the dev container
(`fib(26)` takes ~800µs there after JIT warmup, well above 50µs), so
there is no deterministic fail-before to show; the CI failure above is
the reproduction.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 2 · Platform-specific test-only change;
deferring to CI.

<!-- robobun:evidence:end -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants