mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-05 02:05:08 +00:00
fix(codemode): reject Object.assign cycles (#46076)
This commit is contained in:
parent
b0402f5a34
commit
dffd95ce7c
3 changed files with 205 additions and 24 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
type AstNode,
|
||||
AsyncIteratorSymbol,
|
||||
CodeModeFunction,
|
||||
CodeModeGenerator,
|
||||
CoercionFunction,
|
||||
|
|
@ -9,6 +10,7 @@ import {
|
|||
GeneratorMethodReference,
|
||||
InterpreterRuntimeError,
|
||||
IntrinsicReference,
|
||||
IteratorSymbol,
|
||||
JsonMethodReference,
|
||||
PromiseCapabilityFunction,
|
||||
PromiseInstanceMethodReference,
|
||||
|
|
@ -42,13 +44,12 @@ export const isRuntimeReference = (value: unknown): boolean =>
|
|||
value instanceof SymbolNamespace ||
|
||||
isCodeModeValue(value)
|
||||
|
||||
function* childValues(value: object): Generator<unknown> {
|
||||
if (Array.isArray(value)) {
|
||||
const length = value.length
|
||||
for (let index = 0; index < length; index++) yield value[index]
|
||||
return
|
||||
function* childValues(value: object): Generator {
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
if (!Object.prototype.propertyIsEnumerable.call(value, key)) continue
|
||||
if (typeof key === "symbol" && key !== AsyncIteratorSymbol && key !== IteratorSymbol) continue
|
||||
yield Reflect.get(value, key)
|
||||
}
|
||||
yield* Object.values(value)
|
||||
}
|
||||
|
||||
export const containsRuntimeReference = (value: unknown): boolean => {
|
||||
|
|
@ -90,9 +91,14 @@ export const containsOpaqueReference = (value: unknown): boolean => {
|
|||
}
|
||||
|
||||
// Reject cycles before mutation so later boundary walks remain safe.
|
||||
export const rejectCircularInsertion = (container: object, value: unknown, label: string, node: AstNode): void => {
|
||||
export const rejectCircularInsertion = (
|
||||
container: object,
|
||||
value: unknown,
|
||||
label: string,
|
||||
node: AstNode,
|
||||
seen = new Set<object>(),
|
||||
): void => {
|
||||
const pending: Array<Iterator<unknown>> = [[value].values()]
|
||||
const seen = new Set<object>()
|
||||
while (pending.length > 0) {
|
||||
const next = pending.at(-1)!.next()
|
||||
if (next.done) {
|
||||
|
|
@ -104,7 +110,7 @@ export const rejectCircularInsertion = (container: object, value: unknown, label
|
|||
throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue")
|
||||
if (current === null || typeof current !== "object" || isRuntimeReference(current) || seen.has(current)) continue
|
||||
seen.add(current)
|
||||
pending.push(Array.isArray(current) ? current[Symbol.iterator]() : childValues(current))
|
||||
pending.push(childValues(current))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,6 @@
|
|||
import { Effect } from "effect"
|
||||
import {
|
||||
type AstNode,
|
||||
AsyncIteratorSymbol,
|
||||
InterpreterRuntimeError,
|
||||
IteratorSymbol,
|
||||
IteratorSymbols,
|
||||
} from "../interpreter/model.js"
|
||||
import { containsOpaqueReference } from "../interpreter/references.js"
|
||||
import { type AstNode, AsyncIteratorSymbol, InterpreterRuntimeError, IteratorSymbol } from "../interpreter/model.js"
|
||||
import { containsOpaqueReference, rejectCircularInsertion } from "../interpreter/references.js"
|
||||
import { isBlockedMember } from "../tool-runtime.js"
|
||||
import { isCodeModeValue, CodeModePromise } from "../values.js"
|
||||
import { boundedData, coerceToString } from "./value.js"
|
||||
|
|
@ -37,10 +31,6 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
|
|||
}
|
||||
return input as Record<string, unknown>
|
||||
}
|
||||
const guardedSet = (out: Record<string, unknown>, key: string, item: unknown): void => {
|
||||
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
|
||||
out[key] = item
|
||||
}
|
||||
switch (name) {
|
||||
case "keys":
|
||||
return Object.keys(requireObject())
|
||||
|
|
@ -64,14 +54,29 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
|
|||
throw new InterpreterRuntimeError("Object.assign expects a data object target.", node)
|
||||
}
|
||||
const out = target as Record<string, unknown>
|
||||
const seen = new Set<object>()
|
||||
const guardedSet = (key: PropertyKey, item: unknown): void => {
|
||||
if (typeof key === "string" && isBlockedMember(key))
|
||||
throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
|
||||
rejectCircularInsertion(out, item, "Object.assign result", node, seen)
|
||||
if (!Reflect.set(out, key, item))
|
||||
throw new InterpreterRuntimeError(`Object.assign could not assign property '${String(key)}'.`, node).as(
|
||||
"TypeError",
|
||||
)
|
||||
}
|
||||
for (const source of args.slice(1)) {
|
||||
if (source === null || source === undefined || isCodeModeValue(source)) continue
|
||||
if (typeof source !== "object" || Array.isArray(source)) {
|
||||
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
|
||||
}
|
||||
for (const [key, item] of Object.entries(source)) guardedSet(out, key, item)
|
||||
for (const symbol of IteratorSymbols) {
|
||||
if (Object.hasOwn(source, symbol)) Reflect.set(out, symbol, Reflect.get(source, symbol))
|
||||
for (const key of Reflect.ownKeys(source)) {
|
||||
if (typeof key === "string") {
|
||||
if (Object.prototype.propertyIsEnumerable.call(source, key)) guardedSet(key, Reflect.get(source, key))
|
||||
continue
|
||||
}
|
||||
if (key !== AsyncIteratorSymbol && key !== IteratorSymbol) continue
|
||||
if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue
|
||||
guardedSet(key, Reflect.get(source, key))
|
||||
}
|
||||
}
|
||||
return out
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { CodeMode, Tool } from "../src/index.js"
|
||||
import { AsyncIteratorSymbol, IteratorSymbol } from "../src/interpreter/model.js"
|
||||
import { invokeObjectMethod } from "../src/stdlib/object.js"
|
||||
|
||||
// Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS;
|
||||
// intra-CodeMode checkpoints (Object.* helpers, spread, coercion inputs) preserve the live
|
||||
|
|
@ -824,6 +826,174 @@ describe("stdlib integration", () => {
|
|||
expect(await value(`try { Object.assign(null, { a: 1 }); return false } catch { return true }`)).toBe(true)
|
||||
})
|
||||
|
||||
test("Object.assign ignores non-enumerable supported symbols without reading them", () => {
|
||||
const target = {}
|
||||
const reads: Array<boolean> = []
|
||||
const source = Object.defineProperty({}, IteratorSymbol, {
|
||||
get() {
|
||||
reads.push(true)
|
||||
return target
|
||||
},
|
||||
})
|
||||
expect(invokeObjectMethod("assign", [target, source], { type: "CallExpression" })).toBe(target)
|
||||
expect(reads).toEqual([])
|
||||
expect(Object.hasOwn(target, IteratorSymbol)).toBe(false)
|
||||
})
|
||||
|
||||
test("Object.assign ignores nested non-enumerable supported symbols during cycle checks", () => {
|
||||
const target = {}
|
||||
const reads: Array<boolean> = []
|
||||
const nested = Object.defineProperty({}, IteratorSymbol, {
|
||||
get() {
|
||||
reads.push(true)
|
||||
return target
|
||||
},
|
||||
})
|
||||
expect(invokeObjectMethod("assign", [target, { nested }], { type: "CallExpression" })).toBe(target)
|
||||
expect(reads).toEqual([])
|
||||
expect(target).toEqual({ nested })
|
||||
})
|
||||
|
||||
test("Object.assign rejects cycles through supported symbols on nested arrays", () => {
|
||||
const target = {}
|
||||
const nested = Object.defineProperty([], IteratorSymbol, { enumerable: true, value: target })
|
||||
expect(() => invokeObjectMethod("assign", [target, { nested }], { type: "CallExpression" })).toThrow(
|
||||
"Object.assign result contains a circular value.",
|
||||
)
|
||||
expect(Object.hasOwn(target, "nested")).toBe(false)
|
||||
})
|
||||
|
||||
test("Object.assign cycle checks traverse sparse keys lazily", () => {
|
||||
const target = {}
|
||||
const reads: Array<boolean> = []
|
||||
const nested = Object.defineProperties([], {
|
||||
4294967294: { enumerable: true, value: target },
|
||||
later: {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads.push(true)
|
||||
return null
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(() => invokeObjectMethod("assign", [target, { nested }], { type: "CallExpression" })).toThrow(
|
||||
"Object.assign result contains a circular value.",
|
||||
)
|
||||
expect(reads).toEqual([])
|
||||
})
|
||||
|
||||
test("Object.assign stops after a supported symbol write fails", () => {
|
||||
const previous = () => ({ done: true })
|
||||
const target = Object.defineProperty({}, IteratorSymbol, { value: previous })
|
||||
const reads: Array<boolean> = []
|
||||
const source = Object.defineProperties(
|
||||
{},
|
||||
{
|
||||
[IteratorSymbol]: { enumerable: true, value: () => ({ done: false }) },
|
||||
[AsyncIteratorSymbol]: {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads.push(true)
|
||||
return () => ({ done: true })
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
expect(() => invokeObjectMethod("assign", [target, source], { type: "CallExpression" })).toThrow(
|
||||
"Object.assign could not assign property",
|
||||
)
|
||||
expect(Reflect.get(target, IteratorSymbol)).toBe(previous)
|
||||
expect(reads).toEqual([])
|
||||
})
|
||||
|
||||
test("Object.assign rejects direct and nested cycles", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const target = { kept: true }
|
||||
try { Object.assign(target, { self: target }) } catch { return target }
|
||||
return null
|
||||
`),
|
||||
).toEqual({ kept: true })
|
||||
expect(
|
||||
await value(`
|
||||
const target = { kept: true }
|
||||
const nested = { target }
|
||||
try { Object.assign(target, { nested }) } catch { return target }
|
||||
return null
|
||||
`),
|
||||
).toEqual({ kept: true })
|
||||
expect(
|
||||
await value(`
|
||||
const target = {}
|
||||
const source = {}
|
||||
source[Symbol.iterator] = target
|
||||
try { Object.assign(target, source) } catch { return Object.hasOwn(target, Symbol.iterator) }
|
||||
return true
|
||||
`),
|
||||
).toBe(false)
|
||||
expect(
|
||||
await value(`
|
||||
const target = {}
|
||||
const nested = {}
|
||||
nested[Symbol.iterator] = target
|
||||
try { Object.assign(target, { nested }) } catch { return Object.hasOwn(target, "nested") }
|
||||
return true
|
||||
`),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test("Object.assign preserves mutations before a circular field", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const target = {}
|
||||
try { Object.assign(target, { before: 1, cycle: { target }, after: 2 }) } catch { return target }
|
||||
return null
|
||||
`),
|
||||
).toEqual({ before: 1 })
|
||||
expect(
|
||||
await value(`
|
||||
const target = {}
|
||||
const marker = {}
|
||||
const source = {}
|
||||
source[Symbol.iterator] = marker
|
||||
source[Symbol.asyncIterator] = target
|
||||
try { Object.assign(target, source) } catch {
|
||||
return [target[Symbol.iterator] === marker, Object.hasOwn(target, Symbol.asyncIterator)]
|
||||
}
|
||||
return null
|
||||
`),
|
||||
).toEqual([true, false])
|
||||
})
|
||||
|
||||
test("Object.assign preserves target identity and acyclic shared aliases", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const shared = { count: 1 }
|
||||
const target = {}
|
||||
const result = Object.assign(target, { left: shared, right: shared })
|
||||
result.left.count = 2
|
||||
return [result === target, result.left === shared, result.left === result.right, shared.count]
|
||||
`),
|
||||
).toEqual([true, true, true, 2])
|
||||
})
|
||||
|
||||
test("Object.assign traverses shared aliases once", () => {
|
||||
const reads: Array<boolean> = []
|
||||
const shared = Object.defineProperty({}, "value", {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads.push(true)
|
||||
return 1
|
||||
},
|
||||
})
|
||||
const target = {}
|
||||
expect(invokeObjectMethod("assign", [target, { left: shared, right: shared }], { type: "CallExpression" })).toBe(
|
||||
target,
|
||||
)
|
||||
expect(target).toEqual({ left: shared, right: shared })
|
||||
expect(reads).toEqual([true])
|
||||
})
|
||||
|
||||
test("assignment resolves and reads its left side before evaluating the right side", async () => {
|
||||
expect(await value(`let x = 1; x += (x = 5); return x`)).toBe(6)
|
||||
expect(await value(`let i = 0; const values = [9]; values[i++] = i; return [values, i]`)).toEqual([[1], 1])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue