buffer: decode hex from the low byte of each UTF-16 code unit like node - #39460
Conversation
The hex decoder for two-byte strings stopped at any code unit above
0xFF, in both the scalar loop and the Highway kernel. Node narrows each
code unit to its low byte before classifying it, so U+FF41 decodes as
'A' and Buffer.from("f\uff41", "hex") is <fa>; bun returned an empty
buffer. The base64 decoders already apply this narrowing for two-byte
strings.
Both decoder paths now classify the low byte of each code unit. A unit
whose low byte is not a hex digit still stops the decode, as before.
One-byte strings are unaffected, as are the byte-slice callers of the
shared decoder.
|
Warning Review limit reached
Next review available in: 11 minutes Limit details: You’ve used all 5 included reviews currently available under your plan. Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (5)
Comment |
|
Status: merged as 7a8ce75. Reproduced on the released bun and on main at 079cb0a against node v26.3.0: Buffer.from("f\uff41", "hex") // node: <Buffer fa> bun: <Buffer >
Buffer.from("fa\uff41\uff41", "hex") // node: <Buffer fa aa> bun: <Buffer fa>Self-review after merge found nothing wrong with the fix itself; two notes came out of it:
|
There was a problem hiding this comment.
LGTM — focused Node compat fix applied consistently across all three hex-decode paths.
What was reviewed:
- Traced the three implementation sites (Rust scalar loop, Highway vector loop, kernel scalar tail) — all narrow u16 to low byte before classifying; the u8 path is byte-identical (
if constexprguard, identityhex_byte()). - Checked callers of
decode_hex_to_bytes*: onlyencoding.rsreaches the u16 instantiation; postgres/csrf/IPC/DevServer pass&[u8]and are unaffected. The removedInto<u32>bound onHexCharhas no other consumers. - The replaced test was pinning the old (Node-divergent) behavior; its replacement covers both the new "decodes" half and the unchanged "stops" half at positions spanning vector blocks, the 128-bit mop-up and the scalar tail.
Extended reasoning...
Overview
This PR aligns Bun's UTF-16 hex decoder with Node.js: code units above 0xFF are now classified by their low byte (so U+FF41 decodes as 'A') instead of stopping the decode. Three implementation sites are changed in lockstep: the Rust scalar loop in _decode_hex_to_bytes (via a new HexChar::hex_byte() that truncates u16 to u8), the Highway vector loop DecodeHexVectorLoop (an if constexpr masking 16-bit lanes with 0xFF before classification), and the kernel's scalar tail in DecodeHex16Impl (a static_cast<uint8_t> before ScalarHexNibble). Doc comments in highway/lib.rs are updated to match. Tests are added in buffer.test.js and bun-cryptohasher.test.ts.
Security risks
None. This is a Node-compat behavior change in a string decoder; no auth, crypto key handling, or untrusted-size arithmetic is touched. The change makes the u16 decoder more permissive in the same way Node is, but the classification still goes through the same HEX_TABLE / IsAsciiHexDigit gate — a low byte outside [0-9A-Fa-f] still stops the decode. No new allocation or bounds arithmetic.
Level of scrutiny
Medium. The change touches a runtime-dispatched SIMD kernel, which is cross-platform-sensitive, but the added operations (hn::And with a broadcast constant, guarded by if constexpr on lane width) are basic Highway primitives available on every target. The u8 path is provably unchanged: hex_byte() for u8 is identity, and the if constexpr (sizeof == 2) branch is dead. I traced IsAsciiHexAlpha / IsAsciiHexDigit / HexNibbleValue on masked values (U+FF41 → 0x41 → nibble 10; U+0147 → 0x47 → invalid) and they behave correctly.
Other factors
- Blast radius verified: grepped all
decode_hex_to_bytes*callers — onlyencoding.rs(the Buffer/String encoding dispatch) instantiates the u16 form; postgres bytea, CSRF, IPC and the dev server all pass byte slices and compile to the unchanged u8 path. - Test coverage is thorough: short-input scalar path, 75-pair inputs with wide units placed at vector-block boundaries / the 128-bit mop-up region / the scalar tail, both the "decodes as low byte" and "stops on non-hex low byte" halves, and multiple entry points (
Buffer.from,write,hexWrite,fill,alloc,indexOf,CryptoHasher.update,createHash). The reference JS decoder was updated to& 0xffto model Node. - Replaced test: the deleted test explicitly pinned the old Node-divergent behavior ("treats UTF-16 code units above 0xFF as invalid even when their low byte is a hex digit"). Its replacement guards both the new behavior and the unchanged invariant (units whose low byte is not a hex digit still stop the decode), so no coverage is lost — this is the documented exception to "never delete a test".
- Trait bound removal:
HexChar: Into<u32>was dropped; grep confirms no consumer relies on it beyond the removed.into()calls. - Differential testing against Node v26.3.0 (4000-case corpus, 0 diffs) and precedent for the same low-byte narrowing in base64 (#31423) give additional confidence.
|
Thanks for the review. Nothing to change from it: the three decode sites, the u8 callers and the replaced test are as described. A second review pass is still running on my side; I will update the status comment above once it and the Buildkite build (#100129) finish. |
Problem
Buffer.from(str, "hex")on a two-byte string stops decoding at the first code unit above 0xFF. Node decodes such a unit from its low byte instead:The same decoder is behind every hex-encoded string input (
buf.write,buf.hexWrite,buf.fill,Buffer.alloc(n, str, "hex"),buf.indexOf(str, 0, "hex"),createHash().update(str, "hex"),Bun.CryptoHasher.update(str, "hex"),fs.writeFileSync(p, str, "hex"), ...), so all of them differ from node the same way.Cause: the
u16instantiation of_decode_hex_to_bytes(src/bun_core/string/immutable.rs) had an explicit> u8::MAXbail-out in the scalar loop, and the Highway kernelDecodeHex16Impl(src/jsc/bindings/highway_strings.cpp) classified the full 16-bit lane, so a unit above 0xFF never passed as a hex digit.One-byte (Latin-1) strings already matched node; only strings stored as UTF-16 were affected.
Fix
HexChargainshex_byte(), the byte the decoder classifies: identity foru8, the low byte foru16. The scalar loop looks that byte up in the table, and the> u8::MAXcheck is gone.DecodeHexVectorLoopmasks 16-bit lanes with0xFFbefore classifying them (two extraAnds per iteration on the UTF-16 path only; the 8-bit path is unchanged), and the kernel's scalar tail narrows the same way. The two paths still agree, which theHIGHWAY_MIN_PAIRSsplit relies on.static_cast<uint8_t>before looking it up, so a unit above 0xFF whose low byte is a hex digit decodes as that digit, and one whose low byte is not a hex digit stops the decode. The second half is unchanged here; only the first half was missing. Bun already made the same call for base64/base64url two-byte strings in Decode Buffer base64/base64url with simdutf's lenient accept-garbage mode #31423, and this brings hex in line with that.u16decoder is only instantiated fromconstruct_from_u16andwrite_u16insrc/runtime/webcore/encoding.rs(the node encoding paths). The other callers of the shared decoder (csrf tokens, postgres bytea, IPC, dev server) pass byte slices and compile to the unchangedu8instantiation.Buffer.byteLength(str, "hex")islength / 2and is unaffected.Uint8Array.fromHexis JSC's own implementation and does not go through this code.test/js/node/buffer.test.js: a short-input test (scalar path) coveringBuffer.from,write,hexWrite,fill,Buffer.alloc(n, str, "hex")andindexOf; and, in the SIMD boundary block, the test that pinned the old behaviour is replaced by two tests placing a wide unit inside the vector blocks, in the 128-bit mop-up region of wide targets and in the scalar tail of a 75-pair input, one for units that decode and one for units that stop the decode. The block's JS reference decoder now narrows like node. The "decodes" tests fail on the released bun and pass with this change; the "stops" tests pass both ways and guard the unchanged half.test/js/bun/util/bun-cryptohasher.test.ts:Bun.CryptoHasher(theString::encodeentry point), its HMAC form andnode:crypto'screateHashhash the narrowed bytes.bun bd test test/js/node/buffer.test.js(637 pass),bun bd test test/js/bun/util/bun-cryptohasher.test.ts(403 pass),test/js/bun/util/csrf.test.ts, and the vendored node teststest-buffer-badhex,-alloc,-fill,-write,-from,-bytelength,-indexof,-includespass with the debug build. The local machine has AVX-512 (VBMI), so the widest kernel, its 128-bit mop-up and the scalar tail all ran.Background
substring/sliceeven if the remaining characters are ASCII (that is how hex strings end up on this path in practice, see Error while calling Buffer.from with substring contains chinese character #4919).encoding.rsdispatches the two representations to theu8andu16instantiations of the same decoder.Bufferstring decoders (hex, base64, latin1) work on the low byte of each UTF-16 code unit; they never reject a unit for being above 0xFF. For hex this means a non-hex unit stops the decode exactly like an ASCII'g'would, and a unit whose low byte is a hex digit contributes that digit._decode_hex_to_bytes) hands inputs of 16 or more pairs to a runtime-dispatched Highway kernel and decodes shorter inputs in a scalar loop. The kernel decodes whole vector blocks and returns at the first block containing an invalid pair; its own scalar tail then finds the exact pair. On wide targets (AVX2/AVX-512) a 128-bit pass mops up the remainder before the scalar tail. The semantics therefore live in three places (Rust scalar loop, vector loop, kernel scalar tail), and all three are changed together here.Differential corpus
Generator: 4000 strings of up to 200 code units drawn from ASCII hex digits, wide units whose low byte is a hex digit, wide units with an arbitrary low byte, Latin-1 bytes, lone surrogates and fullwidth forms (U+FF00..U+FF5F), half of them forced into two-byte storage even when every unit fits in a byte. For each string the script prints
Buffer.byteLength,Buffer.from(s, "hex"), the return value and resulting contents ofbuf.write(s, "hex")into a buffer of random length 0..63,Buffer.alloc(7).fill(s, "hex")(or the thrown error code), andindexOf(s, 0, "hex")on a buffer containing the decoded bytes at offset 1.