Skip to content

Commit eff201e

Browse files
committed
v8: keep immutable ArrayBuffer views immutable
v8.serialize stores an ArrayBufferView as a type index, a byte length and the referenced bytes. A view over an immutable ArrayBuffer came back as a mutable view. structuredClone and MessagePort keep immutability because V8 serializes views natively there. For a view over a whole immutable ArrayBuffer, write the type index with its high bit set. Then write the buffer itself through writeValue. V8 tags the buffer as immutable and rebuilds it immutable on read. The reader wraps the buffer in the view class. The encoding of views over mutable ArrayBuffers does not change. A view over part of an immutable ArrayBuffer now throws an error. The error message points to sliceToImmutable. An immutable copy of the range needs that method. It only exists with --js-immutable-arraybuffer. It is not a primordial. User code can replace it before this module loads. V8 has no native way to do the copy yet. A new serdes binding detects immutability. The ArrayBuffer.prototype.immutable getter only exists with the flag. An immutable ArrayBuffer can still be deserialized without it. The reader now copies a mutable view that it reads from an immutable transport buffer. Before this change the view pointed into the transport and came back immutable. The reader also throws ERR_INVALID_STATE on an unknown type index. Before this change it threw a TypeError on undefined. This change leaves the advanced IPC codec in src/node_ipc_serdes.cc as is. Refs: #66346 Assisted-by: Claude Code Signed-off-by: Hakaou <akaou.hassan@gmail.com>
1 parent ce9d650 commit eff201e

4 files changed

Lines changed: 279 additions & 9 deletions

File tree

‎doc/api/v8.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -926,11 +926,20 @@ provided by subclasses.
926926
927927
<!-- YAML
928928
added: v8.0.0
929+
changes:
930+
- version: REPLACEME
931+
pr-url: https://github.com/nodejs/node/pull/REPLACEME
932+
description: Views over immutable `ArrayBuffer`s are deserialized as views
933+
over immutable `ArrayBuffer`s.
929934
-->
930935
931936
A subclass of [`Serializer`][] that serializes `TypedArray`
932937
(in particular [`Buffer`][]) and `DataView` objects as host objects, and only
933938
stores the part of their underlying `ArrayBuffer`s that they are referring to.
939+
A view over a whole immutable `ArrayBuffer` is stored as that immutable
940+
`ArrayBuffer` and is deserialized over an immutable `ArrayBuffer`. A view over
941+
part of an immutable `ArrayBuffer` cannot be serialized: serialize a view over
942+
`buffer.sliceToImmutable(start, end)` instead.
934943
935944
### Class: `v8.DefaultDeserializer`
936945

‎lib/v8.js‎

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
const {
1818
Array,
19+
ArrayBufferPrototypeGetByteLength,
1920
BigInt64Array,
2021
BigUint64Array,
2122
DataView,
@@ -29,13 +30,20 @@ const {
2930
JSONParse,
3031
ObjectPrototypeToString,
3132
SymbolDispose,
33+
TypedArrayPrototypeSet,
3234
Uint16Array,
3335
Uint32Array,
3436
Uint8Array,
3537
Uint8ClampedArray,
3638
} = primordials;
3739

3840
const { Buffer } = require('buffer');
41+
const {
42+
codes: {
43+
ERR_INVALID_STATE,
44+
},
45+
} = require('internal/errors');
46+
const { isArrayBuffer } = require('internal/util/types');
3947
const {
4048
validateString,
4149
validateOneOf,
@@ -44,6 +52,7 @@ const {
4452
const {
4553
Serializer,
4654
Deserializer,
55+
isImmutableArrayBuffer,
4756
} = internalBinding('serdes');
4857
const {
4958
namespace: startupSnapshot,
@@ -401,6 +410,22 @@ Deserializer.prototype.readRawBytes = function readRawBytes(length) {
401410
length);
402411
};
403412

413+
// High bit: a V8-encoded ArrayBuffer replaces length + raw bytes.
414+
const kNestedArrayBufferFlag = 0x80000000;
415+
const kTypeIndexMask = 0x7fffffff;
416+
417+
// sliceToImmutable() is not a primordial and can be replaced before this
418+
// module loads, so only views over a whole buffer are nested.
419+
function immutableRange(serializer, buffer, byteOffset, byteLength) {
420+
if (byteOffset === 0 &&
421+
ArrayBufferPrototypeGetByteLength(buffer) === byteLength) {
422+
return buffer;
423+
}
424+
throw new serializer._getDataCloneError(
425+
'Cannot serialize a view over part of an immutable ArrayBuffer. ' +
426+
'Serialize a view over buffer.sliceToImmutable(start, end) instead');
427+
}
428+
404429
function arrayBufferViewTypeToIndex(abView) {
405430
const type = ObjectPrototypeToString(abView);
406431
if (type === '[object Int8Array]') return 0;
@@ -467,11 +492,15 @@ class DefaultSerializer extends Serializer {
467492
`Unserializable host object: ${inspect(abView)}`);
468493
}
469494
}
495+
const { buffer, byteOffset, byteLength } = abView;
496+
if (isImmutableArrayBuffer(buffer)) {
497+
this.writeUint32(i | kNestedArrayBufferFlag);
498+
this.writeValue(immutableRange(this, buffer, byteOffset, byteLength));
499+
return;
500+
}
470501
this.writeUint32(i);
471-
this.writeUint32(abView.byteLength);
472-
this.writeRawBytes(new Uint8Array(abView.buffer,
473-
abView.byteOffset,
474-
abView.byteLength));
502+
this.writeUint32(byteLength);
503+
this.writeRawBytes(new Uint8Array(buffer, byteOffset, byteLength));
475504
}
476505
}
477506

