fix(tools): snapshot const/enum/oneOf reads to close stateful-getter TOCTOU
Address ds-review-bot v5/v6 review round 8. The prior guards re-read a stateful getter's value between the check and the spelling, so a getter returning different values across reads could still emit invalid Python: - renderConstrainedScalar reads node.const ONCE into a local, then checks and spells that snapshot; a third-read switch can no longer produce Literal[[object Object]]. - The enum path snapshots via [...raw] (reading each element exactly once, covering accessor-property elements) and requires the snapshot be a non-empty all-scalar array; an emptied re-read no longer spells Literal[], and a non-array re-read degrades. - The oneOf branch build guards a non-array or empty re-read to Any instead of joining to '' (a missing type). - pyScalar spells null as None; its JSDoc no longer claims null cannot reach it. Tests cover each re-read shape; py-types.ts stays at 100% coverage.
This commit is contained in:
@@ -161,10 +161,11 @@ function allocateClassName(base: string, state: RenderState): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one validated scalar as Python literal text (`True`/`False`,
|
||||
* JSON-quoted strings, bare numbers). `null` cannot reach here: the `null`
|
||||
* type renders directly as `None`, and the unified validator rejects a null
|
||||
* `const`/`enum` entry on every other scalar type.
|
||||
* Render one validated scalar as Python literal text (`True`/`False`, `None`,
|
||||
* JSON-quoted strings, bare numbers). A validated `const`/`enum` never carries
|
||||
* a bare `null` on a non-`null` scalar type, but a post-validation stateful
|
||||
* getter can re-read one as `null`, so `null` is spelled `None` rather than the
|
||||
* JS `String(null)` = `"null"`.
|
||||
*
|
||||
* A beyond-safe-range integral number takes `BigInt` digits rather than
|
||||
* `String`: Python integers are arbitrary-precision, so the emitted digits ARE
|
||||
@@ -179,6 +180,7 @@ function allocateClassName(base: string, state: RenderState): string {
|
||||
function pyScalar(value: JsonSchemaScalar): string {
|
||||
if (value === true) return 'True'
|
||||
if (value === false) return 'False'
|
||||
if (value === null) return 'None'
|
||||
if (typeof value === 'string') return JSON.stringify(value)
|
||||
if (typeof value === 'number' && Number.isInteger(value) && !Number.isSafeInteger(value)) {
|
||||
return BigInt(value).toString()
|
||||
@@ -201,18 +203,24 @@ function isPyScalar(value: unknown): value is JsonSchemaScalar {
|
||||
*/
|
||||
function renderConstrainedScalar(node: Record<string, unknown>, broad: string, state: RenderState): string {
|
||||
if (Object.hasOwn(node, 'const')) {
|
||||
// Re-read at render time: a stateful getter validated as a scalar can now
|
||||
// return anything. A non-scalar would spell `Literal[[object Object]]`
|
||||
// (invalid Python), so degrade to the broad type per the contract.
|
||||
if (!isPyScalar(node.const)) return broad
|
||||
// Snapshot the value with ONE read: a stateful getter can return different
|
||||
// values across reads, so a separate check-read and spell-read could still
|
||||
// pass the check and then spell a non-scalar (`Literal[[object Object]]`).
|
||||
const value = node.const
|
||||
if (!isPyScalar(value)) return broad
|
||||
state.typing.add('Literal')
|
||||
return `Literal[${pyScalar(node.const)}]`
|
||||
return `Literal[${pyScalar(value)}]`
|
||||
}
|
||||
if (Object.hasOwn(node, 'enum')) {
|
||||
const raw = node.enum
|
||||
if (!Array.isArray(raw) || !raw.every(isPyScalar)) return broad
|
||||
// `[...raw]` reads each element exactly once (elements may be accessor
|
||||
// properties that change between reads); then check and spell that
|
||||
// snapshot. Require non-empty: an emptied re-read would spell `Literal[]`,
|
||||
// a Python SyntaxError that breaks the whole SDK.
|
||||
const values: unknown[] | undefined = Array.isArray(raw) ? [...(raw as unknown[])] : undefined
|
||||
if (values === undefined || values.length === 0 || !values.every(isPyScalar)) return broad
|
||||
state.typing.add('Literal')
|
||||
return `Literal[${raw.map(pyScalar).join(', ')}]`
|
||||
return `Literal[${values.map(pyScalar).join(', ')}]`
|
||||
}
|
||||
return broad
|
||||
}
|
||||
@@ -372,8 +380,17 @@ function renderType(schema: unknown, className: string, state: RenderState): str
|
||||
}
|
||||
const node = frame.schema as Record<string, unknown>
|
||||
if (Object.hasOwn(node, 'oneOf')) {
|
||||
// Snapshot the branches with ONE read (a getter can change them
|
||||
// between reads). A re-read that is not a non-empty array would join to
|
||||
// `''` (or drop branches), so degrade to `Any` instead.
|
||||
const branches = node.oneOf
|
||||
if (!Array.isArray(branches) || branches.length === 0) {
|
||||
state.typing.add('Any')
|
||||
finish('Any')
|
||||
continue
|
||||
}
|
||||
frame.kind = 'oneOf'
|
||||
frame.children = (node.oneOf as unknown[]).map((branch, index) => ({ schema: branch, className: `${frame.className}${index + 1}` }))
|
||||
frame.children = (branches as unknown[]).map((branch, index) => ({ schema: branch, className: `${frame.className}${index + 1}` }))
|
||||
continue
|
||||
}
|
||||
if (!Object.hasOwn(node, 'type')) {
|
||||
|
||||
@@ -200,10 +200,9 @@ describe('jsonSchemaToPy', () => {
|
||||
expect(out).toBe('list[Any]')
|
||||
})
|
||||
|
||||
it('degrades to the broad type when a const getter re-reads as a non-scalar', () => {
|
||||
// `const` validates as a string, then returns an object at render time.
|
||||
// A naive spelling would emit Literal[[object Object]] (invalid Python);
|
||||
// the render must fall back to the broad type instead.
|
||||
it('degrades a const that snapshots as a non-scalar to the broad type', () => {
|
||||
// The single snapshot read returns an object (validation read returned a
|
||||
// scalar); the check must degrade rather than spell Literal[[object Object]].
|
||||
let reads = 0
|
||||
const schema: Record<string, unknown> = { type: 'string' }
|
||||
Object.defineProperty(schema, 'const', {
|
||||
@@ -219,24 +218,118 @@ describe('jsonSchemaToPy', () => {
|
||||
expect(out).not.toContain('object Object')
|
||||
})
|
||||
|
||||
it('degrades to the broad type when an enum getter re-reads as a non-scalar array', () => {
|
||||
// `enum` validates as scalars, then returns an array containing an object
|
||||
// at render time; the render must fall back to the broad type.
|
||||
it('snapshots const with one read so a third-read switch cannot spell a non-scalar', () => {
|
||||
// A getter returning 'fixed' on the validation AND check reads but an
|
||||
// object on a third read would defeat a separate check-read/spell-read.
|
||||
// The render snapshots once, so it either spells the checked value or
|
||||
// degrades — never Literal[[object Object]].
|
||||
let reads = 0
|
||||
const schema: Record<string, unknown> = { type: 'string' }
|
||||
Object.defineProperty(schema, 'const', {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads += 1
|
||||
return reads <= 2 ? 'fixed' : {}
|
||||
},
|
||||
})
|
||||
let out: string | undefined
|
||||
expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow()
|
||||
expect(out === 'str' || out === 'Literal["fixed"]').toBe(true)
|
||||
expect(out).not.toContain('object Object')
|
||||
})
|
||||
|
||||
it('degrades to the broad type when an enum getter re-reads as a non-array', () => {
|
||||
// A validated enum array that re-reads as a non-array must degrade, not
|
||||
// spread a non-iterable or spell a bad literal.
|
||||
let reads = 0
|
||||
const schema: Record<string, unknown> = { type: 'string' }
|
||||
Object.defineProperty(schema, 'enum', {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads += 1
|
||||
return reads <= 1 ? ['a', 'b'] : [{}]
|
||||
return reads <= 1 ? ['a'] : 'not-an-array'
|
||||
},
|
||||
})
|
||||
let out: string | undefined
|
||||
expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow()
|
||||
expect(out).toBe('str')
|
||||
})
|
||||
|
||||
it('degrades to the broad type when an enum getter re-reads as an empty array', () => {
|
||||
// A validated non-empty enum that re-reads as [] would spell Literal[] — a
|
||||
// Python SyntaxError that breaks the whole SDK. Require non-empty at render.
|
||||
let reads = 0
|
||||
const schema: Record<string, unknown> = { type: 'string' }
|
||||
Object.defineProperty(schema, 'enum', {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads += 1
|
||||
return reads <= 1 ? ['a'] : []
|
||||
},
|
||||
})
|
||||
let out: string | undefined
|
||||
expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow()
|
||||
expect(out).toBe('str')
|
||||
expect(out).not.toContain('Literal[]')
|
||||
})
|
||||
|
||||
it('degrades the broad type when an enum element is an accessor that re-reads as a non-scalar', () => {
|
||||
// `[...raw]` reads each element exactly once; the validation read saw a
|
||||
// scalar, the spread read returns an object. The snapshot's every(isPyScalar)
|
||||
// check must degrade rather than spell Literal[[object Object]].
|
||||
let elemReads = 0
|
||||
const arr: unknown[] = []
|
||||
Object.defineProperty(arr, '0', {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get() {
|
||||
elemReads += 1
|
||||
return elemReads <= 1 ? 'a' : {}
|
||||
},
|
||||
})
|
||||
arr.length = 1
|
||||
const schema = { type: 'string', enum: arr }
|
||||
let out: string | undefined
|
||||
expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow()
|
||||
expect(out).toBe('str')
|
||||
expect(out).not.toContain('object Object')
|
||||
})
|
||||
|
||||
it('spells a const re-read as null with None, not the JS string "null"', () => {
|
||||
let reads = 0
|
||||
const schema: Record<string, unknown> = { type: 'string' }
|
||||
Object.defineProperty(schema, 'const', {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads += 1
|
||||
return reads <= 1 ? 'fixed' : null
|
||||
},
|
||||
})
|
||||
let out: string | undefined
|
||||
expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow()
|
||||
// Either the checked value spells, or a null re-read spells None — never "null".
|
||||
expect(out === 'Literal["fixed"]' || out === 'Literal[None]').toBe(true)
|
||||
expect(out).not.toContain('Literal[null]')
|
||||
})
|
||||
|
||||
it('degrades a oneOf that re-reads as an empty array to Any, not an empty string', () => {
|
||||
// oneOf validates as two branches, then returns [] at render; a naive join
|
||||
// would produce '' (a missing type). Degrade to Any instead.
|
||||
let reads = 0
|
||||
const schema: Record<string, unknown> = {}
|
||||
Object.defineProperty(schema, 'oneOf', {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads += 1
|
||||
return reads <= 1 ? [{ type: 'string' }, { type: 'number' }] : []
|
||||
},
|
||||
})
|
||||
let out: string | undefined
|
||||
expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow()
|
||||
expect(out).toBe('Any')
|
||||
expect(out).not.toBe('')
|
||||
})
|
||||
|
||||
it('emits exact digits for a beyond-safe-range integer literal', () => {
|
||||
// Python integers are arbitrary-precision, so the emitted digits ARE the
|
||||
// value the model programs against. `String(2 ** 60)` prints the rounded
|
||||
|
||||
Reference in New Issue
Block a user