Skip to content

Interop: a member filter that hides an indexer hides a wrapped collection's elements - #3561

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:fix/3558-indexer-member-filter
Sep 1, 2026
Merged

Interop: a member filter that hides an indexer hides a wrapped collection's elements#3561
lahma merged 1 commit into
sebastienros:mainfrom
lahma:fix/3558-indexer-member-filter

Conversation

@lahma

@lahma lahma commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Closes #3558.

Options.Interop.TypeResolver.MemberFilter is CLR-containment configuration — it is how a host says which members script may reach — and a member it rejects reads as undefined and cannot be written. That held for the indexer of an ordinary wrapped object, which resolves a member per access. It did not hold for a wrapped collection.

ArrayLikeWrapper answers every index-shaped key itself, which is the whole point of #3416 and #3384: without it an out-of-range list[3] = 9 was the collection's own ArgumentOutOfRangeException out of Evaluate. But that view was never told what the filter had decided, so the same filter, asked the same question, gave two answers depending on whether Jint happened to build a view.

The existing HostIndexerFilterTests.AMemberFilterExcludingTheIndexerBlocksIndexedWrites asserts exactly this contract and passes on main — but only because its engine leaves Options.Interop.AllowWrite at the false #3054 made it default to, so the write is refused for a reason that has nothing to do with the filter. Adding AllowWrite = true — the configuration the containment question is actually asked in — fails it, and 10 further cells of the matrix with it.

Scope: the whole element contract, not only the write half

The issue asks for a deliberate decision about the lanes that were bypassing the filter before #3416 as well. Closing only the writes would leave a control that hides a member from reads and from enumeration while letting a write through — and, worse, leave 0 in list answering true while list.hasOwnProperty(0) and Object.keys(list) already answered false and [] on the same object under the same filter, which OrdinaryHasProperty does not permit.

So under a filter that hides the indexer, an array-like view now has no element properties at all:

