From 4d3de078bfb4ec7149fd333c68e856fa4464701d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 17:14:45 +0000 Subject: [PATCH 1/4] Keep scanning in indexOf() when a resizable buffer shrank When the fromIndex argument shrinks the underlying resizable ArrayBuffer from its valueOf(), %TypedArray%.prototype.indexOf() gives up and returns -1 even though the element it is looking for is still inside the surviving prefix: const rab = new ArrayBuffer(8, {maxByteLength: 8}); const ta = new Int8Array(rab); for (let i = 0; i < 8; i++) ta[i] = i; ta.indexOf(2, {valueOf() { rab.resize(4); return 0; }}); // -1, want 2 lastIndexOf() already survives this: it falls through to the clamped scan below. indexOf() and includes() do not, because the early-out branch is entered for every `special` whenever len exceeds the current element count. Restrict that branch to includes(), which genuinely cannot use the clamped scan (it reports "undefined" for the indices that vanished, so it has to reason about the original length). indexOf() then reaches the same clamped scan as lastIndexOf(): len, k and stop are pinned to the new length, so the scan stays in bounds and finds the elements that are still there. Detached buffers are unaffected: typed_array_is_oob() still takes every `special` down the early-out path. Not addressed here: includes() still misses a value that survived the shrink (`ta.includes(2, evil)` is false above, V8 says true). Fixing that means scanning the clamped prefix *and* reporting a match for undefined against the truncated tail, which is a larger change than this one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014mv33YvfHz7t9mmkituBnn --- quickjs.c | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/quickjs.c b/quickjs.c index bbac00c33..acc88caa0 100644 --- a/quickjs.c +++ b/quickjs.c @@ -60617,8 +60617,13 @@ static JSValue js_typed_array_indexOf(JSContext *ctx, JSValueConst this_val, } /* if the array was detached, no need to go further (but no - exception is raised) */ - if (typed_array_is_oob(p) || len > p->u.array.count) { + exception is raised). "includes" alone also bails out here (rather + than scanning the clamped range below) when the buffer has merely + shrunk: it scans up to the *original* length and reads "undefined" + for any now out-of-bounds index, which the clamped scan cannot + express. */ + if (typed_array_is_oob(p) || + (special == special_includes && len > p->u.array.count)) { /* "includes" scans all the properties, so "undefined" can match */ if (special == special_includes) { if (JS_IsUndefined(argv[0])) From 94d13d56b3743d3e24617753aec91db5e4859800 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 08:13:43 +0000 Subject: [PATCH 2/4] Add a test for indexOf() over a shrunk resizable buffer Covers the scan that %TypedArray%.prototype.indexOf() now performs when the fromIndex coercion shrinks the underlying resizable ArrayBuffer: elements that survive in the clamped prefix are found, the ones that vanished with the truncated tail are not, and fromIndex keeps resolving against the length read before the coercion. Every element type gets a case because each scan path is written out separately (uint8 goes through memchr), plus a length tracking view at an offset, a shrink to zero, a fixed length view that goes out of bounds, and a detached buffer -- the last two still return -1 without raising. A growing buffer is checked too: the scan must not run past the length that was read before the coercion. The includes() cases pin down the branch the fix leaves in place, where the indices the shrink removed still read as undefined. Without the fix the first case already fails with -1 instead of 2. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Kgm5WqGGFTKvG6vEsbUbvc --- tests/typedarray-indexof-shrink.js | 176 +++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 tests/typedarray-indexof-shrink.js diff --git a/tests/typedarray-indexof-shrink.js b/tests/typedarray-indexof-shrink.js new file mode 100644 index 000000000..756998906 --- /dev/null +++ b/tests/typedarray-indexof-shrink.js @@ -0,0 +1,176 @@ +import { assert } from "./assert.js"; + +/* %TypedArray%.prototype.indexOf: the length is read before the fromIndex + argument is coerced. When the coercion shrinks a resizable buffer the + upward scan continues in the still valid prefix instead of returning -1; + the indices that vanished simply cannot match. */ + +{ + const cases = [ + /* [fromIndex, searchElement, expected] */ + [0, 2, 2], /* still inside the surviving prefix */ + [0, 6, -1], /* vanished with the truncated tail */ + [3, 3, 3], /* fromIndex inside the surviving prefix */ + [3, 1, -1], /* present, but before fromIndex */ + [5, 1, -1], /* fromIndex beyond the new length */ + [-8, 1, 1], /* negative: resolved against the old length */ + [-2, 1, -1], /* ... which puts it past the new end */ + ]; + + for (const [index, search, expected] of cases) { + const rab = new ArrayBuffer(8, { maxByteLength: 8 }); + const ta = new Int8Array(rab); + for (let i = 0; i < ta.length; i++) + ta[i] = i; + const evil = { + valueOf() { + rab.resize(4); + return index; + } + }; + assert(ta.indexOf(search, evil), expected, `indexOf(${search}, ${index})`); + } +} + +/* every element type takes its own scan path; uint8 uses memchr */ +{ + const types = [ + Int8Array, Uint8Array, Uint8ClampedArray, Int16Array, Uint16Array, + Int32Array, Uint32Array, Float16Array, Float32Array, Float64Array, + ]; + + for (const Ctor of types) { + const bytes = 8 * Ctor.BYTES_PER_ELEMENT; + const rab = new ArrayBuffer(bytes, { maxByteLength: bytes }); + const ta = new Ctor(rab); + for (let i = 0; i < ta.length; i++) + ta[i] = i; + const evil = { + valueOf() { + rab.resize(bytes / 2); + return 0; + } + }; + assert(ta.indexOf(3, evil), 3, Ctor.name); + } + + for (const Ctor of [BigInt64Array, BigUint64Array]) { + const rab = new ArrayBuffer(64, { maxByteLength: 64 }); + const ta = new Ctor(rab); + for (let i = 0; i < ta.length; i++) + ta[i] = BigInt(i); + const evil = { + valueOf() { + rab.resize(32); + return 0; + } + }; + assert(ta.indexOf(3n, evil), 3, Ctor.name); + assert(ta.indexOf(5n, evil), -1, Ctor.name); + } +} + +/* a length tracking view at an offset shrinks along with the buffer */ +{ + const rab = new ArrayBuffer(8, { maxByteLength: 8 }); + const ta = new Int8Array(rab, 2); + for (let i = 0; i < ta.length; i++) + ta[i] = i; + assert(ta.length, 6); + const evil = { + valueOf() { + rab.resize(4); + return 0; + } + }; + assert(ta.indexOf(1, evil), 1); + assert(ta.indexOf(3, evil), -1); +} + +/* shrink to zero returns -1 */ +{ + const rab = new ArrayBuffer(16, { maxByteLength: 16 }); + const ta = new Int32Array(rab); + ta.fill(9); + const evil = { + valueOf() { + rab.resize(0); + return 0; + } + }; + assert(ta.indexOf(9, evil), -1); +} + +/* a fixed length view that goes out of bounds returns -1, no exception */ +{ + const rab = new ArrayBuffer(16, { maxByteLength: 16 }); + const ta = new Int32Array(rab, 0, 4); + ta.fill(5); + const evil = { + valueOf() { + rab.resize(8); + return 0; + } + }; + assert(ta.indexOf(5, evil), -1); +} + +/* a detached buffer returns -1, no exception */ +{ + const rab = new ArrayBuffer(8, { maxByteLength: 8 }); + const ta = new Int8Array(rab); + ta.fill(3); + const evil = { + valueOf() { + rab.transfer(); + return 0; + } + }; + assert(ta.indexOf(3, evil), -1); +} + +/* growing during the coercion does not extend the scan: the elements added + past the original length are not searched */ +{ + const rab = new ArrayBuffer(4, { maxByteLength: 8 }); + const ta = new Int8Array(rab); + for (let i = 0; i < ta.length; i++) + ta[i] = i + 1; + const evil = { + valueOf() { + rab.resize(8); + return 0; + } + }; + assert(ta.indexOf(0, evil), -1); + assert(ta.indexOf(2, evil), 1); +} + +/* includes() keeps reading "undefined" for the indices the shrink removed, + so it still matches undefined over the truncated tail */ +{ + for (const size of [4, 0]) { + const rab = new ArrayBuffer(8, { maxByteLength: 8 }); + const ta = new Int8Array(rab); + for (let i = 0; i < ta.length; i++) + ta[i] = i; + const evil = { + valueOf() { + rab.resize(size); + return 0; + } + }; + assert(ta.includes(undefined, evil), true, `includes(undefined) @ ${size}`); + } + + /* ... but not once the scan starts past the original length */ + const rab = new ArrayBuffer(8, { maxByteLength: 8 }); + const ta = new Int8Array(rab); + const evil = { + valueOf() { + rab.resize(4); + return 8; + } + }; + assert(ta.includes(undefined, evil), false); +} From c0b1a4852e99c2f37742849633f22a9ba4df2c7f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 09:35:55 +0000 Subject: [PATCH 3/4] Extend the indexOf() shrink test to its neighbours The changed bail-out is shared by lastIndexOf() and includes(), so pin what each of the three does with the same shrink: lastIndexOf scans downward from a start clamped into the surviving prefix, including for a negative fromIndex resolved against the original length. Also covers the two things that are easy to lose in a bail-out path: indexOf compares with strict equality while includes uses SameValueZero, which only shows up for NaN and -0; and a fromIndex coercion that throws propagates, unlike a resize, while a view that is already out of bounds on entry still throws for all three. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K6eRbuuuCujKgQkrHgvMrc --- tests/typedarray-indexof-shrink.js | 127 +++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/tests/typedarray-indexof-shrink.js b/tests/typedarray-indexof-shrink.js index 756998906..87e026098 100644 --- a/tests/typedarray-indexof-shrink.js +++ b/tests/typedarray-indexof-shrink.js @@ -174,3 +174,130 @@ import { assert } from "./assert.js"; }; assert(ta.includes(undefined, evil), false); } + +/* lastIndexOf scans downward and clamps its start to the surviving prefix, + so the same shrink lands it in a different place than indexOf */ +{ + function shrunk(ret) { + const rab = new ArrayBuffer(8, { maxByteLength: 8 }); + const ta = new Int8Array(rab); + for (let i = 0; i < 8; i++) + ta[i] = i; + return [ta, { valueOf() { rab.resize(4); return ret; } }]; + } + + /* fromIndex 7 is clamped to the new last index */ + { + const [ta, evil] = shrunk(7); + assert(ta.lastIndexOf(3, evil), 3); + } + { + const [ta, evil] = shrunk(7); + assert(ta.lastIndexOf(6, evil), -1); /* vanished with the tail */ + } + /* fromIndex 0 only ever looks at index 0 */ + { + const [ta, evil] = shrunk(0); + assert(ta.lastIndexOf(0, evil), 0); + } + { + const [ta, evil] = shrunk(0); + assert(ta.lastIndexOf(2, evil), -1); + } + /* a negative fromIndex resolves against the original length */ + { + const [ta, evil] = shrunk(-6); + assert(ta.lastIndexOf(2, evil), 2); + } + { + const [ta, evil] = shrunk(-8); + assert(ta.lastIndexOf(1, evil), -1); + } +} + +/* indexOf uses strict equality and includes uses SameValueZero, which is + only visible for NaN and -0 */ +{ + const t = new Float64Array([NaN, 0, 1]); + assert(t.indexOf(NaN), -1); + assert(t.includes(NaN), true); + assert(t.indexOf(-0), 1); + assert(t.includes(-0), true); + assert(t.indexOf(0), 1); + + const f16 = new Float16Array([NaN, 1]); + assert(f16.indexOf(NaN), -1); + assert(f16.includes(NaN), true); + + /* an integer array can never hold either, so neither ever matches */ + const i = new Int32Array([0, 1]); + assert(i.indexOf(NaN), -1); + assert(i.includes(NaN), false); + assert(i.indexOf(-0), 0); + + /* the same, with a shrink in between */ + const rab = new ArrayBuffer(32, { maxByteLength: 32 }); + const ta = new Float64Array(rab); + ta[0] = NaN; + ta[3] = NaN; + const evil = { valueOf() { rab.resize(16); return 0; } }; + assert(ta.indexOf(NaN, evil), -1); +} + +/* the fromIndex coercion is still an ordinary ToInteger: its failures + propagate rather than being swallowed like a resize */ +{ + const ta = new Int8Array(4); + + let threw = null; + try { + ta.indexOf(0, Symbol("s")); + } catch (e) { + threw = e; + } + assert(threw instanceof TypeError, true); + + threw = null; + try { + ta.indexOf(0, { valueOf() { throw new RangeError("boom"); } }); + } catch (e) { + threw = e; + } + assert(threw instanceof RangeError, true); + assert(threw.message, "boom"); + + threw = null; + try { + ta.lastIndexOf(0, { valueOf() { throw new RangeError("boom"); } }); + } catch (e) { + threw = e; + } + assert(threw instanceof RangeError, true); + + threw = null; + try { + ta.includes(0, { valueOf() { throw new RangeError("boom"); } }); + } catch (e) { + threw = e; + } + assert(threw instanceof RangeError, true); +} + +/* a view that is already out of bounds when the call starts throws, unlike + one that goes out of bounds during the coercion */ +{ + const rab = new ArrayBuffer(16, { maxByteLength: 16 }); + const ta = new Int32Array(rab, 0, 4); + rab.resize(8); + + for (const call of [() => ta.indexOf(1), () => ta.lastIndexOf(1), + () => ta.includes(1)]) { + let threw = null; + try { + call(); + } catch (e) { + threw = e; + } + assert(threw instanceof TypeError, true); + } +} From fb2f38cd2dc55d73b3d01375547ef275e44d0281 Mon Sep 17 00:00:00 2001 From: Andreas Rosdal Date: Fri, 7 Aug 2026 12:45:26 +0000 Subject: [PATCH 4/4] Drop the lastIndexOf branch the shrink fix made unreachable Now that only "includes" bails out of a merely shrunk buffer, everything that reaches the tail of that block has typed_array_is_oob(p) true, so the lastIndexOf test and its comment describe a path that can no longer be taken. Fold the block into the single goto it always performs. --- quickjs.c | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/quickjs.c b/quickjs.c index acc88caa0..9104053ed 100644 --- a/quickjs.c +++ b/quickjs.c @@ -60625,17 +60625,10 @@ static JSValue js_typed_array_indexOf(JSContext *ctx, JSValueConst this_val, if (typed_array_is_oob(p) || (special == special_includes && len > p->u.array.count)) { /* "includes" scans all the properties, so "undefined" can match */ - if (special == special_includes) { - if (JS_IsUndefined(argv[0])) - if (k < typed_array_length(p)) - res = 0; - goto done; - } - /* lastIndexOf scans downward, so when the buffer merely shrank - during argument coercion the scan continues in the still - valid range; the vanished indices simply cannot match */ - if (special != special_lastIndexOf || typed_array_is_oob(p)) - goto done; + if (special == special_includes && JS_IsUndefined(argv[0]) && + k < typed_array_length(p)) + res = 0; + goto done; } // RAB may have been resized by evil .valueOf method