Skip to content

Commit 22ab7d7

Browse files
authored
buffer: add Buffer.stringLength()
Add `Buffer.stringLength(input[, encoding])`, the counterpart of `Buffer.byteLength()`: it returns the number of UTF-16 code units that `buf.toString(encoding)` would produce, without decoding. For UTF-8 the count is computed with simdutf. Invalid input is counted with the same maximal-subpart replacement that the decoder applies, so the result always matches `toString().length`. The other encodings are computed from `byteLength` alone. This lets code that accumulates streamed input check the result against `buffer.constants.MAX_STRING_LENGTH` and size its memory budget before decoding. Refs: #66062 Signed-off-by: Matteo Collina <hello@matteocollina.com> PR-URL: #66064 Reviewed-By: Paolo Insogna <paolo@cowtech.it> Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
1 parent b125121 commit 22ab7d7

5 files changed

Lines changed: 340 additions & 0 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
'use strict';
2+
3+
const common = require('../common.js');
4+
const { Buffer } = require('node:buffer');
5+
const assert = require('node:assert');
6+
7+
const bench = common.createBenchmark(main, {
8+
n: [1e6],
9+
encoding: ['utf8', 'latin1', 'base64'],
10+
len: [32, 4096, 1048576],
11+
input: ['ascii', 'multibyte', 'invalid'],
12+
});
13+
14+
function main({ n, encoding, len, input }) {
15+
let buf;
16+
if (input === 'ascii') {
17+
buf = Buffer.alloc(len, 'a');
18+
} else {
19+
buf = Buffer.alloc(len - (len % 3), '€');
20+
if (input === 'invalid') buf = Buffer.concat([buf, Buffer.from([0xE2, 0x82])]);
21+
}
22+
const expected = buf.toString(encoding).length;
23+
bench.start();
24+
for (let i = 0; i < n; ++i) {
25+
assert.strictEqual(Buffer.stringLength(buf, encoding), expected);
26+
}
27+
bench.end(n);
28+
}