operation, filter excluding the indexer, AllowWrite = true before after
list[0], list['0'] 1 undefined
0 in list true false
list.hasOwnProperty(0), Object.keys(list) false, [] unchanged — and now agreed with
list[0] = 42, list['0'] = 42 writes (regressed by #3416) refused
list[3] = 42 (growth) grows (regressed by #3416) refused
list.length = 0 clears the list refused
delete list[0] zeroes the slot true, slot untouched
Array.prototype.push / sort mutates TypeError / reads undefined per index

Every refusal is the ordinary [[Set]]/[[Delete]] answer — silent outside strict mode, a TypeError inside it — never a CLR exception.

Composition with CanWrite/IsReadOnly. Containment is asked before the read-only and fixed-size refusals of #3382/#3385, because it answers a different question: whether there is an element property at all, not what may be done to one. A fixed-size T[] whose indexer is hidden therefore reports "no such property" rather than the TypeError naming its bounds — that message would answer a question the host never granted. The two never mask each other, since containment's answer is the same ordinary false the writability lanes give.

Three lanes declared out of contract, and why. length is produced from Count, a member the filter decides about separately, so it goes on answering. Iteration is GetEnumerator's business, so for..of and [...list] still yield the elements — the same shape a Queue<T> has always had, where enumeration works and no index does. And Options.Interop.ArrayConversion in its default Copy mode turns a T[] into a JavaScript array before any member is accessed; that is a conversion of a value, not an access to a member, and a host that wants the filter to govern arrays uses ArrayConversionMode.LiveView.

Shape, and what it costs the hot lane

The decision is the one IndexerAccessor.TryFindIndexer would have made — the first integer-keyed indexer the exposed type declares (List<T>.Item, IList<T>.Item, IReadOnlyList<T>.Item), falling back to the type descriptor's IList.Item for a T[], which declares none of its own. It lives on TypeResolver.ExposesIndexedElements, memoized per resolver and per type, and is dropped with the rest of the resolved state when the filter is reassigned. It is engine-independent: the only engine-steered part of Filter is AllowGetType, which gates the name GetType, and the one shape that could carry it — an indexer renamed by [IndexerName("GetType")] — is excluded from the memo rather than assumed away.

  • Default or absent filter: _memberFilterIsDefault returns true from one bool field read. No dictionary, no delegate, nothing cached.
  • Custom filter: one ConcurrentDictionary<Type, bool> lookup per array-like wrapper construction, beside the TypeDescriptor.Get lookup the base constructor already does. The filter delegate itself runs once per (resolver, type).
  • Per element: ArrayLikeWrapper caches the answer in a readonly bool at construction, so Get/Set/Delete/HasProperty each read a field. No per-operation allocation and no per-operation delegate invocation anywhere.

HasIndexedElements carries the bit, which is what routes a hidden element lane through ObjectOperations in ArrayOperations.For exactly as a Queue<T> is routed, and closes the IndexWrappedOperations lane (a plain ObjectWrapper over an IList, the Native AOT degrade path) with it. No benchmark: nothing is added to a default-configured engine's per-operation path, so there is no number to move. If the wrapper-construction lookup ever needs to be measured for filtered engines, that is the row to add.

Verification

Failing-first, with the final test file against unmodified main (8a949a4): 11 failed / 5 passed of 16, identically on net472, net8.0 and net10.0. With the fix: 16/16 on all three.

  • dotnet build -c Release: 0 errors, 0 new warnings.
  • dotnet test -c Release: green — Jint.Tests 11601/11601 (net8.0, net10.0) and 8221/8221 (net472); Jint.Tests.PublicInterface 3350/3340/2717; Jint.Tests.CommonScripts 28/28; Jint.Tests.SourceGenerators 71/71.
  • JINT_HOST_CONTRACT_VERIFICATION=1 over Jint.Tests and Jint.Tests.PublicInterface: green on all three target frameworks.
  • test262: 102,537 passed / 0 failed / 151 skipped of 102,688 — unchanged.

docs/v5-migration.md §4.97 carries the embedder-facing form, including what an allow-list filter (m => allowed.Contains(m.Name)) has to add to keep the elements it was reaching by accident. The Jint/Runtime/Interop/AGENTS.md gotcha on index-shaped keys now records that owning the key means owning the containment decision, in what order, and which three lanes are out of contract.

🤖 Generated with Claude Code

https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S

…tion's elements

Fixes sebastienros#3558.

`Options.Interop.TypeResolver.MemberFilter` is CLR-containment configuration — it is how a host says
which members script may reach — and a member it rejects reads as `undefined` and cannot be written.
That held for the indexer of an ordinary wrapped object, which resolves a member per access. It did
not hold for a wrapped **collection**. `ArrayLikeWrapper` answers every index-shaped key itself, which
is the whole point of sebastienros#3416 and sebastienros#3384: without it an out-of-range `list[3] = 9` was the collection's
own `ArgumentOutOfRangeException` out of `Evaluate`. But that view was never told what the filter had
decided, so the same filter, asked the same question, gave two answers depending on whether Jint
happened to build a view.

Three refusals had become writes since sebastienros#3416 (`list[0] = 42`, `list['0'] = 42`, and growth
`list[3] = 42`), and reads, `delete`, `push` and `sort` had bypassed the filter for longer than that.
The existing `HostIndexerFilterTests.AMemberFilterExcludingTheIndexerBlocksIndexedWrites` asserted the
contract and passed on `main` only because its engine left `Options.Interop.AllowWrite` at the `false`
sebastienros#3054 made it default to; with writes on, it fails.

The whole element contract is closed rather than only the write half. Under a filter that hides the
indexer, an array-like view now has no element properties at all: `Get` reads `undefined`, `in` is
`false` (agreeing with `hasOwnProperty` and `Object.keys`, which already said so — `OrdinaryHasProperty`
is defined in terms of `[[GetOwnProperty]]`, so they may not disagree), `Set` and `DefineOwnProperty`
refuse, `delete` returns `true` without touching the slot, a `length` write neither grows nor truncates,
and `ArrayOperations.For` routes every `Array.prototype` generic to `ObjectOperations` exactly as a
countable-but-not-indexable `Queue<T>` is routed. Containment is asked **before** the read-only and
fixed-size refusals of sebastienros#3382/sebastienros#3385 so the two compose rather than mask each other: 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.

Three lanes are deliberately left out of the contract, and say so. `length` is produced from `Count`,
a member the filter decides about separately. Iteration is `GetEnumerator`'s business, so `[...list]`
still yields elements — the shape a `Queue<T>` has always had. And `ArrayConversionMode.Copy`, the
default, turns a `T[]` into a JavaScript array before any member is accessed; that is a conversion of
a value, not an access to a member.

The decision is the one `IndexerAccessor.TryFindIndexer` would have made — the first integer-keyed
indexer the exposed type declares, falling back to the descriptor's `IList.Item` for a `T[]`, which
declares none of its own — memoized per resolver and per type behind `TypeResolver.ExposesIndexedElements`
and dropped with the rest of the resolved state when the filter is reassigned. A resolver with the
default filter returns from one bool field read and caches nothing; a filtered one pays one dictionary
lookup per array-like wrapper construction, beside the `TypeDescriptor.Get` the base constructor
already does, and every element access afterwards reads a `readonly bool` field. No per-operation
allocation and no per-operation delegate invocation anywhere.

`docs/v5-migration.md` §4.97 carries the embedder-facing form, including what an allow-list filter has
to add to keep the elements it was reaching by accident.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
@lahma
lahma merged commit 8c439c2 into sebastienros:main Sep 1, 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 member filter that excludes an indexer no longer blocks indexed writes

1 participant