Skip to content

Interop: an index outside a wrapped collection is refused, not handed to the collection - #3472

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:interop-wrapped-index-bounds
Aug 27, 2026
Merged

lahma merged 1 commit into
sebastienros:mainfrom
lahma:interop-wrapped-index-bounds

Conversation

@lahma

@lahma lahma commented Aug 27, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #3422.

A plain ObjectWrapper over a bounded CLR collection hands an out-of-range index straight to the host. The
target is an ordinary embedder shape — a window with a Count and an int this[int], exposed under its own
type — and nothing about it is exotic or AOT-only:

sealed class Window : IReadOnlyCollection<int>
{
    public int this[int index] { get => _items[index]; set => _items[index] = value; }
    public int Count => _items.Count;
    // ...
}
// main, three elements, options.Interop.AllowWrite = true
x[3]                    // ArgumentOutOfRangeException out of Evaluate — a read
x["3"]                  // ArgumentOutOfRangeException
x[3] = 9                // ArgumentOutOfRangeException
x["3"] = 9              // ArgumentOutOfRangeException
x[-1] = 9               // ArgumentOutOfRangeException
x["08"] = 9             // ArgumentOutOfRangeException
Reflect.set(x, 3, 9)    // ArgumentOutOfRangeException
3 in x                  // true — for a position that cannot even be read

None of those is a JavaScriptException, so neither a script try/catch nor a host
catch (JavaScriptException) sees them. #3384 gave an ArrayLikeWrapper ownership of every index-shaped key,
but the refusal lives in the array-like view and this shape does not get one: the target is not an
IList<T>, not an IReadOnlyList<T> and not a non-generic IList, so it is wrapped plainly and every
index-shaped key resolves the reflected indexer, which takes whatever index it parsed out of the key to the
collection.

The guard cannot go where #3384 put its, because a plain wrapper's indexer lane also serves
Dictionary<int, string>, where d[99] = "x" is a legitimate add. So it is conditioned on two facts at
once: the type descriptor reports array-like (a Count, and not a dictionary), and the member the wrapper
is about to resolve is an integer-keyed indexer. A key that is index-shaped and outside [0, Count) — or
index-shaped and never addressable at all, -1, "08", "+3" — is then answered by the wrapper:

// this PR
x[3]                    // undefined
x["3"]                  // undefined
x[3] = 9                // sloppy: silently ignored;  strict: TypeError
3 in x                  // false
x.hasOwnProperty(3)     // false
delete x[3]             // true — there was nothing to delete

x[99] = 'a' on a Dictionary<int, string> still adds, and a string-keyed indexer on an array-like target —
a NameValueCollection asked for x["3"] — still answers for its own key, because the check reads the
resolved accessor's index parameter rather than guessing from the key. Positions the collection does have,
and every named CLR member, are untouched.

ElementKey / ClassifyElementKey move from ArrayLikeWrapper up to ObjectWrapper, unchanged. One
definition of "index-shaped key" is the point of #3384; a second copy here is the thing that would drift.

Proof

Jint.Tests.PublicInterface/HostCollectionIndexBoundsTests.cs is new. Against unmodified main, 28 of its
35 cases fail
— seven keys (3, "3", 10, -1, "-1", "08", "+3") across four questions:

Failed ReadingAnIndexTheCollectionCannotAddressAnswersUndefined(x7)
    System.ArgumentOutOfRangeException : Index was out of range. Must be non-negative and
    less than the size of the collection. (Parameter 'index')

Failed WritingAnIndexTheCollectionCannotAddressIsTheOrdinarySetRefusal(x7)
    same exception, out of engine.Execute("x[3] = 9")

Failed EveryExistenceLaneAgreesThatSuchAnIndexIsAbsent(x7)
    Expected ... to be the same string because 3 is not a position of the collection,
    so no lane may report it as one, but they differ at index 0

Failed DeletingSuchAnIndexSucceedsWithoutTouchingTheCollection(x7)
    Expected boolean to be True because OrdinaryDelete returns true for a property that
    is not there, but found False

Failed!  - Failed: 28, Passed: 7, Skipped: 0, Total: 35

The seven that pass on main are the contrast cases and still pass: the four in-range positions, the named
CLR members, the integer-keyed Dictionary that must keep adding, and the string-keyed indexer that must keep
answering for its own key.

docs/v5-migration.md §4.56 carries the embedder-facing form, and Jint/Runtime/Interop/AGENTS.md gains the
rule that #3384, #3423 and this one share.

Merge after #3464 (the ArrayLikeWrapper half of the same invariant): the two touch neighbouring code in
the same two files, and this one needs a rebase once that lands.

… to the collection

Fixes sebastienros#3422.

A plain ObjectWrapper over a bounded CLR collection - a host type with a Count and an
`int this[int]`, and none of the three interfaces that would give it an array-like view -
took every index-shaped key to the reflected indexer, which hands the index straight to the
collection. `x[3]`, `x["3"] = 9`, `x[-1] = 9` and `Reflect.set(x, 3, 9)` were all the CLR's
own ArgumentOutOfRangeException out of Evaluate, invisible to a script try/catch and to a
host catch (JavaScriptException) alike, while `3 in x` answered true for a position that
could not be read at all.

