realm.spec.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. import { describe, expect, it } from 'vitest'
  2. import * as vm from 'node:vm'
  3. import { materializeFromRealm, MaterializeError, renderThrown } from '../src/realm.ts'
  4. /** Evaluate an expression inside a fresh vm realm and hand back the raw realm value. */
  5. function inRealm(expression: string): unknown {
  6. return vm.runInNewContext(`(${expression})`)
  7. }
  8. /** The MaterializeError message for a value that must be rejected (throws if accepted). */
  9. function rejection(value: unknown): string {
  10. try {
  11. materializeFromRealm(value)
  12. } catch (error: unknown) {
  13. if (error instanceof MaterializeError) return error.message
  14. throw error
  15. }
  16. throw new Error('expected the value to be rejected')
  17. }
  18. describe('materializeFromRealm', () => {
  19. it('copies realm objects/arrays/scalars into host plain data', () => {
  20. const value = inRealm("{ a: 1, b: 'x', c: true, d: null, list: [1, [2, { deep: 'y' }]] }")
  21. const out = materializeFromRealm(value) as Record<string, unknown>
  22. expect(out).toEqual({ a: 1, b: 'x', c: true, d: null, list: [1, [2, { deep: 'y' }]] })
  23. // The copy is HOST data: prototypes are the host intrinsics.
  24. expect(Object.getPrototypeOf(out)).toBe(Object.prototype)
  25. expect(Array.isArray(out.list)).toBe(true)
  26. // And it round-trips through JSON byte-identically (the whole point).
  27. expect(JSON.parse(JSON.stringify(out))).toEqual(out)
  28. })
  29. it('accepts undefined ONLY at the root (a valueless script return)', () => {
  30. expect(materializeFromRealm(undefined)).toBeUndefined()
  31. expect(rejection(inRealm('{ a: undefined }'))).toContain('value.a')
  32. })
  33. it('invokes getters ordinarily — the getter RESULT is what crosses (trust premise)', () => {
  34. const counter = inRealm(`
  35. (() => {
  36. globalThis.reads = 0
  37. return { get x() { globalThis.reads += 1; return globalThis.reads } }
  38. })()
  39. `)
  40. expect(materializeFromRealm(counter)).toEqual({ x: 1 })
  41. })
  42. it('a getter that THROWS surfaces as a MaterializeError carrying the rendered failure', () => {
  43. const hostile = inRealm("{ get x() { throw new Error('read failed') } }")
  44. const message = rejection(hostile)
  45. expect(message).toContain('reading the value threw')
  46. expect(message).toContain('read failed')
  47. })
  48. it('a "__proto__" key becomes an OWN data property of the copy, never a prototype mutation', () => {
  49. const value: unknown = vm.runInNewContext('JSON.parse(\'{"__proto__": {"polluted": 1}, "ok": 2}\')')
  50. const out = materializeFromRealm(value) as Record<string, unknown>
  51. expect(Object.getPrototypeOf(out)).toBe(Object.prototype)
  52. expect(Object.prototype.hasOwnProperty.call(out, '__proto__')).toBe(true)
  53. expect(out.ok).toBe(2)
  54. // The host Object.prototype was NOT touched.
  55. expect(({} as Record<string, unknown>).polluted).toBeUndefined()
  56. })
  57. it('rejects functions, symbols (keys and values), and bigints with path-qualified messages', () => {
  58. expect(rejection(inRealm('{ fn: () => 1 }'))).toContain('value.fn')
  59. expect(rejection(inRealm("{ [Symbol('k')]: 1 }"))).toContain('symbol-keyed')
  60. expect(rejection(inRealm("{ s: Symbol('v') }"))).toContain('value.s')
  61. expect(rejection(inRealm('{ big: 1n }'))).toContain('value.big')
  62. expect(rejection(inRealm("[Symbol('x')]"))).toContain('value[0]')
  63. const taggedArray = inRealm("(() => { const a = [1]; a[Symbol('t')] = 1; return a })()")
  64. expect(rejection(taggedArray)).toContain('symbol-keyed')
  65. })
  66. it('rejects non-finite numbers and undefined values inside containers', () => {
  67. expect(rejection(inRealm('{ n: NaN }'))).toContain('non-finite')
  68. expect(rejection(inRealm('[Infinity]'))).toContain('non-finite')
  69. })
  70. it('rejects exotic prototypes (Date, Map, class instances) but accepts null-prototype data', () => {
  71. expect(rejection(inRealm('{ d: new Date(0) }'))).toContain('exotic prototype')
  72. expect(rejection(inRealm('new Map()'))).toContain('exotic prototype')
  73. expect(rejection(inRealm('(() => { class C { constructor() { this.x = 1 } } return new C() })()')))
  74. .toContain('exotic prototype')
  75. expect(materializeFromRealm(inRealm('Object.assign(Object.create(null), { a: 1 })'))).toEqual({ a: 1 })
  76. })
  77. it('rejects cycles and accepts the same object reused as a sibling (a DAG)', () => {
  78. expect(rejection(inRealm('(() => { const o = {}; o.self = o; return o })()'))).toContain('circular')
  79. const dag = inRealm('(() => { const leaf = { v: 1 }; return { a: leaf, b: leaf } })()')
  80. expect(materializeFromRealm(dag)).toEqual({ a: { v: 1 }, b: { v: 1 } })
  81. })
  82. it('rejects sparse arrays and non-index array properties; an array getter element materializes its value', () => {
  83. expect(rejection(inRealm('[1, , 3]'))).toContain('sparse')
  84. expect(rejection(inRealm('(() => { const a = [1]; a.total = 3; return a })()')))
  85. .toContain('non-index')
  86. expect(materializeFromRealm(inRealm('(() => { const a = [1]; Object.defineProperty(a, 0, { get: () => 7, enumerable: true }); return a })()')))
  87. .toEqual([7])
  88. })
  89. it('skips non-enumerable own properties (matching JSON.stringify exactly)', () => {
  90. const value = inRealm(`(() => {
  91. const o = { visible: 1 }
  92. Object.defineProperty(o, 'hidden', { value: () => 1, enumerable: false })
  93. return o
  94. })()`)
  95. expect(materializeFromRealm(value)).toEqual({ visible: 1 })
  96. })
  97. it('works on plain host values too (the boundary is realm-agnostic)', () => {
  98. expect(materializeFromRealm({ a: [1, 'x'] })).toEqual({ a: [1, 'x'] })
  99. expect(materializeFromRealm('str')).toBe('str')
  100. expect(materializeFromRealm(3)).toBe(3)
  101. expect(materializeFromRealm(false)).toBe(false)
  102. expect(materializeFromRealm(null)).toBeNull()
  103. })
  104. })
  105. describe('renderThrown', () => {
  106. it('prefers the stack, for host and realm errors alike', () => {
  107. const host = renderThrown(new Error('host failure'))
  108. expect(host).toContain('host failure')
  109. expect(host).toContain('at ') // a real stack, not just the message
  110. const realmError: unknown = vm.runInNewContext('(() => { try { throw new Error("realm failure") } catch (e) { return e } })()')
  111. expect(renderThrown(realmError)).toContain('realm failure')
  112. })
  113. it('falls back from stack to message to String()', () => {
  114. expect(renderThrown({ stack: 'custom data stack' })).toBe('custom data stack')
  115. const stackless = new Error('stackless failure')
  116. delete stackless.stack
  117. expect(renderThrown(stackless)).toBe('stackless failure')
  118. expect(renderThrown({ code: 42 })).toBe('[object Object]')
  119. expect(renderThrown('plain')).toBe('plain')
  120. expect(renderThrown(42)).toBe('42')
  121. expect(renderThrown(undefined)).toBe('undefined')
  122. expect(renderThrown(null)).toBe('null')
  123. })
  124. it('is total: a value whose accessors/toString throw renders as a fixed label', () => {
  125. expect(renderThrown({ get stack() { throw new Error('nope') } })).toBe('[unrenderable thrown value]')
  126. expect(renderThrown({ [Symbol.toPrimitive]() { throw new Error('nope') } })).toBe('[unrenderable thrown value]')
  127. })
  128. })