Skip to content

Commit 799740c

Browse files
committed
buffer: add isLatin1
Implements a fast check to determine if a string is a valid byte string (only chars <= 0xff). Signed-off-by: James M Snell <jasnell@gmail.com>
1 parent 3641c36 commit 799740c

7 files changed

Lines changed: 245 additions & 1 deletion

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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: [1e7],
9+
length: ['short', 'long'],
10+
// onebyte: one-byte representation (O(1) check)
11+
// twobyte: two-byte representation containing only code units <= 0xFF
12+
// invalid: ends with a code unit > 0xFF
13+
input: ['onebyte', 'twobyte', 'invalid'],
14+
method: ['isLatin1', 'loop', 'regex'],
15+
});
16+
17+
function loop(str) {
18+
for (let i = 0; i < str.length; i++) {
19+
if (str.charCodeAt(i) > 255) return false;
20+
}
21+
return true;
22+
}
23+
24+
const notLatin1Re = /[\u0100-\uffff]/;
25+
function regex(str) {
26+
return !notLatin1Re.test(str);
27+
}
28+
29+
const methods = { isLatin1: buffer.isLatin1, loop, regex };
30+
31+
function main({ n, length, input, method }) {
32+
const base = length === 'short' ? 'hello w\u00f6rld' : 'hello w\u00f6rld'.repeat(200);
33+
let str;
34+
switch (input) {
35+
case 'onebyte':
36+
str = base;
37+
break;
38+
case 'twobyte':
39+
// Slicing a two-byte string keeps the two-byte representation.
40+
str = ('\u0100' + base).slice(1);
41+
break;
42+
case 'invalid':
43+
str = base + '\u0100';
44+
break;
45+
}
46+
const expected = input !== 'invalid';
47+
const fn = methods[method];
48+
assert.strictEqual(fn(str), expected);
49+
50+
bench.start();
51+
let result;
52+
for (let i = 0; i < n; ++i) {
53+
result = fn(str);
54+
}
55+
bench.end(n);
56+
assert.strictEqual(result, expected);
57+
}

‎doc/api/buffer.md‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5414,6 +5414,53 @@ including the case in which `input` is empty.
54145414

54155415
A detached `ArrayBuffer`, or a `TypedArray` backed by one, is treated as empty.
54165416

5417+
### `buffer.isLatin1(input)`
5418+
5419+
<!-- YAML
5420+
added: REPLACEME
5421+
-->
5422+
5423+
* `input` {string} The string to validate.
5424+
* Returns: {boolean}
5425+
5426+
This function returns `true` if `input` can be losslessly encoded using the
5427+
Node.js [`'latin1'`][character encodings] encoding, including the case in
5428+
which `input` is empty. That is, it returns `true` if every UTF-16 code unit
5429+
of `input` is in the range `U+0000` to `U+00FF`. Such a string is also a valid
5430+
[WebIDL `ByteString`][].
5431+
5432+
This check uses the Node.js definition of `'latin1'`, in which each code unit
5433+
from `U+0000` to `U+00FF` maps directly to the byte of the same value. It does
5434+
not use the [WHATWG Encoding Standard][] definition, in which the `'latin1'`
5435+
label is an alias for `windows-1252`. For example, `'\u0080'` is considered
5436+
latin1 by this function, while `'€'` (`U+20AC`, which `windows-1252` encodes as
5437+
`0x80`) is not.
5438+
5439+
Unlike [`buffer.isAscii()`][] and [`buffer.isUtf8()`][], this function
5440+
validates a string rather than a `Buffer`, `TypedArray`, or `ArrayBuffer`.
5441+
Every byte sequence would trivially be valid `'latin1'`, since every byte maps
5442+
to a code unit less than or equal to `0xFF`.
5443+
5444+
```mjs
5445+
import { isLatin1 } from 'node:buffer';
5446+
5447+
isLatin1('hello'); // true
5448+
isLatin1('café'); // true
5449+
isLatin1('\u00ff'); // true
5450+
isLatin1('\u0100'); // false
5451+
isLatin1('€'); // false
5452+
```
5453+
5454+
```cjs
5455+
const { isLatin1 } = require('node:buffer');
5456+
5457+
isLatin1('hello'); // true
5458+
isLatin1('café'); // true
5459+
isLatin1('\u00ff'); // true
5460+
isLatin1('\u0100'); // false
5461+
isLatin1('€'); // false
5462+
```
5463+
54175464
### `buffer.isUtf8(input)`
54185465

