fix(code-runtime-python): bound checkDoneValue object metering in O(cap)

The object branch counted every own key before applying the size bound, so a
forged done.value with millions of keys and a small cap forced an O(frame)
walk — contradicting the O(cap) guarantee the comment promised and able to
block the host event loop. Bail mid-count the instant the running minimum
encoding (braces + 4 bytes/entry + commas) crosses maxBytes, and drop the now
-redundant post-count check the loop subsumes. Add a Proxy-based test proving
a 2M-key object enumerates fewer than 1000 keys under a 64-byte cap.

Also correct the checkDoneValue JSDoc: per-scalar byte length is measured via
scalarJson (exact BigInt digits for beyond-safe integers), not JSON.stringify.
This commit is contained in:
Chinesezjc
2026-08-07 13:27:54 +08:00
parent 31506dec2d
commit 104cd5f975
2 changed files with 33 additions and 8 deletions
@@ -207,6 +207,21 @@ describe('checkDoneValue', () => {
const wide: Record<string, number> = {}
for (let i = 0; i < 10; i++) wide[`k${i}`] = i
expect(checkDoneValue(wide, 12)).toEqual({ ok: false, reason: 'over-budget' })
// A forged object with millions of keys and a small cap must reject in
// O(cap): the key COUNT loop itself bails once the running minimum encoding
// (braces + 4 bytes/entry + commas) crosses the budget, rather than walking
// the whole breadth before checking. Observable as a bounded key subset:
// build a Proxy whose ownKeys would yield far more than the cap admits and
// assert the metered walk never enumerates past it.
let enumerated = 0
const millionKeys = new Proxy({}, {
ownKeys() { return Array.from({ length: 2_000_000 }, (_unused, i) => `k${i}`) },
getOwnPropertyDescriptor() { enumerated += 1; return { enumerable: true, configurable: true, value: 0 } },
})
expect(checkDoneValue(millionKeys, 64)).toEqual({ ok: false, reason: 'over-budget' })
// With cap 64, at most ~16 entries (4 bytes each) can fit before the bound
// trips, so the walk enumerates far fewer than the 2,000,000 declared keys.
expect(enumerated).toBeLessThan(1000)
})
it('rejects an over-budget string on its length before escaping it', () => {