here is conditioned on two facts at once, because the same lane serves shapes where an
out-of-range key is the point: the target must be array-like (a Count, and not a dictionary,
so `d[99] = "x"` on a Dictionary<int, string> still adds), and the member being resolved must
be an integer-keyed indexer (so a string-keyed indexer on a collection still answers for its
own key). Such a key now reads undefined, writes as the ordinary [[Set]] refusal, is absent
from `in` and hasOwnProperty, and deletes as true.

ElementKey/ClassifyElementKey move from ArrayLikeWrapper up to ObjectWrapper unchanged: one
definition of "index-shaped key" is the point of sebastienros#3384, and a second copy would drift.

docs/v5-migration.md §4.56 carries the embedder-facing form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
@lahma
lahma force-pushed the interop-wrapped-index-bounds branch from 76248eb to b88f757 Compare August 27, 2026 20:05
@lahma
lahma merged commit 078bcdd into sebastienros:main Aug 27, 2026
7 checks passed
lahma added a commit to lahma/jint that referenced this pull request Sep 1, 2026
… filter that hides the indexer hides it

Backport of eight main pull requests that together decide one question — what an index-shaped key means on a
wrapped CLR collection — plus the fix for the containment hole the seventh of them opened. They are one unit:
each of the first seven moves the answer, and taking any of them alone leaves the lanes disagreeing with each
other.

  sebastienros#3356  a host collection with a count is not a host collection with an index
  sebastienros#3381  a degraded array view still refuses a resize
  sebastienros#3425  the IndexWrappedOperations lane is not AOT-only, and a generic that must grow refuses
  sebastienros#3416  an index on a host collection is one property, however script spelled it
  sebastienros#3464  hasOwnProperty and "in" give one answer about an index on a wrapped collection
  sebastienros#3472  an index outside a wrapped collection is refused, not handed to the collection
  sebastienros#3480  a collection exposed as IList<T> or IReadOnlyList<T> gets the wrapper that contract names
  sebastienros#3561  a member filter that hides an indexer hides a wrapped collection's elements (fixes sebastienros#3558)

sebastienros#3385 - a read-only host collection refuses script with a JavaScript error - is the ninth member of the
cluster and is already on this branch as sebastienros#3556, so its hunks are not here. Its suite,
HostReadOnlyCollectionTests, is 68 of 68 green both before and after this change, which is what says so.

What script sees. An index-shaped key is now the view's own property, whichever way it is spelled and whether
or not the position exists. A read outside the range is undefined rather than the collection's own
ArgumentOutOfRangeException out of Evaluate; a write at the end grows a growable target exactly as a "length"
write of the same size does; "in", hasOwnProperty, propertyIsEnumerable and getOwnPropertyDescriptor give one
answer, because OrdinaryHasProperty is defined in terms of [[GetOwnProperty]] and may not disagree with it; a
delete of an absent position succeeds without reaching the collection; and a countable-but-not-indexable
target - Queue<T>, Stack<T>, LinkedList<T>, SortedSet<T> - is array-like with no element at index 0 rather
than an InvalidCastException from a lane that cast it to IList.

The containment half is why sebastienros#3561 is in the same change. Options.Interop.TypeResolver.MemberFilter is how a
host says which members script may reach, and an ArrayLikeWrapper answers every index-shaped key itself, so
the filter's decision about the indexer never reached the element lanes. On this branch that matters more than
it does on main: Interop.AllowWrite ships on here, so a filter that hid the indexer stopped nothing. Measured
on this branch, with the cluster applied and sebastienros#3561 held back, three refusals had become writes
(list[0] = 42, list['0'] = 42 and growth list[3] = 42) and reads, "in", delete, push and sort had never been
covered at all - and the pre-existing
HostIndexerFilterTests.AMemberFilterExcludingTheIndexerBlocksIndexedWrites, which passes on stock 4.x, fails.
The whole element contract is closed rather than only the write half, and containment is asked before the
read-only and fixed-size refusals of sebastienros#3382/sebastienros#3385 so the two compose: a fixed-size array whose indexer is
hidden reports "no such property" rather than the TypeError naming its bounds, which would answer a question
the host never granted.

Evidence, on net10.0 and net472 alike (identical counts on both). Against stock 4.x with the suites in place:
HostNonIndexedCollectionTests 33 of 45 failed, HostExposedCollectionTypeTests 21 of 33,
HostCollectionIndexWriteTests 56 of 63, HostCollectionIndexAgreementTests 11 of 18,
HostCollectionIndexBoundsTests 28 of 35, HostIndexerFilterTests 13 of 18, and 6 of the 7 new
InteropTests.ClrArrayLiveView cases. All of them pass now. The containment tests run in both write
configurations, because on this branch the default is the interesting one: the elements leak under
AllowWrite = true and the reads leak under AllowWrite = false, and both are pinned.