54195466
<!-- YAML
@@ -5775,6 +5822,7 @@ or after startup, if the alignment has to hold at run time.
57755822
[UTF-16]: https://en.wikipedia.org/wiki/UTF-16
57765823
[UTF-8]: https://en.wikipedia.org/wiki/UTF-8
57775824
[WHATWG Encoding Standard]: https://encoding.spec.whatwg.org/
5825+
[WebIDL `ByteString`]: https://webidl.spec.whatwg.org/#idl-ByteString
57785826
[`--build-snapshot`]: cli.md#--build-snapshot
57795827
[`Buffer.alloc()`]: #static-method-bufferallocsize-fill-encoding
57805828
[`Buffer.allocUnsafe()`]: #static-method-bufferallocunsafesize-alignment
@@ -5814,10 +5862,13 @@ or after startup, if the alignment has to hold at run time.
58145862
[`buf.values()`]: #bufvalues
58155863
[`buffer.constants.MAX_LENGTH`]: #bufferconstantsmax_length
58165864
[`buffer.constants.MAX_STRING_LENGTH`]: #bufferconstantsmax_string_length
5865+
[`buffer.isAscii()`]: #bufferisasciiinput
5866+
[`buffer.isUtf8()`]: #bufferisutf8input
58175867
[`buffer.kMaxLength`]: #bufferkmaxlength
58185868
[`util.inspect()`]: util.md#utilinspectobject-options
58195869
[`v8.startupSnapshot.setDeserializeMainFunction()`]: v8.md#v8startupsnapshotsetdeserializemainfunctioncallback-data
58205870
[`v8::Uint8Array::kMaxLength`]: https://v8.github.io/api/head/classv8_1_1Uint8Array.html#a7677e3d0c9c92e4d40bef7212f5980c6
58215871
[base64url]: https://tools.ietf.org/html/rfc4648#section-5
5872+
[character encodings]: #buffers-and-character-encodings
58225873
[endianness]: https://en.wikipedia.org/wiki/Endianness
58235874
[iterator]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols

‎lib/buffer.js‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ const {
6262
copy: _copy,
6363
fill: bindingFill,
6464
isAscii: bindingIsAscii,
65+
isLatin1: bindingIsLatin1,
6566
isUtf8: bindingIsUtf8,
6667
stringLengthUtf8: bindingStringLengthUtf8,
6768
indexOfBuffer,
@@ -1498,6 +1499,17 @@ function isAscii(input) {
14981499
throw new ERR_INVALID_ARG_TYPE('input', ['ArrayBuffer', 'Buffer', 'TypedArray'], input);
14991500
}
15001501

1502+
/**
1503+
* Returns true if every UTF-16 code unit of `input` is <= 0xFF, i.e. `input`
1504+
* can be losslessly encoded as 'latin1'.
1505+
* @param {string} input
1506+
* @returns {boolean}
1507+
*/
1508+
function isLatin1(input) {
1509+
validateString(input, 'input');
1510+
return bindingIsLatin1(input);
1511+
}
1512+
15011513
function stringLength(input, encoding = 'utf8') {
15021514
if (!isTypedArray(input) && !isAnyArrayBuffer(input)) {
15031515
throw new ERR_INVALID_ARG_TYPE('input', ['ArrayBuffer', 'Buffer', 'TypedArray'], input);
@@ -1529,6 +1541,7 @@ module.exports = {
15291541
transcode,
15301542
isUtf8,
15311543
isAscii,
1544+
isLatin1,
15321545

15331546
// Legacy
15341547
kMaxLength,

‎src/node_buffer.cc‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,6 +1414,28 @@ static bool FastIsAscii(Local<Value> receiver,
14141414

14151415
static CFunction fast_is_ascii(CFunction::Make(FastIsAscii));
14161416

1417+
// Returns true if every UTF-16 code unit of the string is <= 0xFF, i.e. the
1418+
// string can be losslessly encoded using Node.js' 'latin1' encoding (which
1419+
// maps U+0000-U+00FF directly to bytes 0x00-0xFF, unlike the WHATWG
1420+
// 'latin1' label, which is an alias for windows-1252).
1421+
// ContainsOnlyOneByte() is O(1) for strings with a one-byte representation,
1422+
// uses SIMD for flat two-byte strings, and traverses cons strings without
1423+
// flattening (no allocation), which makes it safe to call from a fast API
1424+
// call.
1425+
static void IsLatin1(const FunctionCallbackInfo<Value>& args) {
1426+
CHECK_EQ(args.Length(), 1);
1427+
CHECK(args[0]->IsString());
1428+
args.GetReturnValue().Set(args[0].As<String>()->ContainsOnlyOneByte());
1429+
}
1430+
1431+
static bool FastIsLatin1(Local<Value> receiver, Local<Value> value) {
1432+
TRACK_V8_FAST_API_CALL("buffer.isLatin1");
1433+
CHECK(value->IsString());
1434+
return value.As<String>()->ContainsOnlyOneByte();
1435+
}
1436+
1437+
static CFunction fast_is_latin1(CFunction::Make(FastIsLatin1));
1438+
14171439
// Number of UTF-16 code units produced by decoding [p, end) as UTF-8 with
14181440
// WHATWG "maximal subpart" U+FFFD replacement, matching the fallback that
14191441
// StringBytes::Encode takes for invalid input (v8::String::NewFromUtf8).
@@ -1928,6 +1950,8 @@ void Initialize(Local<Object> target,
19281950
SetFastMethodNoSideEffect(context, target, "isUtf8", IsUtf8, &fast_is_utf8);
19291951
SetFastMethodNoSideEffect(
19301952
context, target, "isAscii", IsAscii, &fast_is_ascii);
1953+
SetFastMethodNoSideEffect(
1954+
context, target, "isLatin1", IsLatin1, &fast_is_latin1);
19311955
SetFastMethodNoSideEffect(context,
19321956
target,
19331957
"stringLengthUtf8",
@@ -2008,6 +2032,8 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
20082032
registry->Register(fast_is_utf8);
20092033
registry->Register(IsAscii);
20102034
registry->Register(fast_is_ascii);
2035+
registry->Register(IsLatin1);
2036+
registry->Register(fast_is_latin1);
20112037
registry->Register(StringLengthUtf8);
20122038
registry->Register(fast_string_length_utf8);
20132039

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
'use strict';
2+
3+
require('../common');
4+
const assert = require('assert');
5+
const { isLatin1 } = require('buffer');
6+
7+
function reference(str) {
8+
for (let i = 0; i < str.length; i++) {
9+
if (str.charCodeAt(i) > 0xFF) return false;
10+
}
11+
return true;
12+
}
13+
14+
// Basic cases.
15+
assert.strictEqual(isLatin1(''), true);
16+
assert.strictEqual(isLatin1('hello'), true);
17+
assert.strictEqual(isLatin1('\x00'), true);
18+
assert.strictEqual(isLatin1('\x7f\x80'), true);
19+
assert.strictEqual(isLatin1('caf\u00e9'), true);
20+
assert.strictEqual(isLatin1('\u00ff'), true);
21+
assert.strictEqual(isLatin1('\u0100'), false);
22+
assert.strictEqual(isLatin1('\u20ac'), false);
23+
assert.strictEqual(isLatin1('\uffff'), false);
24+
// Surrogate pairs and lone surrogates are > 0xFF.
25+
assert.strictEqual(isLatin1('\ud83d\ude00'), false);
26+
assert.strictEqual(isLatin1('\ud800'), false);
27+
assert.strictEqual(isLatin1('\udfff'), false);
28+
29+
// Position of the offending code unit must not matter, and long strings must
30+
// exercise the vectorized paths.
31+
for (const len of [1, 7, 8, 15, 16, 31, 32, 33, 63, 64, 65, 1000, 4099]) {
32+
const base = 'a\u00ff'.repeat(len).slice(0, len);
33+
assert.strictEqual(isLatin1(base), true);
34+
for (const pos of [0, len >> 1, len - 1]) {
35+
for (const ch of ['\u0100', '\u1234', '\ud800', '\uffff']) {
36+
const str = base.slice(0, pos) + ch + base.slice(pos + 1);
37+
assert.strictEqual(isLatin1(str), false, `len=${len} pos=${pos}`);
38+
}
39+
}
40+
}
41+
42+
// Strings stored with a two-byte representation that only contain code units
43+
// <= 0xFF must still be reported as latin1.
44+
{
45+
const twoByte = '\u0100' + 'abc\u00e9\u00ff'.repeat(100);
46+
const sliced = twoByte.slice(1);
47+
assert.strictEqual(isLatin1(twoByte), false);
48+
assert.strictEqual(isLatin1(sliced), true);
49+
assert.strictEqual(isLatin1(twoByte.substring(1, 20)), true);
50+
}
51+
52+
// Cons strings (results of concatenation) with mixed representations.
53+
{
54+
let cons = '';
55+
for (let i = 0; i < 100; i++) cons += `x${i}\u00e9`;
56+
assert.strictEqual(isLatin1(cons), true);
57+
assert.strictEqual(isLatin1(cons + '\u0100'), false);
58+
assert.strictEqual(isLatin1('\u0100' + cons), false);
59+
assert.strictEqual(isLatin1(cons + '\u0100'.slice(1) + cons), true);
60+
}
61+
62+
// Randomized comparison with the reference implementation.
63+
for (let i = 0; i < 1000; i++) {
64+
const len = Math.floor(Math.random() * 100);
65+
const max = Math.random() < 0.5 ? 0x100 : 0x10000;
66+
let str = '';
67+
for (let j = 0; j < len; j++) {
68+
// Keep the probability of producing a code unit > 0xFF low so that
69+
// both outcomes are exercised.
70+
const code = Math.random() < 0.98 ?
71+
Math.floor(Math.random() * 0x100) :
72+
Math.floor(Math.random() * max);
73+
str += String.fromCharCode(code);
74+
}
75+
assert.strictEqual(isLatin1(str), reference(str), JSON.stringify(str));
76+
}
77+
78+
// Invalid argument types.
79+
[
80+
undefined, null, 1, 1n, true, {}, [], Symbol('a'),
81+
Buffer.from('a'), new Uint8Array(1), new ArrayBuffer(1),
82+
new String('a'),
83+
].forEach((input) => {
84+
assert.throws(() => isLatin1(input), { code: 'ERR_INVALID_ARG_TYPE' });
85+
});

‎test/parallel/test-buffer-isutf8-isascii-fast.js‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33

44
const common = require('../common');
55
const assert = require('assert');
6-
const { Buffer, isAscii, isUtf8 } = require('buffer');
6+
const { Buffer, isAscii, isLatin1, isUtf8 } = require('buffer');
77

88
const ascii = Buffer.from('hello');
99
const utf8 = Buffer.from('hello \xc4\x9f');
10+
const latin1 = 'hello \u00e9';
1011

1112
function testFastIsAscii() {
1213
assert.strictEqual(isAscii(ascii), true);
@@ -16,6 +17,10 @@ function testFastIsUtf8() {
1617
assert.strictEqual(isUtf8(utf8), true);
1718
}
1819

20+
function testFastIsLatin1() {
21+
assert.strictEqual(isLatin1(latin1), true);
22+
}
23+
1924
eval('%PrepareFunctionForOptimization(isAscii)');
2025
testFastIsAscii();
2126
eval('%OptimizeFunctionOnNextCall(isAscii)');
@@ -26,9 +31,15 @@ testFastIsUtf8();
2631
eval('%OptimizeFunctionOnNextCall(isUtf8)');
2732
testFastIsUtf8();
2833

34+
eval('%PrepareFunctionForOptimization(isLatin1)');
35+
testFastIsLatin1();
36+
eval('%OptimizeFunctionOnNextCall(isLatin1)');
37+
testFastIsLatin1();
38+
2939
if (common.isDebug) {
3040
const { internalBinding } = require('internal/test/binding');
3141
const { getV8FastApiCallCount } = internalBinding('debug');
3242
assert.strictEqual(getV8FastApiCallCount('buffer.isAscii'), 1);
3343
assert.strictEqual(getV8FastApiCallCount('buffer.isUtf8'), 1);
44+
assert.strictEqual(getV8FastApiCallCount('buffer.isLatin1'), 1);
3445
}

‎typings/internalBinding/buffer.d.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export interface BufferBinding {
2121

2222
isUtf8(input: ArrayBufferView | ArrayBuffer | SharedArrayBuffer): boolean;
2323
isAscii(input: ArrayBufferView | ArrayBuffer | SharedArrayBuffer): boolean;
24+
isLatin1(input: string): boolean;
2425

2526
kMaxLength: number;
2627
kStringMaxLength: number;

0 commit comments

Comments
 (0)