@@ -483,20 +512,41 @@ class DefaultDeserializer extends Deserializer {
483512
*/
484513
_readHostObject() {
485514
const typeIndex = this.readUint32();
486-
const ctor = arrayBufferViewIndexToType(typeIndex);
515+
const ctor = arrayBufferViewIndexToType(typeIndex & kTypeIndexMask);
516+
if (ctor === undefined) {
517+
throw new ERR_INVALID_STATE(
518+
`unknown ArrayBufferView type index ${typeIndex}`);
519+
}
520+
const BYTES_PER_ELEMENT = ctor.BYTES_PER_ELEMENT || 1;
521+
522+
if ((typeIndex & kNestedArrayBufferFlag) !== 0) {
523+
const buffer = this.readValue();
524+
if (!isArrayBuffer(buffer)) {
525+
throw new ERR_INVALID_STATE(
526+
'expected an ArrayBuffer value for an ArrayBufferView');
527+
}
528+
const bufferLength = ArrayBufferPrototypeGetByteLength(buffer);
529+
if (bufferLength % BYTES_PER_ELEMENT !== 0) {
530+
throw new ERR_INVALID_STATE(
531+
`${bufferLength} bytes do not fit ${ctor.name}`);
532+
}
533+
return new ctor(buffer, 0, bufferLength / BYTES_PER_ELEMENT);
534+
}
535+
487536
const byteLength = this.readUint32();
488537
const byteOffset = this._readRawBytes(byteLength);
489-
const BYTES_PER_ELEMENT = ctor.BYTES_PER_ELEMENT || 1;
490538

491539
const offset = this.buffer.byteOffset + byteOffset;
492-
if (offset % BYTES_PER_ELEMENT === 0) {
540+
if (offset % BYTES_PER_ELEMENT === 0 &&
541+
!isImmutableArrayBuffer(this.buffer.buffer)) {
493542
return new ctor(this.buffer.buffer,
494543
offset,
495544
byteLength / BYTES_PER_ELEMENT);
496545
}
497-
// Copy to an aligned buffer first.
546+
// Copy to an aligned, mutable buffer first.
498547
const buffer_copy = Buffer.allocUnsafe(byteLength);
499-
buffer_copy.set(new Uint8Array(this.buffer.buffer, this.buffer.byteOffset + byteOffset, byteLength));
548+
TypedArrayPrototypeSet(buffer_copy,
549+
new Uint8Array(this.buffer.buffer, offset, byteLength));
500550
return new ctor(buffer_copy.buffer,
501551
buffer_copy.byteOffset,
502552
byteLength / BYTES_PER_ELEMENT);

‎src/node_serdes.cc‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,13 +461,22 @@ void DeserializerContext::ReadRawBytes(
461461
args.GetReturnValue().Set(offset);
462462
}
463463

464+
// Deserialized buffers can be immutable even when the flag-gated JS getter
465+
// is unavailable. The getter can also be replaced by user code.
466+
void IsImmutableArrayBuffer(const FunctionCallbackInfo<Value>& args) {
467+
args.GetReturnValue().Set(args[0]->IsArrayBuffer() &&
468+
args[0].As<ArrayBuffer>()->IsImmutable());
469+
}
470+
464471
void Initialize(Local<Object> target,
465472
Local<Value> unused,
466473
Local<Context> context,
467474
void* priv) {
468475
Environment* env = Environment::GetCurrent(context);
469476
Isolate* isolate = env->isolate();
470477

478+
SetMethod(context, target, "isImmutableArrayBuffer", IsImmutableArrayBuffer);
479+
471480
Local<FunctionTemplate> ser =
472481
NewFunctionTemplate(isolate, SerializerContext::New);
473482

@@ -523,6 +532,8 @@ void Initialize(Local<Object> target,
523532
}
524533

525534
void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
535+
registry->Register(IsImmutableArrayBuffer);
536+
526537
registry->Register(SerializerContext::New);
527538

528539
registry->Register(SerializerContext::WriteHeader);
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
// Flags: --js-immutable-arraybuffer
2+
'use strict';
3+
const common = require('../common');
4+
const assert = require('assert');
5+
const v8 = require('v8');
6+
7+
if (typeof ArrayBuffer.prototype.transferToImmutable !== 'function')
8+
common.skip('ArrayBuffer.prototype.transferToImmutable is not available');
9+
10+
function immutable(bytes) {
11+
const ab = new ArrayBuffer(bytes.length);
12+
new Uint8Array(ab).set(bytes);
13+
return ab.transferToImmutable();
14+
}
15+
16+
function bytesOf(view) {
17+
return [...new Uint8Array(view.buffer, view.byteOffset, view.byteLength)];
18+
}
19+
20+
const source = immutable([9, 9, 65, 66, 67, 68, 9, 9]);
21+
22+
// A whole immutable view keeps its type, its bytes and its immutability.
23+
{
24+
const cases = [
25+
[new Uint8Array(source), 1],
26+
[Buffer.from(source), 1],
27+
[new Uint16Array(source), 2],
28+
[new DataView(source), 1],
29+
[new Float64Array(source), 8],
30+
];
31+
for (const [view, bytesPerElement] of cases) {
32+
const result = v8.deserialize(v8.serialize(view));
33+
assert.strictEqual(result.constructor, view.constructor);
34+
assert.strictEqual(result.buffer.immutable, true);
35+
assert.strictEqual(result.byteOffset, 0);
36+
assert.strictEqual(result.byteLength, 8);
37+
assert.strictEqual(result.length ?? 8, 8 / bytesPerElement);
38+
assert.deepStrictEqual(bytesOf(result), [9, 9, 65, 66, 67, 68, 9, 9]);
39+
}
40+
}
41+
42+
// A partial view is refused, an explicit sliceToImmutable() copy serializes.
43+
{
44+
const secret = immutable([...Buffer.from('PREFIX|PUBLIC|SUFFIX')]);
45+
assert.throws(() => v8.serialize(new Uint8Array(secret, 7, 6)), {
46+
name: 'Error',
47+
message: /Serialize a view over buffer\.sliceToImmutable\(start, end\) instead/,
48+
});
49+
const serialized = v8.serialize(new Uint8Array(secret.sliceToImmutable(7, 13)));
50+
assert.strictEqual(Buffer.from(serialized).includes('PUBLIC'), true);
51+
assert.strictEqual(Buffer.from(serialized).includes('PREFIX'), false);
52+
assert.strictEqual(Buffer.from(serialized).includes('SUFFIX'), false);
53+
const result = v8.deserialize(serialized);
54+
assert.strictEqual(result.buffer.immutable, true);
55+
assert.deepStrictEqual(bytesOf(result), [...Buffer.from('PUBLIC')]);
56+
}
57+
58+
// A masked byteLength does not make a partial view look whole.
59+
{
60+
const buffer = immutable([...Buffer.from('PUBLIC|SECRET')]);
61+
const view = new Uint8Array(buffer, 0, 6);
62+
Object.defineProperty(buffer, 'byteLength', { value: 6 });
63+
assert.throws(() => v8.serialize(view), {
64+
message: /view over part of an immutable ArrayBuffer/,
65+
});
66+
}
67+
68+
// Views sharing a whole buffer still share it after the round trip.
69+
{
70+
const result = v8.deserialize(v8.serialize({
71+
first: new Uint8Array(source),
72+
second: new DataView(source),
73+
}));
74+
assert.strictEqual(result.first.buffer, result.second.buffer);
75+
assert.strictEqual(result.first.buffer.immutable, true);
76+
}
77+
78+
// Mutable and immutable views mix, with repeated references and a cycle.
79+
{
80+
const mutable = new Uint8Array([1, 2, 3]);
81+
const value = { mutable, immutable: new Uint8Array(source), again: mutable };
82+
value.self = value;
83+
const result = v8.deserialize(v8.serialize(value));
84+
assert.strictEqual(result.mutable.buffer.immutable, false);
85+
assert.strictEqual(result.immutable.buffer.immutable, true);
86+
assert.strictEqual(result.mutable, result.again);
87+
assert.strictEqual(result.self, result);
88+
}
89+
90+
// Only the mutable view keeps aliasing the transport bytes.
91+
{
92+
const serialized = v8.serialize({
93+
mutable: new Uint8Array([5, 5]),
94+
immutable: new Uint8Array(immutable([65, 66])),
95+
});
96+
const result = v8.deserialize(serialized);
97+
serialized.fill(0);
98+
assert.deepStrictEqual(bytesOf(result.immutable), [65, 66]);
99+
assert.deepStrictEqual(bytesOf(result.mutable), [0, 0]);
100+
}
101+
102+
// A mutable view read from an immutable transport comes back mutable.
103+
{
104+
const serialized = v8.serialize(new Uint8Array([7, 8]));
105+
const transport = new Uint8Array(immutable([...serialized]));
106+
const result = v8.deserialize(transport);
107+
assert.strictEqual(result.buffer.immutable, false);
108+
assert.deepStrictEqual(bytesOf(result), [7, 8]);
109+
}
110+
111+
// SharedArrayBuffer views and transport keep working.
112+
{
113+
const shared = new SharedArrayBuffer(4);
114+
new Uint8Array(shared).set([1, 2, 3, 4]);
115+
for (const view of [new Uint8Array(shared), new DataView(shared), Buffer.from(shared)]) {
116+
const result = v8.deserialize(v8.serialize(view));
117+
assert.strictEqual(result.constructor, view.constructor);
118+
assert.deepStrictEqual(bytesOf(result), [1, 2, 3, 4]);
119+
}
120+
const serialized = v8.serialize(new Uint8Array([6, 7]));
121+
const transport = new Uint8Array(new SharedArrayBuffer(serialized.byteLength));
122+
transport.set(serialized);
123+
assert.deepStrictEqual(bytesOf(v8.deserialize(transport)), [6, 7]);
124+
}
125+
126+
// Malformed records are rejected with ERR_INVALID_STATE.
127+
{
128+
class BadSerializer extends v8.Serializer {
129+
constructor(write) {
130+
super();
131+
this._setTreatArrayBufferViewsAsHostObjects(true);
132+
this._writeHostObject = write;
133+
}
134+
}
135+
const serializeWith = (write) => {
136+
const serializer = new BadSerializer(write);
137+
serializer.writeHeader();
138+
serializer.writeValue(new Uint8Array(1));
139+
return serializer.releaseBuffer();
140+
};
141+
const unknownIndex = serializeWith(function() {
142+
this.writeUint32(14);
143+
this.writeUint32(0);
144+
});
145+
assert.throws(() => v8.deserialize(unknownIndex), {
146+
code: 'ERR_INVALID_STATE',
147+
message: /unknown ArrayBufferView type index 14/,
148+
});
149+
const notABuffer = serializeWith(function() {
150+
this.writeUint32(1 | 0x80000000);
151+
this.writeValue(4);
152+
});
153+
assert.throws(() => v8.deserialize(notABuffer), {
154+
code: 'ERR_INVALID_STATE',
155+
message: /expected an ArrayBuffer value/,
156+
});
157+
const wrongLength = serializeWith(function() {
158+
this.writeUint32(4 | 0x80000000);
159+
this.writeValue(immutable([1, 2, 3]));
160+
});
161+
assert.throws(() => v8.deserialize(wrongLength), {
162+
code: 'ERR_INVALID_STATE',
163+
message: /3 bytes do not fit Uint16Array/,
164+
});
165+
}
166+
167+
// Symbol.species is not consulted, aligned or not.
168+
{
169+
const serialized = v8.serialize(new Uint16Array([257, 514]));
170+
Object.defineProperty(Uint8Array, Symbol.species, {
171+
configurable: true,
172+
get: common.mustNotCall('Symbol.species must not be consulted'),
173+
});
174+
for (const shift of [0, 1]) {
175+
const transport = new Uint8Array(serialized.byteLength + shift);
176+
transport.set(serialized, shift);
177+
const result = v8.deserialize(
178+
new Uint8Array(transport.buffer, shift, serialized.byteLength));
179+
assert.deepStrictEqual([...result], [257, 514]);
180+
}
181+
delete Uint8Array[Symbol.species];
182+
}
183+
184+
// Without the flag, an immutable view read from a record serializes again.
185+
{
186+
const { spawnSync } = require('child_process');
187+
const record = v8.serialize(new Uint8Array(source)).toString('hex');
188+
const child = spawnSync(process.execPath, ['-e', `
189+
const v8 = require('v8');
190+
const whole = v8.deserialize(Buffer.from('${record}', 'hex'));
191+
whole[0] = 0;
192+
if (whole[0] === 0) throw new Error('expected an immutable buffer');
193+
const again = v8.deserialize(v8.serialize(whole));
194+
again[0] = 0;
195+
if (again[0] === 0) throw new Error('expected an immutable buffer again');
196+
console.log(String.fromCharCode(...again.subarray(2, 6)));
197+
`], { encoding: 'utf8' });
198+
assert.strictEqual(child.status, 0, child.stderr);
199+
assert.strictEqual(child.stdout.trim(), 'ABCD');
200+
}

0 commit comments

Comments
 (0)