1
0

protocol.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. import { describe, expect, it } from 'vitest'
  2. import { checkDoneValue, encodeJsonPlain, hasNonLosslessNumber, hasUnsafeIntegerToken, logTruncationMarker, validateChildFrame } from '../src/index.ts'
  3. describe('logTruncationMarker', () => {
  4. it('names the configured byte budget', () => {
  5. expect(logTruncationMarker(65536)).toBe('[dsh-code-runtime-python] log capture truncated at 65536 bytes')
  6. expect(logTruncationMarker(1)).toBe('[dsh-code-runtime-python] log capture truncated at 1 bytes')
  7. })
  8. })
  9. describe('validateChildFrame', () => {
  10. it('rebuilds boot-ack frames without extra fields', () => {
  11. expect(validateChildFrame({ type: 'boot-ack' })).toEqual({ type: 'boot-ack' })
  12. // Forged extras never ride along.
  13. expect(validateChildFrame({ type: 'boot-ack', extra: 'x' })).toEqual({ type: 'boot-ack' })
  14. })
  15. it('rebuilds log frames when the text field is a string', () => {
  16. expect(validateChildFrame({ type: 'log', text: 'hi' })).toEqual({ type: 'log', text: 'hi' })
  17. // Non-string text drops.
  18. expect(validateChildFrame({ type: 'log', text: 42 })).toBeUndefined()
  19. expect(validateChildFrame({ type: 'log' })).toBeUndefined()
  20. })
  21. it('rebuilds call frames with a numeric id, string global, and string name', () => {
  22. expect(validateChildFrame({ type: 'call', id: 1, global: 'tools', name: 'echo', args: { x: 1 } }))
  23. .toEqual({ type: 'call', id: 1, global: 'tools', name: 'echo', args: { x: 1 } })
  24. // A frame with NO args key drops whole: rebuilding it as `undefined`
  25. // would invoke the binding with a non-JSON value, bypassing the
  26. // lossless-JSON argument boundary. Any present value is JSON-plain by
  27. // construction (frames arrive via JSON.parse), so null passes.
  28. expect(validateChildFrame({ type: 'call', id: 2, global: 'tools', name: 'echo' })).toBeUndefined()
  29. expect(validateChildFrame({ type: 'call', id: 2, global: 'tools', name: 'echo', args: null }))
  30. .toEqual({ type: 'call', id: 2, global: 'tools', name: 'echo', args: null })
  31. // A missing/mistyped required field drops.
  32. expect(validateChildFrame({ type: 'call', id: '1', global: 'tools', name: 'echo' })).toBeUndefined()
  33. expect(validateChildFrame({ type: 'call', id: 1, global: 7, name: 'echo' })).toBeUndefined()
  34. expect(validateChildFrame({ type: 'call', id: 1, global: 'tools' })).toBeUndefined()
  35. })
  36. it('rebuilds done frames with optional value/error', () => {
  37. expect(validateChildFrame({ type: 'done' })).toEqual({ type: 'done' })
  38. expect(validateChildFrame({ type: 'done', value: 42 })).toEqual({ type: 'done', value: 42 })
  39. expect(validateChildFrame({ type: 'done', error: { kind: 'exception', message: 'boom' } }))
  40. .toEqual({ type: 'done', error: { kind: 'exception', message: 'boom' } })
  41. expect(validateChildFrame({ type: 'done', error: { kind: 'invalid-output', message: 'lossy' } }))
  42. .toEqual({ type: 'done', error: { kind: 'invalid-output', message: 'lossy' } })
  43. expect(validateChildFrame({ type: 'done', error: { kind: 'output-limit', message: 'big' } }))
  44. .toEqual({ type: 'done', error: { kind: 'output-limit', message: 'big' } })
  45. expect(validateChildFrame({ type: 'done', value: 1, error: { kind: 'exception', message: 'boom' } }))
  46. .toEqual({ type: 'done', value: 1, error: { kind: 'exception', message: 'boom' } })
  47. // A `value: undefined` field is dropped (JSON never carries it, but a forged
  48. // shape might; the rebuild coalesces to the absent case).
  49. expect(validateChildFrame({ type: 'done', value: undefined })).toEqual({ type: 'done' })
  50. // A missing or unrecognized kind drops the frame: the child always sends
  51. // one of the three, so anything else is a forgery.
  52. expect(validateChildFrame({ type: 'done', error: { message: 'boom' } })).toBeUndefined()
  53. expect(validateChildFrame({ type: 'done', error: { kind: 'timeout', message: 'x' } })).toBeUndefined()
  54. })
  55. it('rejects malformed done frames', () => {
  56. // error must be an object.
  57. expect(validateChildFrame({ type: 'done', error: 'boom' })).toBeUndefined()
  58. expect(validateChildFrame({ type: 'done', error: null })).toBeUndefined()
  59. // error.message must be a string.
  60. expect(validateChildFrame({ type: 'done', error: {} })).toBeUndefined()
  61. expect(validateChildFrame({ type: 'done', error: { message: 42 } })).toBeUndefined()
  62. })
  63. it('drops non-object inputs and unknown types silently', () => {
  64. expect(validateChildFrame(null)).toBeUndefined()
  65. expect(validateChildFrame(undefined)).toBeUndefined()
  66. expect(validateChildFrame(42)).toBeUndefined()
  67. expect(validateChildFrame('str')).toBeUndefined()
  68. expect(validateChildFrame({})).toBeUndefined()
  69. expect(validateChildFrame({ type: 'unknown' })).toBeUndefined()
  70. })
  71. it('drops CALL frames whose args are non-finite or negative zero', () => {
  72. // JSON.parse turns 1e400 into Infinity and preserves -0; the honest child
  73. // rejects both before sending, so a call frame carrying one is forged.
  74. expect(validateChildFrame({ type: 'call', id: 1, global: 'tools', name: 'x', args: { n: Infinity } })).toBeUndefined()
  75. expect(validateChildFrame({ type: 'call', id: Infinity, global: 'tools', name: 'x', args: null })).toBeUndefined()
  76. // Plain zero and ordinary floats pass.
  77. expect(validateChildFrame({ type: 'call', id: 1, global: 'tools', name: 'x', args: [0, 1.5] }))
  78. .toEqual({ type: 'call', id: 1, global: 'tools', name: 'x', args: [0, 1.5] })
  79. })
  80. it('passes DONE values through untouched — losslessness is metered later', () => {
  81. // validateChildFrame no longer scans done.value: an unbounded scan would
  82. // push every member of a wide forged payload before any byte cap ran. The
  83. // done handler's checkDoneValue folds losslessness into the metered walk.
  84. expect(validateChildFrame({ type: 'done', value: Infinity })).toEqual({ type: 'done', value: Infinity })
  85. expect(validateChildFrame({ type: 'done', value: [{ x: -0 }] })).toEqual({ type: 'done', value: [{ x: -0 }] })
  86. expect(validateChildFrame({ type: 'done', value: [0, 1.5] })).toEqual({ type: 'done', value: [0, 1.5] })
  87. })
  88. })
  89. describe('lossless-number scan', () => {
  90. it('finds non-finite and negative-zero numbers at any depth, iteratively', () => {
  91. expect(hasNonLosslessNumber(Infinity)).toBe(true)
  92. expect(hasNonLosslessNumber(-Infinity)).toBe(true)
  93. expect(hasNonLosslessNumber(NaN)).toBe(true)
  94. expect(hasNonLosslessNumber(-0)).toBe(true)
  95. expect(hasNonLosslessNumber({ a: [1, { b: -0 }] })).toBe(true)
  96. expect(hasNonLosslessNumber({ a: [0, 1.5, 'x', null, true] })).toBe(false)
  97. // Deep nesting must not overflow the stack.
  98. let deep: unknown = 0
  99. for (let i = 0; i < 100000; i++) deep = [deep]
  100. expect(hasNonLosslessNumber(deep)).toBe(false)
  101. })
  102. it('walks wide arrays and objects one member at a time', () => {
  103. // `call.args` carries no seam byte cap, so a wide forged payload has no
  104. // budget to be rejected against — the walk must hold one cursor per
  105. // NESTING LEVEL, not one entry per member, or a flat payload just below
  106. // the 256 MiB frame ceiling would allocate tens of millions of stack
  107. // entries (and `Object.values` a second full-breadth copy). Observable
  108. // through the boundary: a wide payload whose per-member cost the old shape
  109. // would have paid still scans, and a violation ANYWHERE in it is found
  110. // wherever it sits.
  111. const wideArray = new Array(2_000_000).fill(0) as unknown[]
  112. expect(hasNonLosslessNumber(wideArray)).toBe(false)
  113. // Last element, so the cursor must run the whole breadth lazily.
  114. wideArray[wideArray.length - 1] = -0
  115. expect(hasNonLosslessNumber(wideArray)).toBe(true)
  116. const wideObject: Record<string, unknown> = {}
  117. for (let i = 0; i < 200_000; i++) wideObject[`k${i}`] = i
  118. expect(hasNonLosslessNumber(wideObject)).toBe(false)
  119. wideObject.last = Infinity
  120. expect(hasNonLosslessNumber(wideObject)).toBe(true)
  121. // Interleaved nesting: a per-level cursor must resume its parent after a
  122. // child level ends, so a violation after a nested container is still seen.
  123. expect(hasNonLosslessNumber([[1], { a: 2 }, NaN])).toBe(true)
  124. })
  125. it('scans only own enumerable properties', () => {
  126. // The per-level cursor filters own keys (a prototype-carrying frame is
  127. // impossible off JSON.parse, but the filter is what keeps the walk equal
  128. // to what the encoder would serialize).
  129. const withProto = Object.create({ inherited: -0 }) as Record<string, unknown>
  130. withProto.own = 1
  131. expect(hasNonLosslessNumber(withProto)).toBe(false)
  132. })
  133. })
  134. describe('unsafe-integer token scan', () => {
  135. it('flags integer tokens outside the safe range, skipping strings and float forms', () => {
  136. expect(hasUnsafeIntegerToken('{"v":9007199254740993}')).toBe(true)
  137. // Exact beyond-safe-range tokens are lossless and pass (2**53, 2**64).
  138. expect(hasUnsafeIntegerToken('{"v":9007199254740992}')).toBe(false)
  139. expect(hasUnsafeIntegerToken('{"v":18446744073709551616}')).toBe(false)
  140. // A token that parses to Infinity is trivially lossy.
  141. expect(hasUnsafeIntegerToken(`{"v":${'9'.repeat(400)}}`)).toBe(true)
  142. expect(hasUnsafeIntegerToken('{"v":-9007199254740993}')).toBe(true)
  143. expect(hasUnsafeIntegerToken('{"v":9007199254740991}')).toBe(false)
  144. expect(hasUnsafeIntegerToken('{"v":"9007199254740993"}')).toBe(false)
  145. expect(hasUnsafeIntegerToken(String.raw`{"v":"esc\"9007199254740993"}`)).toBe(false)
  146. expect(hasUnsafeIntegerToken('{"v":9007199254740993.0}')).toBe(false)
  147. expect(hasUnsafeIntegerToken('{"v":9e99}')).toBe(false)
  148. })
  149. })
  150. describe('checkDoneValue', () => {
  151. it('matches the exact encoded size and rejects one byte over', () => {
  152. const cases: unknown[] = [null, true, false, 0, -1.5, 'a"b\\', [], {}, [1, 'x', null], { a: [1, 2], b: { c: 'd' } }]
  153. for (const value of cases) {
  154. const exact = Buffer.byteLength(JSON.stringify(value), 'utf8')
  155. expect(checkDoneValue(value, exact), JSON.stringify(value)).toEqual({ ok: true, bytes: exact })
  156. expect(checkDoneValue(value, exact - 1), JSON.stringify(value)).toEqual({ ok: false, reason: 'over-budget' })
  157. expect(encodeJsonPlain(value)).toBe(JSON.stringify(value))
  158. }
  159. })
  160. it('stops early on a huge value instead of measuring it whole', () => {
  161. const huge = { data: 'x'.repeat(1_000_000), tail: 'y' }
  162. expect(checkDoneValue(huge, 1024)).toEqual({ ok: false, reason: 'over-budget' })
  163. // A forged flat array below the frame ceiling must fail BEFORE its
  164. // elements are enqueued — the pre-enqueue bound keeps the walk O(cap).
  165. const flat = new Array(10_000_000).fill(0)
  166. expect(checkDoneValue(flat, 1024)).toEqual({ ok: false, reason: 'over-budget' })
  167. // Same bound for a wide object: braces+commas fit the cap, but the
  168. // per-entry lower bound (quoted key + colon + value) does not, so it fails
  169. // before any key is metered or any value enqueued.
  170. const wide: Record<string, number> = {}
  171. for (let i = 0; i < 10; i++) wide[`k${i}`] = i
  172. expect(checkDoneValue(wide, 12)).toEqual({ ok: false, reason: 'over-budget' })
  173. })
  174. it('rejects an over-budget string on its length before escaping it', () => {
  175. // A control-heavy forged string escapes to ~6x its length; the walk must
  176. // refuse it on the cheap `length + 2` lower bound so the escaped copy is
  177. // never allocated. Observable through the boundary: a string whose LENGTH
  178. // already exceeds the cap fails even though every character is 1 byte.
  179. expect(checkDoneValue('�'.repeat(4096), 1024)).toEqual({ ok: false, reason: 'over-budget' })
  180. // The bound is a lower bound, never a false rejection: a string that fits
  181. // exactly still passes with its exact escaped size.
  182. expect(checkDoneValue('�', 8)).toEqual({ ok: true, bytes: 8 })
  183. expect(checkDoneValue('�', 7)).toEqual({ ok: false, reason: 'over-budget' })
  184. // Same lower bound for keys, checked before the key is escaped.
  185. expect(checkDoneValue({ ['�'.repeat(4096)]: 1 }, 1024)).toEqual({ ok: false, reason: 'over-budget' })
  186. })
  187. it('meters only own enumerable keys', () => {
  188. // The walk counts keys with a `for...in` + hasOwn pass rather than
  189. // Object.keys/entries (which allocate per member before the bound). A
  190. // prototype-carrying forgery is impossible off JSON.parse, but the own-key
  191. // filter is what keeps the count equal to the encoder's.
  192. const withProto = Object.create({ inherited: 'x' }) as Record<string, unknown>
  193. withProto.own = 1
  194. expect(checkDoneValue(withProto, 1024)).toEqual({ ok: true, bytes: Buffer.byteLength('{"own":1}', 'utf8') })
  195. })
  196. it('rejects non-finite and negative-zero numbers at any depth as non-lossless', () => {
  197. expect(checkDoneValue(Infinity, 1024)).toEqual({ ok: false, reason: 'non-lossless' })
  198. expect(checkDoneValue(-Infinity, 1024)).toEqual({ ok: false, reason: 'non-lossless' })
  199. expect(checkDoneValue(NaN, 1024)).toEqual({ ok: false, reason: 'non-lossless' })
  200. expect(checkDoneValue(-0, 1024)).toEqual({ ok: false, reason: 'non-lossless' })
  201. expect(checkDoneValue({ a: [1, { b: -0 }] }, 1024)).toEqual({ ok: false, reason: 'non-lossless' })
  202. // An ordinary finite value within budget passes with its exact byte count.
  203. const clean = { a: [0, 1.5, 'x', null, true] }
  204. expect(checkDoneValue(clean, 1024)).toEqual({ ok: true, bytes: Buffer.byteLength(JSON.stringify(clean), 'utf8') })
  205. })
  206. it('meters deep nesting iteratively without overflowing the stack', () => {
  207. let deep: unknown = 0
  208. for (let i = 0; i < 100_000; i++) deep = [deep]
  209. // 100000 '[' + '0' + 100000 ']' = 200001 bytes.
  210. expect(checkDoneValue(deep, 1_000_000)).toEqual({ ok: true, bytes: 200_001 })
  211. })
  212. it('emits exact digits for beyond-safe integral doubles', () => {
  213. // String(2**60) prints the ROUNDED ...847000; echoing that to the child
  214. // would change the integer. BigInt digits give the exact ...846976.
  215. const v = JSON.parse('[1152921504606846976]') as unknown
  216. expect(encodeJsonPlain(v)).toBe('[1152921504606846976]')
  217. expect(checkDoneValue(v, 100)).toEqual({ ok: true, bytes: Buffer.byteLength('[1152921504606846976]', 'utf8') })
  218. })
  219. })