Fix Proxy [[OwnPropertyKeys]] check for integer-index keys (#1609) - #2095
tangtaizong666 wants to merge 1 commit into
Conversation
…1609) JSProxy::ownPropertyKeys enforces the ES [[OwnPropertyKeys]] invariant by checking that every own key of the target is present in the ownKeys trap result using SameValue. Hermes represents integer-index own-property keys (e.g. "12345") internally as numbers, while the trap result is validated to hold only Strings and Symbols. SameValue(Number, String) is always false, so any integer-index own key on a non-extensible target spuriously failed the invariant and threw "ownKeys target is non-extensible but key is missing from trap result". Normalize the numeric index keys in targetKeys to their canonical string form before the invariant comparisons, mirroring how for-in enumeration already converts numeric array indices to strings. The returned key list is unchanged, so this only removes the spurious failure. Fixes facebook#1609.
xhon-pelushi
left a comment
There was a problem hiding this comment.
Built static_h (e9edc8b) with and without this patch and checked it against V8. The diagnosis is right, the fix is right, and it's a bigger win than the description claims. Three suggestions below, none blocking.
Root cause confirmed in source
JSObject.cpp:507encodes integer-index own keys withHermesValue::encodeTrustedNumberValue, sotargetKeysreally does hold12345as a Number.isSameValue(Operations.cpp:125) returnsfalseon thegetTag()mismatch before it ever reaches the deep string comparison, soSameValue(12345, "12345")is false as described.inTrapResult(JSProxy.cpp:1507) is the only place inJSProxy.cppthat compares a key. The other threeisSameValuecalls — lines 201, 912, 1047 — compare descriptor and trap values, not keys. So this is the only affected comparison and the fix doesn't need a sibling.
Two things I checked because they'd have made the patch wrong:
- Mutating
targetKeysin place is safe.JSObject::getOwnPropertyKeysallocates a fresh array per call, andtargetKeysis never returned — every return path hands backtrapResultor the separate step-6a array. The normalized list is only ever used for the comparisons. - The approach is already the in-repo idiom, not a new invention.
getOwnPropertyKeysAsStrings(lib/VM/JSLib/Object.cpp:490) is the same in-place number→stringJSArray::setElementAtloop with the same GC-marker flushing, and the single-element version appears atInterpreter-slowpaths.cpp:570andStaticH.cpp:2365. Placing the block after the step-17 early return also keeps it off the common all-extensible path.
It reproduces, and the fix holds
Unpatched, the new test fails exactly as claimed, and both invariant messages are reachable depending on the shape:
$ ./build-rel/bin/hermes -Xes6-proxy -non-strict -O -target=HBC test/hermes/regress-proxy-ownkeys-numeric-key.js
Uncaught TypeError: ownKeys target is non-extensible but key is missing from trap result
at keys (native)
at global (test/hermes/regress-proxy-ownkeys-numeric-key.js:34:28)
$ ./build-rel/bin/hermes -Xes6-proxy i1609.js # Object.freeze({123:'x'}) behind a passthrough trap
Uncaught TypeError: ownKeys target key is non-configurable but not present in trap result
Patched, the test prints the expected four lines and the issue repro returns ["123"], matching V8.
Regression sweep. I ran all 498 test/hermes/*.js under one uniform invocation on both builds and diffed. Six files differed; five of them (array-literal-large, date-constructor, narrow-trusted-regress, put-to-transient, regress-error-stack-native-stack-overflow) also differ base-against-base on a rerun, so they're nondeterministic and not attributable. The only real change is regress-proxy-ownkeys-numeric-key.js going 1 → 0. test/hermes/proxy.js is byte-identical.
Differential test against V8. 27 cases covering index keys, ordering, symbols, boundary indices, non-canonical numeric-looking keys, the negative invariant cases, and the consumers (Object.keys, getOwnPropertyNames, JSON.stringify, spread, for-in, nested proxies), run on node 18 and both Hermes builds:
| disagreements with V8 | |
|---|---|
| Hermes before | 15 of 27 |
| Hermes after | 0 of 27 |
Importantly the five negative cases still throw — trap omitting the index, returning the wrong index, returning an extra key, returning a Number, and returning duplicates all still raise TypeError, same as V8. The fix removes the spurious failures without loosening the invariant.
Stress. 5000 integer-index keys on a frozen target, Reflect.ownKeys 50 times: no crash or handle-scope trouble, and the result matches V8 (len=5000, first/last both string).
1. The description under-sells the scope: it isn't only non-extensible targets
The comparison only runs for non-extensible targets, which is why the bug only surfaces once the target is frozen /
preventExtensions'd.
Step 19's loop runs whenever targetNonconfigurableKeys is non-empty, independent of extensibility — if (*extensibleRes) return is step 20, after it. So a perfectly extensible object with one non-configurable integer-index property also throws:
var o = {};
Object.defineProperty(o, '5', {value: 1, configurable: false, enumerable: true});
Reflect.ownKeys(new Proxy(o, {ownKeys: t => Reflect.ownKeys(t)}));
// before: TypeError: ownKeys target key is non-configurable but not present in trap result
// after: ["5"] (V8: ["5"])That's also the message @tmikov got in the first repro on #1609, so it's the path the original reporter hit. Worth fixing the wording since it makes the change look narrower than it is.
The other case I'd highlight is a frozen array, which I'd guess is at least as common in the wild as numeric string keys:
Reflect.ownKeys(new Proxy(Object.freeze([1,2,3]), {ownKeys: t => Reflect.ownKeys(t)}));
// before: TypeError after: ["0","1","2","length"] (V8: same)2. Two test cases worth adding
The four cases in the new test all have extensibleTarget === false, so they only ever exercise the step-21 loop plus step 19. Neither of the shapes above is covered:
- extensible target with a non-configurable integer-index key — the only case where step 19 fires and step 20 returns before step 21, i.e. the one path where the normalization matters but step 21 never runs.
Object.freeze([1,2,3])behind a proxy — index keys alongside"length", and the likely real-world shape.
3. Minor: the loop converts more keys than get compared
The normalization walks every numeric entry in targetKeys, but when extensibleTarget is true only the nonConfigurable entries are ever passed to inTrapResult — step 20 returns before the step-21 loop. So an extensible target with one non-configurable index key among a thousand index keys pays a thousand numberToStringPrimitive allocations to perform one comparison. Converting inside inTrapResult, or restricting the loop to the indices that will actually be compared, would avoid that. Very much a nit given Proxy's overall cost here, but it's nearly free to fix.
What I did not verify
FileCheck isn't built in my tree, so the regression sweep used one uniform flag set across test/hermes/*.js rather than each test's own RUN line and compared base-vs-patched output rather than checking CHECK: directives — good for spotting differences caused by this patch, not a substitute for check-hermes. I also only exercised the -target=HBC CLI path; JSProxy::ownPropertyKeys is the single implementation (reached from JSObject.cpp:432), so the SH path should be covered too, but I didn't run it, and nothing was tested on-device under React Native.
Summary
Reflect.ownKeys/Object.keys/Object.getOwnPropertyNameson aProxywhose target is non-extensible and has an integer-index own key (e.g.
"12345") throwseven when the trap faithfully returns the target's own keys. This breaks common
state libraries such as Valtio (see #1609), where a frozen snapshot object with
numeric string keys can no longer be enumerated.
Root cause.
JSProxy::ownPropertyKeysenforces the ES[[OwnPropertyKeys]]invariant (steps 19 and 21) by checking that every own key of the target is
present in the trap result using
SameValue. Hermes represents integer-indexown-property keys internally as numbers (
JSObject::getOwnPropertyKeysreturns
12345as a Number), whereas the trap result is validated to containonly Strings and Symbols and therefore holds the key as the String
"12345".SameValue(12345, "12345")is alwaysfalse, so the invariant check fails andthrows. The comparison only runs for non-extensible targets, which is why the
bug only surfaces once the target is frozen /
preventExtensions'd.Fix. Before the invariant comparisons, normalize any numeric index keys in
targetKeysto their canonical string form — the same conversion that for-inenumeration already performs for numeric array indices
(
Interpreter-slowpaths.cpp, "We must return the property as a string"). Thereturned key list is unchanged (it still comes from the trap result), so this
only removes the spurious invariant failure.
Test Plan
New regression test
test/hermes/regress-proxy-ownkeys-numeric-key.jscovering:preventExtensionstarget (configurable-keys loop),Object.freezed target (non-configurable-keys loop),The test passes with this change and fails on the unpatched engine with the
reported
TypeError. Existingtest/hermes/proxy.jscontinues to pass.Fixes #1609