Deliberate divergences from main. sebastienros#3054 - which is what makes Interop.AllowWrite default to false there - is
a v5 default change and stays out, so this branch keeps its Delete and ArrayOperations.Set guards and the two
suites spell the write switch out where main could leave it to the default. Jint.AotExample's probes from
sebastienros#3381/sebastienros#3425/sebastienros#3480 are not ported: this branch's AotExample is a 22-line stub with none of the AOT probe
harness those hunks extend. docs/v5-migration.md and Jint/Runtime/Interop/AGENTS.md do not exist here, so
their hunks are carried into the XML docs and comments beside the code instead. The suites are xUnit v3 here
rather than the NUnit main moved to in sebastienros#3409.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
lahma added a commit that referenced this pull request Sep 1, 2026
… filter that hides the indexer hides it (#3562)

Backport of eight main pull requests that together decide one question — what an index-shaped key means on a
wrapped CLR collection — plus the fix for the containment hole the seventh of them opened. They are one unit:
each of the first seven moves the answer, and taking any of them alone leaves the lanes disagreeing with each
other.

  #3356  a host collection with a count is not a host collection with an index
  #3381  a degraded array view still refuses a resize
  #3425  the IndexWrappedOperations lane is not AOT-only, and a generic that must grow refuses
  #3416  an index on a host collection is one property, however script spelled it
  #3464  hasOwnProperty and "in" give one answer about an index on a wrapped collection
  #3472  an index outside a wrapped collection is refused, not handed to the collection
  #3480  a collection exposed as IList<T> or IReadOnlyList<T> gets the wrapper that contract names
  #3561  a member filter that hides an indexer hides a wrapped collection's elements (fixes #3558)

#3385 - a read-only host collection refuses script with a JavaScript error - is the ninth member of the
cluster and is already on this branch as #3556, so its hunks are not here. Its suite,
HostReadOnlyCollectionTests, is 68 of 68 green both before and after this change, which is what says so.

What script sees. An index-shaped key is now the view's own property, whichever way it is spelled and whether
or not the position exists. A read outside the range is undefined rather than the collection's own
ArgumentOutOfRangeException out of Evaluate; a write at the end grows a growable target exactly as a "length"
write of the same size does; "in", hasOwnProperty, propertyIsEnumerable and getOwnPropertyDescriptor give one
answer, because OrdinaryHasProperty is defined in terms of [[GetOwnProperty]] and may not disagree with it; a
delete of an absent position succeeds without reaching the collection; and a countable-but-not-indexable
target - Queue<T>, Stack<T>, LinkedList<T>, SortedSet<T> - is array-like with no element at index 0 rather
than an InvalidCastException from a lane that cast it to IList.

The containment half is why #3561 is in the same change. Options.Interop.TypeResolver.MemberFilter is how a
host says which members script may reach, and an ArrayLikeWrapper answers every index-shaped key itself, so
the filter's decision about the indexer never reached the element lanes. On this branch that matters more than
it does on main: Interop.AllowWrite ships on here, so a filter that hid the indexer stopped nothing. Measured
on this branch, with the cluster applied and #3561 held back, three refusals had become writes
(list[0] = 42, list['0'] = 42 and growth list[3] = 42) and reads, "in", delete, push and sort had never been
covered at all - and the pre-existing
HostIndexerFilterTests.AMemberFilterExcludingTheIndexerBlocksIndexedWrites, which passes on stock 4.x, fails.
The whole element contract is closed rather than only the write half, and containment is asked before the
read-only and fixed-size refusals of #3382/#3385 so the two compose: a fixed-size array whose indexer is
hidden reports "no such property" rather than the TypeError naming its bounds, which would answer a question
the host never granted.

Evidence, on net10.0 and net472 alike (identical counts on both). Against stock 4.x with the suites in place:
HostNonIndexedCollectionTests 33 of 45 failed, HostExposedCollectionTypeTests 21 of 33,
HostCollectionIndexWriteTests 56 of 63, HostCollectionIndexAgreementTests 11 of 18,
HostCollectionIndexBoundsTests 28 of 35, HostIndexerFilterTests 13 of 18, and 6 of the 7 new
InteropTests.ClrArrayLiveView cases. All of them pass now. The containment tests run in both write
configurations, because on this branch the default is the interesting one: the elements leak under
AllowWrite = true and the reads leak under AllowWrite = false, and both are pinned.

Deliberate divergences from main. #3054 - which is what makes Interop.AllowWrite default to false there - is
a v5 default change and stays out, so this branch keeps its Delete and ArrayOperations.Set guards and the two
suites spell the write switch out where main could leave it to the default. Jint.AotExample's probes from
#3381/#3425/#3480 are not ported: this branch's AotExample is a 22-line stub with none of the AOT probe
harness those hunks extend. docs/v5-migration.md and Jint/Runtime/Interop/AGENTS.md do not exist here, so
their hunks are carried into the XML docs and comments beside the code instead. The suites are xUnit v3 here
rather than the NUnit main moved to in #3409.


Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Interop: a plain ObjectWrapper over an array-like target hands an out-of-range index to the collection

1 participant