From 4e69ac9eceecb77b219d9ba1c08f2ca57dc2268b Mon Sep 17 00:00:00 2001 From: agape1225 <49804691+agape1225@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:18:52 +0900 Subject: [PATCH] src: fix two encodeInto() bugs that reject input that fits TextEncoder.encodeInto() could report that a code point does not fit in the destination Uint8Array even when it does. 1. simpleUtfEncodingLength() used 0x400 as the boundary between 2-byte and 3-byte UTF-8 encodings, but the correct boundary is 0x800: code points in [0x80, 0x800) need 2 bytes in UTF-8, and only code points >= 0x800 need 3. 2. The same function is called with a raw `char` from the Latin1 (one-byte string) code path. `char` is signed on some platforms, so a byte >= 0x80 gets sign-extended to a large uint16_t value instead of the intended code point, which also made encodeInto() behave differently for the same prefix depending on whether the rest of the source string forced V8 to represent it as one-byte (Latin1) or two-byte (UTF-16) internally. Fixes: https://github.com/nodejs/node/issues/65994 Signed-off-by: agape1225 <49804691+agape1225@users.noreply.github.com> --- src/encoding_binding.cc | 11 +++++- ...-encoding-custom-textencoder-encodeinto.js | 39 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 test/parallel/test-whatwg-encoding-custom-textencoder-encodeinto.js diff --git a/src/encoding_binding.cc b/src/encoding_binding.cc index 8a445088b54a..dadaed04e2fe 100644 --- a/src/encoding_binding.cc +++ b/src/encoding_binding.cc @@ -10,6 +10,7 @@ #include #include +#include namespace node { namespace encoding_binding { @@ -88,7 +89,7 @@ constexpr bool isSurrogatePair(uint16_t lead, uint16_t trail) { constexpr size_t simpleUtfEncodingLength(uint16_t c) { if (c < 0x80) return 1; - if (c < 0x400) return 2; + if (c < 0x800) return 2; return 3; } @@ -162,7 +163,13 @@ size_t findBestFit(const Char* data, size_t length, size_t bufferSize) { } while (pos < length && utf8Accumulated < bufferSize) { - size_t extra = simpleUtfEncodingLength(data[pos]); + // `char` is signed on some platforms/ABIs, so widening a byte >= 0x80 + // straight to uint16_t would sign-extend it into a bogus code point. + // Go through the Char type's unsigned counterpart first (a no-op for + // char16_t, which is unsigned already) to get the right code unit. + using UnsignedChar = std::make_unsigned_t; + size_t extra = simpleUtfEncodingLength( + static_cast(static_cast(data[pos]))); if (utf8Accumulated + extra > bufferSize) break; pos++; utf8Accumulated += extra; diff --git a/test/parallel/test-whatwg-encoding-custom-textencoder-encodeinto.js b/test/parallel/test-whatwg-encoding-custom-textencoder-encodeinto.js new file mode 100644 index 000000000000..5633d6310910 --- /dev/null +++ b/test/parallel/test-whatwg-encoding-custom-textencoder-encodeinto.js @@ -0,0 +1,39 @@ +'use strict'; + +// This tests that TextEncoder.encodeInto() does not underestimate how many +// bytes a code point needs when computing how much of the source string fits +// into the destination. + +require('../common'); +const assert = require('assert'); + +// Long enough to bypass the small-string fast path (kSmallStringThreshold = 32 +// in src/encoding_binding.cc) and exercise the chunked encoding logic. +const encoder = new TextEncoder(); + +{ + // Code points in [0x80, 0x800) are 2 bytes in UTF-8; treating them as 3 + // bytes causes encodeInto() to reject input that would actually fit. + const text = 'Ѐ'.repeat(33); + const result = encoder.encodeInto(text, new Uint8Array(2)); + assert.strictEqual(result.read, 1); + assert.strictEqual(result.written, 2); +} + +{ + // A one-byte (Latin1) source string takes a different internal path than + // a two-byte (UTF-16) one. Bytes >= 0x80 must be treated as unsigned there + // too, or they get sign-extended into a bogus, oversized code point. + const text = 'é'.repeat(33); + const result = encoder.encodeInto(text, new Uint8Array(2)); + assert.strictEqual(result.read, 1); + assert.strictEqual(result.written, 2); + + // Appending a two-byte character forces the whole string to be stored as + // UTF-16 internally, which must not change how the Latin1-only prefix + // encodes. + const withTrailingChar = encoder.encodeInto( + text + '☺', new Uint8Array(2)); + assert.strictEqual(withTrailingChar.read, result.read); + assert.strictEqual(withTrailingChar.written, result.written); +}