mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 10:16:03 +00:00
fix(codemode): harden JSON replacers
This commit is contained in:
@@ -96,26 +96,35 @@ const stringify = <R>(
|
||||
const apply = applyCollectionCallback(runner, replacer, "JSON.stringify", node)
|
||||
const root: SafeObject = Object.create(null) as SafeObject
|
||||
root[""] = input
|
||||
const stack = new Set<object>()
|
||||
const visit = (holder: SafeObject | Array<unknown>, key: string): Effect.Effect<unknown, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const value = yield* apply([key, toJSONValue(holder[key as keyof typeof holder])])
|
||||
if (value === undefined || typeofValue(value) === "function") return undefined
|
||||
copyIn(value, "JSON.stringify replacer result", true)
|
||||
if (value === undefined) return undefined
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : null
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") return value
|
||||
if (Array.isArray(value)) {
|
||||
if (stack.has(value))
|
||||
throw new InterpreterRuntimeError("Converting circular structure to JSON.", node).as("TypeError")
|
||||
stack.add(value)
|
||||
const result: Array<unknown> = []
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
result.push((yield* visit(value, String(index))) ?? null)
|
||||
}
|
||||
stack.delete(value)
|
||||
return result
|
||||
}
|
||||
if (!isPlainObject(value)) return {}
|
||||
if (stack.has(value))
|
||||
throw new InterpreterRuntimeError("Converting circular structure to JSON.", node).as("TypeError")
|
||||
stack.add(value)
|
||||
const result: SafeObject = Object.create(null) as SafeObject
|
||||
for (const name of Object.keys(value)) {
|
||||
const item = yield* visit(value, name)
|
||||
if (item !== undefined) result[name] = item
|
||||
}
|
||||
stack.delete(value)
|
||||
return result
|
||||
})
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
* - test/built-ins/JSON/stringify/replacer-array-wrong-type.js
|
||||
* - test/built-ins/JSON/stringify/replacer-function-abrupt.js
|
||||
* - test/built-ins/JSON/stringify/replacer-function-arguments.js
|
||||
* - test/built-ins/JSON/stringify/replacer-function-array-circular.js
|
||||
* - test/built-ins/JSON/stringify/replacer-function-object-deleted-property.js
|
||||
* - test/built-ins/JSON/stringify/replacer-function-object-circular.js
|
||||
* - test/built-ins/JSON/stringify/replacer-function-result-undefined.js
|
||||
* - test/built-ins/JSON/stringify/replacer-function-result.js
|
||||
*
|
||||
@@ -27,6 +29,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CodeMode } from "../src/index.js"
|
||||
import { invokeJsonMethod } from "../src/stdlib/json.js"
|
||||
|
||||
const value = async (code: string) => {
|
||||
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
|
||||
@@ -132,6 +135,40 @@ describe("Test262 JSON.stringify replacer adaptations", () => {
|
||||
).toBe('{"a":1,"b":"<replaced>"}')
|
||||
})
|
||||
|
||||
test("test/built-ins/JSON/stringify/replacer-function-object-circular.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const direct = { prop: {} }
|
||||
const indirect = { p1: { p2: {} } }
|
||||
const errors = []
|
||||
try { JSON.stringify(direct, () => direct) } catch (error) { errors.push(error.name) }
|
||||
try {
|
||||
JSON.stringify(indirect, (key, item) => key === "p2" ? indirect : item)
|
||||
} catch (error) { errors.push(error.name) }
|
||||
return errors
|
||||
`),
|
||||
).toEqual(["TypeError", "TypeError"])
|
||||
})
|
||||
|
||||
test("test/built-ins/JSON/stringify/replacer-function-array-circular.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const circular = [{}]
|
||||
try { JSON.stringify(circular, () => circular) } catch (error) { return error.name }
|
||||
return "missed"
|
||||
`),
|
||||
).toBe("TypeError")
|
||||
})
|
||||
|
||||
test("repeated objects outside the active ancestor chain are not circular", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const shared = { value: 1 }
|
||||
return JSON.stringify({ left: shared, right: shared }, (key, item) => item)
|
||||
`),
|
||||
).toBe('{"left":{"value":1},"right":{"value":1}}')
|
||||
})
|
||||
|
||||
test("function replacers replace values and map undefined to omission or null", async () => {
|
||||
expect(
|
||||
await value(`return JSON.stringify({ a: 1, nested: { b: [1] } }, (key, item) => item === 1 ? undefined : item)`),
|
||||
@@ -140,6 +177,19 @@ describe("Test262 JSON.stringify replacer adaptations", () => {
|
||||
expect(await value(`return JSON.stringify(1, () => undefined) === undefined`)).toBe(true)
|
||||
})
|
||||
|
||||
test("callable replacer results follow JSON omission semantics", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const fn = () => 1
|
||||
return [
|
||||
JSON.stringify({ user: 1, builtin: 2 }, (key, item) => key === "user" ? fn : key === "builtin" ? Math.abs : item),
|
||||
JSON.stringify([1, 2], (key, item) => key === "0" ? fn : key === "1" ? Number : item),
|
||||
JSON.stringify(null, () => "abc".includes) === undefined,
|
||||
]
|
||||
`),
|
||||
).toEqual(["{}", "[null,null]", true])
|
||||
})
|
||||
|
||||
test("built-in toJSON conversion runs before the function replacer", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
@@ -168,18 +218,26 @@ describe("CodeMode JSON callback boundaries", () => {
|
||||
expect(result).toMatchObject({ ok: false, error: { kind: "UnsupportedSyntax" } })
|
||||
})
|
||||
|
||||
test("blocked keys are rejected before callbacks can expose them", async () => {
|
||||
test("blocked parse keys are rejected before reviver traversal", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
let parseBlocked = false
|
||||
let stringifyBlocked = false
|
||||
try { JSON.parse('{"__proto__":1}', (key, item) => item) } catch (error) { parseBlocked = true }
|
||||
try {
|
||||
const input = JSON.parse('{"constructor":1}')
|
||||
JSON.stringify(input, (key, item) => item)
|
||||
} catch (error) { stringifyBlocked = true }
|
||||
return [parseBlocked, stringifyBlocked]
|
||||
`),
|
||||
).toEqual([true, true])
|
||||
await value(
|
||||
`try { JSON.parse('{"__proto__":1}', (key, item) => item) } catch (error) { return true } return false`,
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("JSON.stringify directly rejects blocked input keys", () => {
|
||||
expect(() =>
|
||||
invokeJsonMethod(
|
||||
{
|
||||
invokeFunction: () => Effect.die("unused"),
|
||||
invokeCallable: () => Effect.die("unused"),
|
||||
settlePromise: () => Effect.die("unused"),
|
||||
},
|
||||
"stringify",
|
||||
[Object.fromEntries([["constructor", 1]])],
|
||||
{ type: "CallExpression" },
|
||||
),
|
||||
).toThrow("blocked property 'constructor'")
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user