Skip to content

Commit b1c6e2b

Browse files
committed
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>
1 parent 7aaf9b4 commit b1c6e2b

5 files changed

Lines changed: 338 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 { stringLength } = 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(stringLength(buf, encoding), expected);
26+
}
27+
bench.end(n);
28+
}

doc/api/buffer.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5425,6 +5425,58 @@ changes:
54255425
Resolves a `'blob:nodedata:...'` an associated {Blob} object registered using
54265426
a prior call to `URL.createObjectURL()`.
54275427

5428+
### `buffer.stringLength(input[, encoding])`
5429+
5430+
<!-- YAML
5431+
added: REPLACEME
5432+
-->
5433+
5434+
* `input` {Buffer | ArrayBuffer | TypedArray} The bytes that would be decoded.
5435+
* `encoding` {string} The character encoding `input` would be decoded with.
5436+
**Default:** `'utf8'`.
5437+
* Returns: {integer}
5438+
5439+
Returns the length, in UTF-16 code units, of the string that
5440+
`buf.toString(encoding)` would produce for the same bytes, without decoding
5441+
them. This is the counterpart of [`Buffer.byteLength()`][].
5442+
5443+
For `'utf8'`, invalid byte sequences are counted as they would be decoded:
5444+
each maximal invalid subsequence becomes one `U+FFFD` replacement character.
5445+
For every other encoding the result is computed from `input.byteLength` alone.
5446+
5447+
A detached `ArrayBuffer`, or a `TypedArray` backed by one, is treated as empty.
5448+
5449+
The result is not capped: compare it with
5450+
[`buffer.constants.MAX_STRING_LENGTH`][] before decoding to know whether the
5451+
decode can succeed at all. A string of `n` code units occupies between `n` and
5452+
`2 * n` bytes of memory.
5453+
5454+
```mjs
5455+
import { Buffer, stringLength, constants } from 'node:buffer';
5456+
5457+
const buf = Buffer.from('€ 100', 'utf8');
5458+
5459+
console.log(stringLength(buf));
5460+
// Prints: 5
5461+
console.log(stringLength(buf, 'hex'));
5462+
// Prints: 14
5463+
console.log(stringLength(buf) <= constants.MAX_STRING_LENGTH);
5464+
// Prints: true
5465+
```
5466+
5467+
```cjs
5468+
const { Buffer, stringLength, constants } = require('node:buffer');
5469+
5470+
const buf = Buffer.from('€ 100', 'utf8');
5471+
5472+
console.log(stringLength(buf));
5473+
// Prints: 5
5474+
console.log(stringLength(buf, 'hex'));
5475+
// Prints: 14
5476+
console.log(stringLength(buf) <= constants.MAX_STRING_LENGTH);
5477+
// Prints: true
5478+
```
5479+
54285480
### `buffer.transcode(source, fromEnc, toEnc)`
54295481

54305482
<!-- YAML
@@ -5715,6 +5767,7 @@ or after startup, if the alignment has to hold at run time.
57155767
[`Buffer.alloc()`]: #static-method-bufferallocsize-fill-encoding
57165768
[`Buffer.allocUnsafe()`]: #static-method-bufferallocunsafesize-alignment
57175769
[`Buffer.allocUnsafeSlow()`]: #static-method-bufferallocunsafeslowsize-alignment
5770+
[`Buffer.byteLength()`]: #static-method-bufferbytelengthstring-encoding
57185771
[`Buffer.concat()`]: #static-method-bufferconcatlist-totallength
57195772
[`Buffer.copyBytesFrom()`]: #static-method-buffercopybytesfromview-offset-length
57205773
[`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,
@@ -1494,11 +1496,38 @@ function isAscii(input) {
14941496
throw new ERR_INVALID_ARG_TYPE('input', ['ArrayBuffer', 'Buffer', 'TypedArray'], input);
14951497
}
14961498

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

15031532
// Legacy
15041533
kMaxLength,

src/node_buffer.cc

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

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

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

@@ -1839,6 +1927,11 @@ void Initialize(Local<Object> target,
18391927
SetFastMethodNoSideEffect(context, target, "isUtf8", IsUtf8, &fast_is_utf8);
18401928
SetFastMethodNoSideEffect(
18411929
context, target, "isAscii", IsAscii, &fast_is_ascii);
1930+
SetFastMethodNoSideEffect(context,
1931+
target,
1932+
"stringLengthUtf8",
1933+
StringLengthUtf8,
1934+
&fast_string_length_utf8);
18421935

18431936
target
18441937
->Set(context,
@@ -1914,6 +2007,8 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
19142007
registry->Register(fast_is_utf8);
19152008
registry->Register(IsAscii);
19162009
registry->Register(fast_is_ascii);
2010+
registry->Register(StringLengthUtf8);
2011+
registry->Register(fast_string_length_utf8);
19172012

19182013
registry->Register(StringSlice<ASCII>);
19192014
registry->Register(StringSlice<BASE64>);

0 commit comments

Comments
 (0)