From 22b158758f95b72efdb19e96d685a9f7f658e2ce Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Mon, 5 Oct 2026 11:38:10 +0300 Subject: [PATCH] Backport #4157 to 4.x: Fix TypedArray.prototype.with after a shrinking coercion Adapted from 431590d25132d780b2854ee33e03ae276b498759 (engine fix and regression tests only; the test262 pin is not bumped). %TypedArray%.prototype.with bulk-copied the length read before coercing the index and the value, so a coercion that shrank a resizable buffer made Array.Copy read past the reallocated block and a CLR ArgumentException escaped into the host. It now copies only the elements the source still has and writes undefined through TypedArraySetElement for the rest (NaN, +0, or a TypeError for BigInt). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Runtime/TypedArrayWithShrinkTests.cs | 125 ++++++++++++++++++ .../IntrinsicTypedArrayPrototype.cs | 16 ++- 2 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 Jint.Tests/Runtime/TypedArrayWithShrinkTests.cs diff --git a/Jint.Tests/Runtime/TypedArrayWithShrinkTests.cs b/Jint.Tests/Runtime/TypedArrayWithShrinkTests.cs new file mode 100644 index 0000000000..2e3c71efa9 --- /dev/null +++ b/Jint.Tests/Runtime/TypedArrayWithShrinkTests.cs @@ -0,0 +1,125 @@ +namespace Jint.Tests.Runtime; + +/// +/// %TypedArray%.prototype.with +/// reads the length before it coerces the index and the value, and either coercion can run script that shrinks +/// a resizable buffer. Every element past the new length then reads as undefined and +/// TypedArraySetElement converts it: NaN in a float array, zero in an integer one, and a +/// TypeError in a BigInt one (tc39/ecma262#3979, tc39/test262#5136). Jint bulk-copied the original +/// length's worth of bytes out of the reallocated, shorter buffer, so a CLR ArgumentException escaped +/// into the host instead. +/// +public class TypedArrayWithShrinkTests +{ + private readonly Engine _engine = new(); + + private static readonly bool SupportsHalf = Type.GetType("System.Half") is not null; + + // Script-side catch, so a CLR exception leaking out of the engine fails the test instead of passing as an error. + private string Run(string constructor, string one, string body) => _engine.Evaluate($$""" + (function () { + var TA = {{constructor}}; + var buffer = new ArrayBuffer(3 * TA.BYTES_PER_ELEMENT, { maxByteLength: 3 * TA.BYTES_PER_ELEMENT }); + var sample = new TA(buffer); + sample[0] = {{one}}; + sample[1] = {{one}} + {{one}}; + sample[2] = {{one}} + {{one}} + {{one}}; + var calls = 0; + function shrink() { calls++; buffer.resize(TA.BYTES_PER_ELEMENT); } + try { + {{body}} + } catch (e) { + return e.constructor.name + ' calls=' + calls + ' source=' + Array.prototype.join.call(sample, ','); + } + })() + """).AsString(); + + [Theory] + [InlineData("Int8Array", "0", "index")] + [InlineData("Uint8Array", "0", "index")] + [InlineData("Uint8ClampedArray", "0", "index")] + [InlineData("Int16Array", "0", "index")] + [InlineData("Uint16Array", "0", "index")] + [InlineData("Int32Array", "0", "index")] + [InlineData("Uint32Array", "0", "index")] + [InlineData("Float32Array", "NaN", "index")] + [InlineData("Float64Array", "NaN", "index")] + [InlineData("Int8Array", "0", "value")] + [InlineData("Uint8Array", "0", "value")] + [InlineData("Uint8ClampedArray", "0", "value")] + [InlineData("Int16Array", "0", "value")] + [InlineData("Uint16Array", "0", "value")] + [InlineData("Int32Array", "0", "value")] + [InlineData("Uint32Array", "0", "value")] + [InlineData("Float32Array", "NaN", "value")] + [InlineData("Float64Array", "NaN", "value")] + public void CoercionThatShrinksConvertsTheMissingElements(string constructor, string missing, string coerced) + { + var call = coerced == "index" + ? "sample.with({ valueOf: function () { shrink(); return 0; } }, 9)" + : "sample.with(0, { valueOf: function () { shrink(); return 9; } })"; + + Run(constructor, "1", "var result = " + call + """ + ; + return Array.prototype.join.call(result, ',') + ' calls=' + calls + ' source=' + Array.prototype.join.call(sample, ','); + """).Should().Be($"9,{missing},{missing} calls=1 source=1"); + } + + [Theory] + [InlineData("index")] + [InlineData("value")] + public void AFloat16ArrayThatShrinksConvertsTheMissingElementsToNaN(string coerced) + { + if (!SupportsHalf) + { + return; + } + + var call = coerced == "index" + ? "sample.with({ valueOf: function () { shrink(); return 0; } }, 9)" + : "sample.with(0, { valueOf: function () { shrink(); return 9; } })"; + + Run("Float16Array", "1", "var result = " + call + """ + ; + return Array.prototype.join.call(result, ',') + ' calls=' + calls + ' source=' + Array.prototype.join.call(sample, ','); + """).Should().Be("9,NaN,NaN calls=1 source=1"); + } + + [Theory] + [InlineData("BigInt64Array", "index")] + [InlineData("BigInt64Array", "value")] + [InlineData("BigUint64Array", "index")] + [InlineData("BigUint64Array", "value")] + public void ABigIntArrayThatShrinksIsATypeError(string constructor, string coerced) + { + var call = coerced == "index" + ? "sample.with({ valueOf: function () { shrink(); return 0; } }, 9n)" + : "sample.with(0, { valueOf: function () { shrink(); return 9n; } })"; + + Run(constructor, "1n", call + "; return 'did not throw';") + .Should().Be("TypeError calls=1 source=1"); + } + + [Theory] + [InlineData("Int32Array", "1", "3")] + [InlineData("Float64Array", "1", "3")] + [InlineData("BigInt64Array", "1n", "3n")] + public void ANegativeIndexShrunkOutOfBoundsIsARangeError(string constructor, string one, string three) + { + Run(constructor, one, $"sample.with(-2, {{ valueOf: function () {{ shrink(); return {three}; }} }}); return 'did not throw';") + .Should().Be($"RangeError calls=1 source={one.TrimEnd('n')}"); + } + + [Fact] + public void AnUnshrunkBufferCopiesEveryElement() + { + _engine.Evaluate(""" + (function () { + var buffer = new ArrayBuffer(16, { maxByteLength: 32 }); + var sample = new Int32Array(buffer, 4, 3); + sample.set([1, 2, 3]); + return Array.prototype.join.call(sample.with(-1, 9), ','); + })() + """).AsString().Should().Be("1,2,9"); + } +} diff --git a/Jint/Native/TypedArray/IntrinsicTypedArrayPrototype.cs b/Jint/Native/TypedArray/IntrinsicTypedArrayPrototype.cs index ee0f71e882..2f9afc067b 100644 --- a/Jint/Native/TypedArray/IntrinsicTypedArrayPrototype.cs +++ b/Jint/Native/TypedArray/IntrinsicTypedArrayPrototype.cs @@ -1671,11 +1671,25 @@ private ObjectInstance With(JsValue thisObject, JsValue indexArg, JsValue value) // The result has the same element type, so bulk-copy the source bytes into the freshly created target // and then overwrite the single replaced element (whose value is already coerced above). + // Only the elements O still has are copied: the coercions above may have shrunk its buffer. var elementSize = o._arrayElementType.GetElementSize(); + var present = System.Math.Min(len, o.GetLength()); _engine.Constraints.Check(); - System.Array.Copy(o._viewedArrayBuffer._arrayBufferData!, o._byteOffset, a._viewedArrayBuffer._arrayBufferData!, a._byteOffset, len * elementSize); + System.Array.Copy(o._viewedArrayBuffer._arrayBufferData!, o._byteOffset, a._viewedArrayBuffer._arrayBufferData!, a._byteOffset, present * elementSize); a[(int) actualIndex] = value; + // Elements past O's current length read as undefined and are converted by TypedArraySetElement: + // NaN for floats, +0 for integers, a TypeError for BigInt (tc39/ecma262#3979). + for (var k = present; k < len; k++) + { + if (k % ConstraintCheckInterval == 0) + { + _engine.Constraints.Check(); + } + + a[k] = Undefined; + } + return a; }