Skip to content

Commit 3064219

Browse files
authored
fix(executor): unwrap boxed primitives by internal slot when checking JSON serializability (#8658)
* fix(executor): unwrap boxed primitives by internal slot when checking JSON serializability * fix(executor): check the boxed slot first and convert wrappers with ToNumber/ToString
1 parent 20a7350 commit 3064219

5 files changed

Lines changed: 82 additions & 10 deletions

File tree

‎apps/sim/executor/execution/snapshot-serializer.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,16 +352,46 @@ describe('buildCompletedExecutionState', () => {
352352
'a boxed number carrying a BigInt property',
353353
{ value: Object.assign(Object(1), { big: BigInt(1) }) },
354354
],
355+
['an object that only inherits from BigInt', { value: Object.create(BigInt.prototype) }],
356+
[
357+
'a proxy whose getPrototypeOf trap throws',
358+
{
359+
value: new Proxy(
360+
{ a: 1 },
361+
{
362+
getPrototypeOf: () => {
363+
throw new Error('JSON.stringify never asks for the prototype')
364+
},
365+
}
366+
),
367+
},
368+
],
355369
])('serializes like the JSON-cloned pause state for %s', (_name, output) => {
356370
const context = contextWithOutput(output)
357371
expect(JSON.stringify(buildCompletedExecutionState(context))).toBe(
358372
JSON.stringify(jsonClonedState(context))
359373
)
360374
})
361375

376+
const numberLookalike = Object.create(Number.prototype)
377+
numberLookalike.self = numberLookalike
378+
362379
it.each([
363380
['a cycle', cyclic],
364381
['a BigInt', { big: BigInt(1) }],
382+
['a cycle in an object that only inherits from Number', { value: numberLookalike }],
383+
[
384+
'a BigInt wrapper whose prototype was swapped',
385+
{ value: Object.setPrototypeOf(Object(BigInt(1)), {}) },
386+
],
387+
[
388+
'a BigInt wrapper whose prototype was reset to Object.prototype',
389+
{ value: Object.setPrototypeOf(Object(BigInt(1)), Object.prototype) },
390+
],
391+
[
392+
'a Number wrapper that converts to a BigInt',
393+
{ value: Object.assign(Object(1), { [Symbol.toPrimitive]: () => BigInt(1) }) },
394+
],
365395
])('throws like the pause snapshot for %s', (_name, output) => {
366396
const context = contextWithOutput(output)
367397
expect(() => jsonClonedState(context)).toThrow(TypeError)

‎apps/sim/executor/execution/snapshot-serializer.ts‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { unboxJsonPrimitive } from '@/lib/core/utils/boxed-primitives'
12
import { LARGE_VALUE_THRESHOLD_BYTES } from '@/lib/execution/payloads/large-value-ref'
23
import type { DAG } from '@/executor/dag/builder'
34
import type { EdgeManager } from '@/executor/execution/edge-manager'
@@ -353,14 +354,11 @@ function assertJsonSerializable(value: unknown): void {
353354
const toJSON = (current as { toJSON?: unknown }).toJSON
354355
if (typeof toJSON === 'function') current = toJSON.call(current, key)
355356
}
356-
if (typeof current === 'bigint' || current instanceof BigInt) {
357+
if (typeof current === 'object' && current !== null) current = unboxJsonPrimitive(current)
358+
if (typeof current === 'bigint') {
357359
throw new TypeError('Do not know how to serialize a BigInt')
358360
}
359361
if (typeof current !== 'object' || current === null) return
360-
// Serialized as their primitive value; their own properties are never read.
361-
if (current instanceof Number || current instanceof String || current instanceof Boolean) {
362-
return
363-
}
364362
if (ancestors.has(current)) {
365363
throw new TypeError('Converting circular structure to JSON')
366364
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { types } from 'node:util'
2+
3+
/**
4+
* Unwraps a primitive wrapper the way `JSON.stringify` does: by its internal
5+
* slot, not its prototype, so an object that merely inherits from
6+
* `Number.prototype` stays an object and a wrapper with a replaced prototype is
7+
* still unwrapped. Number and String wrappers convert through ToNumber and
8+
* ToString (unary `+` and a template literal, which run any user
9+
* `valueOf`/`toString` and throw where JSON would, e.g. on a BigInt or Symbol
10+
* result); Boolean and BigInt wrappers read their stored value. The slot check
11+
* runs no Proxy traps. Anything else is returned unchanged.
12+
*/
13+
export function unboxJsonPrimitive(value: unknown): unknown {
14+
if (typeof value !== 'object' || value === null || !types.isBoxedPrimitive(value)) {
15+
return value
16+
}
17+
if (types.isNumberObject(value)) return +value
18+
if (types.isStringObject(value)) return `${value}`
19+
if (types.isBooleanObject(value)) return Boolean.prototype.valueOf.call(value)
20+
if (types.isBigIntObject(value)) return BigInt.prototype.valueOf.call(value)
21+
return value
22+
}

‎apps/sim/lib/logs/execution/json-byte-size.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,31 @@ describe('getJsonByteSize', () => {
4040
},
4141
],
4242
['a function with toJSON', { fn: Object.assign(() => 1, { toJSON: () => 'serialized' }) }],
43+
[
44+
'objects that only inherit from wrapper prototypes',
45+
{
46+
n: Object.assign(Object.create(Number.prototype), { a: 1 }),
47+
s: Object.assign(Object.create(String.prototype), { b: 'x' }),
48+
},
49+
],
50+
['a Number wrapper whose prototype was swapped', { n: Object.setPrototypeOf(Object(42), {}) }],
51+
[
52+
'a Number wrapper whose prototype was reset to Object.prototype',
53+
{ n: Object.setPrototypeOf(Object(7), Object.prototype) },
54+
],
55+
[
56+
'a proxy whose getPrototypeOf trap throws',
57+
{
58+
p: new Proxy(
59+
{ a: 1 },
60+
{
61+
getPrototypeOf: () => {
62+
throw new Error('JSON.stringify never asks for the prototype')
63+
},
64+
}
65+
),
66+
},
67+
],
4368
['escapes, multi-byte text, and lone surrogates', { 'k"\\': 'a\n\u0001é漢😀\ud800' }],
4469
])('matches JSON.stringify for %s', (_name, payload) => {
4570
expect(getJsonByteSize(payload, LIMIT)).toBe(jsonBytes(payload))

‎apps/sim/lib/logs/execution/json-byte-size.ts‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getErrorMessage } from '@sim/utils/errors'
22
import { quotedStringBytes } from '@/lib/core/utils/bounded-json'
3+
import { unboxJsonPrimitive } from '@/lib/core/utils/boxed-primitives'
34

45
/**
56
* Byte length of `JSON.stringify(value)`, measured without building the string.
@@ -35,11 +36,7 @@ export function getJsonByteSize(value: unknown, maxBytes: number): number | unde
3536
? (raw as { toJSON?: unknown }).toJSON
3637
: undefined
3738
const value = typeof toJSON === 'function' ? toJSON.call(raw, key) : raw
38-
if (value instanceof Number) return Number(value)
39-
if (value instanceof String) return String(value)
40-
if (value instanceof Boolean) return Boolean.prototype.valueOf.call(value)
41-
if (value instanceof BigInt) return BigInt.prototype.valueOf.call(value)
42-
return value
39+
return typeof value === 'object' && value !== null ? unboxJsonPrimitive(value) : value
4340
}
4441

4542
const isOmitted = (item: unknown): boolean =>

0 commit comments

Comments
 (0)