doc/api/buffer.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,6 +1015,59 @@ console.log(`${str}: ${str.length} characters, ` +
10151015
When `string` is a {Buffer|DataView|TypedArray|ArrayBuffer|SharedArrayBuffer},
10161016
the byte length as reported by `.byteLength` is returned.
10171017

1018+
### Static method: `Buffer.stringLength(input[, encoding])`
1019+
1020+
<!-- YAML
1021+
added: REPLACEME
1022+
-->
1023+
1024+
* `input` {Buffer | ArrayBuffer | TypedArray} The bytes that would be decoded.
1025+
* `encoding` {string} The character encoding `input` would be decoded with.
1026+
**Default:** `'utf8'`.
1027+
* Returns: {integer}
1028+
1029+
Returns the length, in UTF-16 code units, of the string that
1030+
`buf.toString(encoding)` would produce for the same bytes, without decoding
1031+
them. This is the counterpart of [`Buffer.byteLength()`][], which returns the
1032+
number of bytes a string would encode to.
1033+
1034+
For `'utf8'`, invalid byte sequences are counted as they would be decoded:
1035+
each maximal invalid subsequence becomes one `U+FFFD` replacement character.
1036+
For every other encoding the result is computed from `input.byteLength` alone.
1037+
1038+
A detached `ArrayBuffer`, or a `TypedArray` backed by one, is treated as empty.
1039+
1040+
The result is not capped: compare it with
1041+
[`buffer.constants.MAX_STRING_LENGTH`][] before decoding to know whether the
1042+
decode can succeed at all. A string of `n` code units occupies between `n` and
1043+
`2 * n` bytes of memory.
1044+
1045+
```mjs
1046+
import { Buffer, constants } from 'node:buffer';
1047+
1048+
const buf = Buffer.from('€ 100', 'utf8');
1049+
1050+
console.log(Buffer.stringLength(buf));
1051+
// Prints: 5
1052+
console.log(Buffer.stringLength(buf, 'hex'));
1053+
// Prints: 14
1054+
console.log(Buffer.stringLength(buf) <= constants.MAX_STRING_LENGTH);
1055+
// Prints: true
1056+
```
1057+
1058+
```cjs
1059+
const { Buffer, constants } = require('node:buffer');
1060+
1061+
const buf = Buffer.from('€ 100', 'utf8');
1062+
1063+
console.log(Buffer.stringLength(buf));
1064+
// Prints: 5
1065+
console.log(Buffer.stringLength(buf, 'hex'));
1066+
// Prints: 14
1067+
console.log(Buffer.stringLength(buf) <= constants.MAX_STRING_LENGTH);
1068+
// Prints: true
1069+
```
1070+
10181071
### Static method: `Buffer.compare(buf1, buf2)`
10191072

10201073
<!-- YAML
@@ -5726,6 +5779,7 @@ or after startup, if the alignment has to hold at run time.
57265779
[`Buffer.alloc()`]: #static-method-bufferallocsize-fill-encoding
57275780
[`Buffer.allocUnsafe()`]: #static-method-bufferallocunsafesize-alignment
57285781
[`Buffer.allocUnsafeSlow()`]: #static-method-bufferallocunsafeslowsize-alignment
5782+
[`Buffer.byteLength()`]: #static-method-bufferbytelengthstring-encoding
57295783
[`Buffer.concat()`]: #static-method-bufferconcatlist-totallength
57305784
[`Buffer.copyBytesFrom()`]: #static-method-buffercopybytesfromview-offset-length
57315785
[`Buffer.from(array)`]: #static-method-bufferfromarray

lib/buffer.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const {
2626
ArrayBufferIsView,
2727
ArrayIsArray,
2828
ArrayPrototypeForEach,
29+
MathCeil,
2930
MathFloor,
3031
MathMin,
3132
MathTrunc,
@@ -62,6 +63,7 @@ const {
6263
fill: bindingFill,
6364
isAscii: bindingIsAscii,
6465
isUtf8: bindingIsUtf8,
66+
stringLengthUtf8: bindingStringLengthUtf8,
6567
indexOfBuffer,
6668
indexOfNumber,
6769
indexOfString,
@@ -938,6 +940,7 @@ function byteLength(string, encoding) {
938940
}
939941

940942
Buffer.byteLength = byteLength;
943+
Buffer.stringLength = stringLength;
941944

942945
// For backwards compatibility.
943946
ObjectDefineProperty(Buffer.prototype, 'parent', {
@@ -1495,6 +1498,32 @@ function isAscii(input) {
14951498
throw new ERR_INVALID_ARG_TYPE('input', ['ArrayBuffer', 'Buffer', 'TypedArray'], input);
14961499
}
14971500

1501+
function stringLength(input, encoding = 'utf8') {
1502+
if (!isTypedArray(input) && !isAnyArrayBuffer(input)) {
1503+
throw new ERR_INVALID_ARG_TYPE('input', ['ArrayBuffer', 'Buffer', 'TypedArray'], input);
1504+
}
1505+
validateString(encoding, 'encoding');
1506+
const ops = getEncodingOps(encoding);
1507+
if (ops === undefined) {
1508+
throw new ERR_UNKNOWN_ENCODING(encoding);
1509+
}
1510+
const length = input.byteLength;
1511+
switch (ops.encodingVal) {
1512+
case encodingsMap.utf8:
1513+
return length === 0 ? 0 : bindingStringLengthUtf8(input);
1514+
case encodingsMap.utf16le:
1515+
return MathFloor(length / 2);
1516+
case encodingsMap.hex:
1517+
return length * 2;
1518+
case encodingsMap.base64:
1519+
return MathCeil(length / 3) * 4;
1520+
case encodingsMap.base64url:
1521+
return MathCeil(length * 4 / 3);
1522+
default: // latin1, ascii
1523+
return length;
1524+
}
1525+
}
1526+
14981527
module.exports = {
14991528
Buffer,
15001529
transcode,

src/node_buffer.cc

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1410,6 +1410,94 @@ static bool FastIsAscii(Local<Value> receiver,
14101410

14111411
static CFunction fast_is_ascii(CFunction::Make(FastIsAscii));
14121412

1413+
// Number of UTF-16 code units produced by decoding [p, end) as UTF-8 with
1414+
// WHATWG "maximal subpart" U+FFFD replacement, matching the fallback that
1415+
// StringBytes::Encode takes for invalid input (v8::String::NewFromUtf8).
1416+
static size_t Utf16LengthFromInvalidUtf8(const uint8_t* p, const uint8_t* end) {
1417+
size_t units = 0;
1418+
while (p < end) {
1419+
const uint8_t lead = *p;
1420+
if (lead < 0x80) {
1421+
p++;
1422+
units++;
1423+
continue;
1424+
}
1425+
size_t len;
1426+
uint8_t lo = 0x80;
1427+
uint8_t hi = 0xBF;
1428+
if (lead >= 0xC2 && lead <= 0xDF) {
1429+
len = 2;
1430+
} else if (lead >= 0xE0 && lead <= 0xEF) {
1431+
len = 3;
1432+
if (lead == 0xE0) lo = 0xA0;
1433+
if (lead == 0xED) hi = 0x9F;
1434+
} else if (lead >= 0xF0 && lead <= 0xF4) {
1435+
len = 4;
1436+
if (lead == 0xF0) lo = 0x90;
1437+
if (lead == 0xF4) hi = 0x8F;
1438+
} else {
1439+
// Invalid lead byte: one replacement character.
1440+
p++;
1441+
units++;
1442+
continue;
1443+
}
1444+
size_t i = 1;
1445+
for (; i < len && p + i < end; i++) {
1446+
const uint8_t c = p[i];
1447+
if (i == 1 ? (c < lo || c > hi) : (c < 0x80 || c > 0xBF)) break;
1448+
}
1449+
if (i == len) {
1450+
p += len;
1451+
units += (len == 4) ? 2 : 1;
1452+
} else {
1453+
// The lead byte plus the valid continuation bytes seen so far form the
1454+
// maximal subpart and become one replacement character; the byte that
1455+
// failed is decoded again on the next iteration.
1456+
p += i;
1457+
units++;
1458+
}
1459+
}
1460+
return units;
1461+
}
1462+
1463+
static double StringLengthUtf8Impl(Local<Value> value) {
1464+
ArrayBufferViewContents<uint8_t> abv(value);
1465+
const uint8_t* data = abv.data();
1466+
const size_t length = abv.length();
1467+
if (length == 0) return 0;
1468+
const simdutf::result r = simdutf::validate_utf8_with_errors(
1469+
reinterpret_cast<const char*>(data), length);
1470+
if (r.error == simdutf::error_code::SUCCESS) {
1471+
return static_cast<double>(simdutf::utf16_length_from_utf8(
1472+
reinterpret_cast<const char*>(data), length));
1473+
}
1474+
// r.count is the offset of the first invalid sequence; everything before it
1475+
// is valid UTF-8.
1476+
const size_t valid = simdutf::utf16_length_from_utf8(
1477+
reinterpret_cast<const char*>(data), r.count);
1478+
return static_cast<double>(
1479+
valid + Utf16LengthFromInvalidUtf8(data + r.count, data + length));
1480+
}
1481+
1482+
static void StringLengthUtf8(const FunctionCallbackInfo<Value>& args) {
1483+
CHECK_EQ(args.Length(), 1);
1484+
CHECK(args[0]->IsTypedArray() || args[0]->IsArrayBuffer() ||
1485+
args[0]->IsSharedArrayBuffer());
1486+
1487+
args.GetReturnValue().Set(StringLengthUtf8Impl(args[0]));
1488+
}
1489+
1490+
static double FastStringLengthUtf8(Local<Value> receiver,
1491+
Local<Value> value,
1492+
// NOLINTNEXTLINE(runtime/references)
1493+
FastApiCallbackOptions& options) {
1494+
TRACK_V8_FAST_API_CALL("buffer.stringLengthUtf8");
1495+
HandleScope scope(options.isolate);
1496+
return StringLengthUtf8Impl(value);
1497+
}
1498+
1499+
static CFunction fast_string_length_utf8(CFunction::Make(FastStringLengthUtf8));
1500+
14131501
void SetBufferPrototype(const FunctionCallbackInfo<Value>& args) {
14141502
Realm* realm = Realm::GetCurrent(args);
14151503

@@ -1836,6 +1924,11 @@ void Initialize(Local<Object> target,
18361924
SetFastMethodNoSideEffect(context, target, "isUtf8", IsUtf8, &fast_is_utf8);
18371925
SetFastMethodNoSideEffect(
18381926
context, target, "isAscii", IsAscii, &fast_is_ascii);
1927+
SetFastMethodNoSideEffect(context,
1928+
target,
1929+
"stringLengthUtf8",
1930+
StringLengthUtf8,
1931+
&fast_string_length_utf8);
18391932

18401933
target
18411934
->Set(context,
@@ -1911,6 +2004,8 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
19112004
registry->Register(fast_is_utf8);
19122005
registry->Register(IsAscii);
19132006
registry->Register(fast_is_ascii);
2007+
registry->Register(StringLengthUtf8);
2008+
registry->Register(fast_string_length_utf8);
19142009

19152010
registry->Register(StringSlice<ASCII>);
19162011
registry->Register(StringSlice<BASE64>);

0 commit comments

Comments
 (0)