diff --git a/packages/codemode/src/stdlib/json.ts b/packages/codemode/src/stdlib/json.ts index 1fe0bc5189..3ac871912d 100644 --- a/packages/codemode/src/stdlib/json.ts +++ b/packages/codemode/src/stdlib/json.ts @@ -96,26 +96,35 @@ const stringify = ( const apply = applyCollectionCallback(runner, replacer, "JSON.stringify", node) const root: SafeObject = Object.create(null) as SafeObject root[""] = input + const stack = new Set() const visit = (holder: SafeObject | Array, key: string): Effect.Effect => 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 = [] 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 }) diff --git a/packages/codemode/test/json-callbacks-test262.test.ts b/packages/codemode/test/json-callbacks-test262.test.ts index 4993dc44bb..b3741020b4 100644 --- a/packages/codemode/test/json-callbacks-test262.test.ts +++ b/packages/codemode/test/json-callbacks-test262.test.ts @@ -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":""}') }) + 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'") }) })