diff --git a/packages/maple/docs/boundary.md b/packages/maple/docs/boundary.md index b7d0c4f..72bb184 100644 --- a/packages/maple/docs/boundary.md +++ b/packages/maple/docs/boundary.md @@ -11,6 +11,7 @@ globals), the arguments of a host function, its return value, and the result of | Sandbox error | – | plain object | – | plain object | | Function | host function | `undefined` | `INTERNAL_ERROR` | `undefined` | | Iterator | – | `TypeError` | – | `undefined` | +| `WeakMap`, `WeakSet` | `TypeError` before | `TypeError` | `INTERNAL_ERROR` | `undefined` | | Anything else | `TypeError` before | – | `INTERNAL_ERROR` | – | | Value reached twice | shared | shared | shared | shared | | Cycle | kept | `TypeError` | kept | kept | diff --git a/packages/maple/docs/builtins.md b/packages/maple/docs/builtins.md index 373393d..0021e73 100644 --- a/packages/maple/docs/builtins.md +++ b/packages/maple/docs/builtins.md @@ -12,7 +12,7 @@ data key of the same name shadows them: `undefined`, `NaN`, `Infinity`, `parseInt`, `parseFloat`, `isNaN`, `isFinite`, `encodeURIComponent`, `decodeURIComponent`, `encodeURI`, `decodeURI`, `escape`, `unescape`, `String`, `Number`, `Boolean`, `Array`, `Object`, `Math`, `JSON`, `Date`, `RegExp`, `Map`, `Set`, -`Iterator`, `Error`, `TypeError`, `RangeError`, `ReferenceError`, `SyntaxError`, `URIError`, +`WeakMap`, `WeakSet`, `Iterator`, `Error`, `TypeError`, `RangeError`, `ReferenceError`, `SyntaxError`, `URIError`, `EvalError`, `atob`, `btoa`. `atob` and `btoa` are web APIs rather than ECMAScript, provided because Node, browsers and workers @@ -134,6 +134,16 @@ Set: `add`, `has`, `delete`, `clear`, `forEach`, `size`, `keys`, `values`, `entr The set algebra takes a Set or a Map as the other operand. JavaScript's other set-like objects are refused with a `TypeError`, so the host never calls a script `has` or `keys`. +## WeakMap and WeakSet + +WeakMap: `get`, `set`, `has`, `delete`. + +WeakSet: `add`, `has`, `delete`. + +Any object can be a key or an entry, functions and built-ins included. Scripts have no symbols, so +nothing else can. A WeakMap or WeakSet never comes in with the input and leaves a run as +`undefined` ([Boundary](boundary.md)). + ## Iterators `keys`, `values` and `entries` of arrays, Maps and Sets, and `matchAll`, return iterators with diff --git a/packages/maple/docs/language.md b/packages/maple/docs/language.md index ef79e64..739a8f4 100644 --- a/packages/maple/docs/language.md +++ b/packages/maple/docs/language.md @@ -43,7 +43,7 @@ function declaration that is hoisting time, so the script fails before its first Reading one of these names also throws `DISALLOWED_SYNTAX`, unless the script or the host binds it: -- Language: `Proxy`, `Reflect`, `Symbol`, `WeakMap`, `WeakSet`, `WeakRef`, `FinalizationRegistry`, +- Language: `Proxy`, `Reflect`, `Symbol`, `WeakRef`, `FinalizationRegistry`, `Promise`, `SharedArrayBuffer`, `Atomics`, `WebAssembly`, `Intl`, `globalThis`, `eval`, `Function` - Server runtimes: `require`, `module`, `exports`, `process`, `global`, `Buffer`, `__dirname`, `__filename`, `Deno`, `Bun` @@ -58,12 +58,12 @@ name. `typeof` never throws for a name that is not bound, so `typeof window !== ## Constructors `new` is allowed for `Error`, `TypeError`, `RangeError`, `ReferenceError`, `SyntaxError`, -`URIError`, `EvalError`, `Date`, `RegExp`, `Map`, `Set`, `Array` and `Object`. Any other `new` -target, including a script function or a bound function, is `DISALLOWED_SYNTAX`. There are no -primitive wrapper objects: `String`, `Number` and `Boolean` are conversion functions only, so -`new String('a')` is rejected, and `Object('a')` is a `TypeError`. +`URIError`, `EvalError`, `Date`, `RegExp`, `Map`, `Set`, `WeakMap`, `WeakSet`, `Array` and +`Object`. Any other `new` target, including a script function or a bound function, is +`DISALLOWED_SYNTAX`. There are no primitive wrapper objects: `String`, `Number` and `Boolean` are +conversion functions only, so `new String('a')` is rejected, and `Object('a')` is a `TypeError`. -`instanceof` accepts those thirteen plus `Iterator` on the right-hand side. Any other right-hand +`instanceof` accepts those fifteen plus `Iterator` on the right-hand side. Any other right-hand side, a script function, a bound function or `String` included, throws a `TypeError`. ## Functions @@ -98,16 +98,16 @@ arrays of its own, even for the same source. Inside the sandbox exactly these values exist: -| Kind | Representation | `typeof` | -| -------------- | ------------------------------------------------------------------------- | ------------ | -| Primitives | Host primitives: `undefined`, `null`, booleans, numbers, strings | as in JS | -| Plain objects | Host objects created by Maple or by the input copy; own properties only | `'object'` | -| Arrays | Host arrays created by Maple or by the input copy | `'object'` | -| Closures | Maple's own function objects | `'function'` | -| Host functions | Branded wrappers; the native function is not reachable | `'function'` | -| Intrinsics | Branded wrappers, same surface as host functions | `'function'` | -| Wrappers | Branded wrappers around a native `RegExp`, `Date`, `Map`, `Set`, iterator | `'object'` | -| Errors | Maple's own error objects with `name` and `message` | `'object'` | +| Kind | Representation | `typeof` | +| -------------- | ----------------------------------------------------------------------------------------------- | ------------ | +| Primitives | Host primitives: `undefined`, `null`, booleans, numbers, strings | as in JS | +| Plain objects | Host objects created by Maple or by the input copy; own properties only | `'object'` | +| Arrays | Host arrays created by Maple or by the input copy | `'object'` | +| Closures | Maple's own function objects | `'function'` | +| Host functions | Branded wrappers; the native function is not reachable | `'function'` | +| Intrinsics | Branded wrappers, same surface as host functions | `'function'` | +| Wrappers | Branded wrappers around a native `RegExp`, `Date`, `Map`, `Set`, `WeakMap`, `WeakSet`, iterator | `'object'` | +| Errors | Maple's own error objects with `name` and `message` | `'object'` | Wrappers are instances of Maple's own classes, which a script cannot construct, so a plain object can never pose as one. The native value inside is never exposed; every method goes through a diff --git a/packages/maple/src/boundary/from-host.spec.ts b/packages/maple/src/boundary/from-host.spec.ts index 35ee84a..5c5c53e 100644 --- a/packages/maple/src/boundary/from-host.spec.ts +++ b/packages/maple/src/boundary/from-host.spec.ts @@ -129,6 +129,12 @@ describe('fromHost', () => { expect(() => fromHost([1, Symbol('s')], 'data')).toThrow( new TypeError('Unsupported value at data[1]: symbol') ); + expect(() => fromHost({ cache: new WeakMap() }, 'data')).toThrow( + new TypeError('Unsupported value at data.cache: WeakMap') + ); + expect(() => fromHost([new WeakSet()], 'data')).toThrow( + new TypeError('Unsupported value at data[0]: WeakSet') + ); expect(() => fromHost(new Uint8Array(1), 'data')).toThrow( new TypeError('Unsupported value at data: Uint8Array') ); diff --git a/packages/maple/src/boundary/to-host.spec.ts b/packages/maple/src/boundary/to-host.spec.ts index 61396cb..794e4ae 100644 --- a/packages/maple/src/boundary/to-host.spec.ts +++ b/packages/maple/src/boundary/to-host.spec.ts @@ -9,6 +9,8 @@ import { IteratorWrapper } from '../values/wrappers/iterator-wrapper.ts'; import { MapWrapper } from '../values/wrappers/map-wrapper.ts'; import { RegExpWrapper } from '../values/wrappers/regexp-wrapper.ts'; import { SetWrapper } from '../values/wrappers/set-wrapper.ts'; +import { WeakMapWrapper } from '../values/wrappers/weak-map-wrapper.ts'; +import { WeakSetWrapper } from '../values/wrappers/weak-set-wrapper.ts'; import { fromHost } from './from-host.ts'; import { toHost, UnconvertibleValueError } from './to-host.ts'; @@ -154,6 +156,19 @@ describe('toHost', () => { expect(toHost(new IteratorWrapper([1].values()))).toBeUndefined(); }); + it('drops WeakMaps and WeakSets, and refuses them when strict', () => { + expect(toHost({ map: new WeakMapWrapper(), set: new WeakSetWrapper() })).toStrictEqual({ + map: undefined, + set: undefined + }); + expect(() => toHost(new WeakMapWrapper(), true)).toThrow( + new UnconvertibleValueError('WeakMap') + ); + expect(() => toHost(new WeakSetWrapper(), true)).toThrow( + new UnconvertibleValueError('WeakSet') + ); + }); + it('refuses values that cannot exist in the sandbox', () => { expect(() => toHost(new Date(0))).toThrow(Error); }); diff --git a/packages/maple/src/boundary/to-host.ts b/packages/maple/src/boundary/to-host.ts index b1bf4d7..757b3cf 100644 --- a/packages/maple/src/boundary/to-host.ts +++ b/packages/maple/src/boundary/to-host.ts @@ -10,6 +10,8 @@ import { IteratorWrapper } from '../values/wrappers/iterator-wrapper.ts'; import { MapWrapper } from '../values/wrappers/map-wrapper.ts'; import { RegExpWrapper } from '../values/wrappers/regexp-wrapper.ts'; import { SetWrapper } from '../values/wrappers/set-wrapper.ts'; +import { WeakMapWrapper } from '../values/wrappers/weak-map-wrapper.ts'; +import { WeakSetWrapper } from '../values/wrappers/weak-set-wrapper.ts'; import type { SandboxErrorInfo } from './maple-error.ts'; /** @@ -18,7 +20,7 @@ import type { SandboxErrorInfo } from './maple-error.ts'; */ export const MAX_ERROR_PROPERTIES_SIZE = 65_536; -type UnconvertibleKind = 'iterator' | 'cyclic value'; +type UnconvertibleKind = 'iterator' | 'WeakMap' | 'WeakSet' | 'cyclic value'; /** * Keys a copy leaves out. Host code that copies by assignment would turn an own `__proto__` into @@ -49,9 +51,10 @@ export class NestingLimitError extends RangeError { /** * Copies a sandbox value out for the host: a fresh, mutable structure that shares nothing with the - * run and contains nothing executable. Functions become `undefined`. Iterators become `undefined` - * and cycles are preserved, or, when `strict` as for host function arguments, both throw an - * `UnconvertibleValueError`. Throws a plain error for a value the sandbox could not have produced. + * run and contains nothing executable. Functions become `undefined`. Iterators, WeakMaps and + * WeakSets become `undefined` and cycles are preserved, or, when `strict` as for host function + * arguments, all four throw an `UnconvertibleValueError`. Throws a plain error for a value the + * sandbox could not have produced. */ export function toHost(value: unknown, strict: boolean = false): unknown { return convert(value, { copies: new Map(), open: new Set(), strict, acyclic: false }, 0); @@ -116,6 +119,12 @@ function convert(value: unknown, walk: Walk, depth: number): unknown { } return undefined; } + if (value instanceof WeakMapWrapper || value instanceof WeakSetWrapper) { + if (walk.strict) { + throw new UnconvertibleValueError(value.tag); + } + return undefined; + } if (walk.open.has(value)) { if (walk.strict) { diff --git a/packages/maple/src/builtins/map/map.spec.ts b/packages/maple/src/builtins/map/map.spec.ts index 6370641..2bcf204 100644 --- a/packages/maple/src/builtins/map/map.spec.ts +++ b/packages/maple/src/builtins/map/map.spec.ts @@ -56,6 +56,18 @@ describe('Map', () => { expect(run(`${setup} const [[k]] = m; return k;`)).toBe('b'); }); + it('reads each entry like entry[0] and entry[1], so any object is a pair', () => { + expect(run('return new Map([{ 0: "a", 1: 1 }]).get("a");')).toBe(1); + expect(run('return new Map([{}]).get(undefined);')).toBeUndefined(); + expect(run('return new Map([{}]).has(undefined);')).toBe(true); + expect( + run('const pair = new Set(); pair[0] = "k"; pair[1] = 7; return new Map([pair]).get("k");') + ).toBe(7); + expect(run('try { new Map([1]); } catch (e) { return e.message; }')).toBe( + 'Iterator value 1 is not an entry object' + ); + }); + it('forEach calls its callback with the thisArg as this', () => { expect( run( diff --git a/packages/maple/src/builtins/map/map.ts b/packages/maple/src/builtins/map/map.ts index 4123eba..c4cc666 100644 --- a/packages/maple/src/builtins/map/map.ts +++ b/packages/maple/src/builtins/map/map.ts @@ -2,6 +2,7 @@ import { Builtin } from '../../values/builtin.ts'; import { describeValue } from '../../values/coercion.ts'; import { iterate } from '../../values/iteration.ts'; import { MapWrapper } from '../../values/wrappers/map-wrapper.ts'; +import { getMember } from '../method-dispatch.ts'; import { MAP_PROTOTYPE } from './map-prototype.ts'; import { MAP_STATICS } from './map-statics.ts'; @@ -9,7 +10,8 @@ export const MAP_BUILTIN: Builtin = new Builtin({ name: 'Map', length: Map.length, call: (_args, { fail }) => fail('TypeError', "Constructor Map requires 'new'"), - // `new Map(iterable?)`; each entry must be an array of key and value + // `new Map(iterable?)`; each entry is read like `entry[0]` and `entry[1]`, so any object works + // as a pair construct: ([iterable], context) => { const map = new Map(); if (iterable === undefined || iterable === null) { @@ -23,13 +25,13 @@ export const MAP_BUILTIN: Builtin = new Builtin({ for (let step = iterator.next(); !step.done; step = iterator.next()) { const entry: unknown = step.value; - if (!Array.isArray(entry)) { + if (typeof entry !== 'object' || entry === null) { return context.fail( 'TypeError', `Iterator value ${describeValue(entry)} is not an entry object` ); } - map.set(entry[0], entry[1]); + map.set(getMember(entry, '0', context), getMember(entry, '1', context)); } return new MapWrapper(map); diff --git a/packages/maple/src/builtins/method-dispatch.ts b/packages/maple/src/builtins/method-dispatch.ts index b1dfb06..365f131 100644 --- a/packages/maple/src/builtins/method-dispatch.ts +++ b/packages/maple/src/builtins/method-dispatch.ts @@ -13,6 +13,8 @@ import { OBJECT_PROTOTYPE } from './object/object-prototype.ts'; import { REGEXP_PROTOTYPE } from './regexp/regexp-prototype.ts'; import { SET_PROTOTYPE } from './set/set-prototype.ts'; import { STRING_PROTOTYPE } from './string/string-prototype.ts'; +import { WEAK_MAP_PROTOTYPE } from './weak-map/weak-map-prototype.ts'; +import { WEAK_SET_PROTOTYPE } from './weak-set/weak-set-prototype.ts'; /** * Every prototype a value can have, tried in order, so arrays find theirs before plain objects @@ -28,6 +30,8 @@ const PROTOTYPES: ReadonlyArray = [ DATE_PROTOTYPE, MAP_PROTOTYPE, SET_PROTOTYPE, + WEAK_MAP_PROTOTYPE, + WEAK_SET_PROTOTYPE, ERROR_PROTOTYPE, ITERATOR_PROTOTYPE, FUNCTION_PROTOTYPE diff --git a/packages/maple/src/builtins/standard-globals.ts b/packages/maple/src/builtins/standard-globals.ts index 3f46b6e..4a08b54 100644 --- a/packages/maple/src/builtins/standard-globals.ts +++ b/packages/maple/src/builtins/standard-globals.ts @@ -25,6 +25,8 @@ import { OBJECT_BUILTIN } from './object/object.ts'; import { REGEXP_BUILTIN } from './regexp/regexp.ts'; import { SET_BUILTIN } from './set/set.ts'; import { STRING_BUILTIN } from './string/string.ts'; +import { WEAK_MAP_BUILTIN } from './weak-map/weak-map.ts'; +import { WEAK_SET_BUILTIN } from './weak-set/weak-set.ts'; // The library is typechecked without host libs; these web APIs exist in Node, browsers and workers declare const atob: (data: string) => string; @@ -67,6 +69,8 @@ export const STANDARD_GLOBALS: Readonly> = Object.freeze RegExp: REGEXP_BUILTIN, Map: MAP_BUILTIN, Set: SET_BUILTIN, + WeakMap: WEAK_MAP_BUILTIN, + WeakSet: WEAK_SET_BUILTIN, Iterator: ITERATOR_BUILTIN }); diff --git a/packages/maple/src/builtins/weak-map/methods/delete.ts b/packages/maple/src/builtins/weak-map/methods/delete.ts new file mode 100644 index 0000000..29b8d8f --- /dev/null +++ b/packages/maple/src/builtins/weak-map/methods/delete.ts @@ -0,0 +1,8 @@ +import { canBeHeldWeakly } from '../../../support/can-be-held-weakly.ts'; +import { method, type Method } from '../../../values/native.ts'; +import type { WeakMapWrapper } from '../../../values/wrappers/weak-map-wrapper.ts'; + +export const WEAK_MAP_METHOD_DELETE: Method = method( + WeakMap.prototype.delete.length, + (m, [key]) => canBeHeldWeakly(key) && m.map.delete(key) +); diff --git a/packages/maple/src/builtins/weak-map/methods/get.ts b/packages/maple/src/builtins/weak-map/methods/get.ts new file mode 100644 index 0000000..9c2e1d8 --- /dev/null +++ b/packages/maple/src/builtins/weak-map/methods/get.ts @@ -0,0 +1,8 @@ +import { canBeHeldWeakly } from '../../../support/can-be-held-weakly.ts'; +import { method, type Method } from '../../../values/native.ts'; +import type { WeakMapWrapper } from '../../../values/wrappers/weak-map-wrapper.ts'; + +export const WEAK_MAP_METHOD_GET: Method = method( + WeakMap.prototype.get.length, + (m, [key]) => (canBeHeldWeakly(key) ? m.map.get(key) : undefined) +); diff --git a/packages/maple/src/builtins/weak-map/methods/has.ts b/packages/maple/src/builtins/weak-map/methods/has.ts new file mode 100644 index 0000000..16d2c86 --- /dev/null +++ b/packages/maple/src/builtins/weak-map/methods/has.ts @@ -0,0 +1,8 @@ +import { canBeHeldWeakly } from '../../../support/can-be-held-weakly.ts'; +import { method, type Method } from '../../../values/native.ts'; +import type { WeakMapWrapper } from '../../../values/wrappers/weak-map-wrapper.ts'; + +export const WEAK_MAP_METHOD_HAS: Method = method( + WeakMap.prototype.has.length, + (m, [key]) => canBeHeldWeakly(key) && m.map.has(key) +); diff --git a/packages/maple/src/builtins/weak-map/methods/set.ts b/packages/maple/src/builtins/weak-map/methods/set.ts new file mode 100644 index 0000000..8ae313d --- /dev/null +++ b/packages/maple/src/builtins/weak-map/methods/set.ts @@ -0,0 +1,17 @@ +import { canBeHeldWeakly } from '../../../support/can-be-held-weakly.ts'; +import { method, type Method } from '../../../values/native.ts'; +import type { WeakMapWrapper } from '../../../values/wrappers/weak-map-wrapper.ts'; + +export const INVALID_WEAK_MAP_KEY = 'Invalid value used as weak map key'; + +/** A primitive key throws, as in JavaScript: `new WeakMap().set('a', 1)`. */ +export const WEAK_MAP_METHOD_SET: Method = method( + WeakMap.prototype.set.length, + (m, [key, value], { fail }) => { + if (!canBeHeldWeakly(key)) { + return fail('TypeError', INVALID_WEAK_MAP_KEY); + } + m.map.set(key, value); + return m; + } +); diff --git a/packages/maple/src/builtins/weak-map/weak-map-prototype.ts b/packages/maple/src/builtins/weak-map/weak-map-prototype.ts new file mode 100644 index 0000000..b5dfff9 --- /dev/null +++ b/packages/maple/src/builtins/weak-map/weak-map-prototype.ts @@ -0,0 +1,21 @@ +import { Prototype } from '../../values/prototype.ts'; +import { WeakMapWrapper } from '../../values/wrappers/weak-map-wrapper.ts'; +import { WEAK_MAP_METHOD_DELETE } from './methods/delete.ts'; +import { WEAK_MAP_METHOD_GET } from './methods/get.ts'; +import { WEAK_MAP_METHOD_HAS } from './methods/has.ts'; +import { WEAK_MAP_METHOD_SET } from './methods/set.ts'; + +/** + * Keys are compared by identity in the host `WeakMap`, so `wm.get({})` never finds an entry stored + * under another `{}`. Nothing lists the entries, so a script cannot tell whether one was collected. + */ +export const WEAK_MAP_PROTOTYPE: Prototype = new Prototype( + 'WeakMap', + (value): value is WeakMapWrapper => value instanceof WeakMapWrapper, + { + delete: WEAK_MAP_METHOD_DELETE, + get: WEAK_MAP_METHOD_GET, + has: WEAK_MAP_METHOD_HAS, + set: WEAK_MAP_METHOD_SET + } +); diff --git a/packages/maple/src/builtins/weak-map/weak-map.spec.ts b/packages/maple/src/builtins/weak-map/weak-map.spec.ts new file mode 100644 index 0000000..dee450d --- /dev/null +++ b/packages/maple/src/builtins/weak-map/weak-map.spec.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { Maple } from '../../maple.ts'; +import { runAndCatch } from '../../spec-helpers.ts'; + +const maple = new Maple(); + +function run(code: string): unknown { + return maple.run(code).value; +} + +function errorOf(code: string): unknown { + const error = runAndCatch(maple, code).sandboxError; + return [error?.name, error?.message]; +} + +describe('WeakMap', () => { + it('stores values under object keys by identity', () => { + expect(run('const k = {}; const wm = new WeakMap(); wm.set(k, 1); return wm.get(k);')).toBe(1); + expect(run('const wm = new WeakMap(); wm.set({}, 1); return wm.get({});')).toBeUndefined(); + expect(run('const k = []; return new WeakMap().set(k, 1).set(k, 2).get(k);')).toBe(2); + expect(run('const wm = new WeakMap(); return wm.set({}, 1) === wm;')).toBe(true); + }); + + it('answers has and delete', () => { + expect(run('const k = {}; const wm = new WeakMap([[k, 1]]); return wm.has(k);')).toBe(true); + expect(run('return new WeakMap().has({});')).toBe(false); + expect( + run('const k = {}; const wm = new WeakMap([[k, 1]]); return [wm.delete(k), wm.has(k)];') + ).toEqual([true, false]); + expect(run('return new WeakMap().delete({});')).toBe(false); + }); + + it('takes any object as a key, functions and built-ins included', () => { + expect(run('const f = () => 1; return new WeakMap([[f, "f"]]).get(f);')).toBe('f'); + expect(run('return new WeakMap([[Math, "m"]]).get(Math);')).toBe('m'); + expect(run('const m = new Map(); return new WeakMap([[m, 1]]).has(m);')).toBe(true); + expect(run('const wm = new WeakMap(); wm.set(wm, 1); return wm.get(wm);')).toBe(1); + }); + + it('refuses primitive keys when storing them', () => { + expect(errorOf('new WeakMap().set(1, 2);')).toEqual([ + 'TypeError', + 'Invalid value used as weak map key' + ]); + expect(errorOf('new WeakMap().set(null, 2);')).toEqual([ + 'TypeError', + 'Invalid value used as weak map key' + ]); + expect(errorOf('new WeakMap([["a", 1]]);')).toEqual([ + 'TypeError', + 'Invalid value used as weak map key' + ]); + }); + + it('looks primitive keys up without throwing', () => { + expect(run('return new WeakMap().get("a");')).toBeUndefined(); + expect(run('return new WeakMap().has(1);')).toBe(false); + expect(run('return new WeakMap().delete(undefined);')).toBe(false); + }); + + it('constructs from an iterable of entries', () => { + expect(run('const a = {}; const b = {}; return new WeakMap([[a, 1], [b, 2]]).get(b);')).toBe(2); + expect(run('return new WeakMap(null).has({});')).toBe(false); + expect(run('const k = {}; return new WeakMap([{ 0: k, 1: 42 }]).get(k);')).toBe(42); + expect( + run( + 'const k = {}; const pair = new Set(); pair[0] = k; pair[1] = 7; return new WeakMap([pair]).get(k);' + ) + ).toBe(7); + expect(errorOf('new WeakMap(5);')).toEqual(['TypeError', '5 is not iterable']); + expect(errorOf('new WeakMap([1]);')).toEqual([ + 'TypeError', + 'Iterator value 1 is not an entry object' + ]); + expect(errorOf('new WeakMap([{}]);')).toEqual([ + 'TypeError', + 'Invalid value used as weak map key' + ]); + }); + + it('requires new and reports its type', () => { + expect(errorOf('WeakMap();')).toEqual(['TypeError', "Constructor WeakMap requires 'new'"]); + expect(run('return new WeakMap() instanceof WeakMap;')).toBe(true); + expect(run('return new Map() instanceof WeakMap;')).toBe(false); + expect(run('return typeof new WeakMap();')).toBe('object'); + expect(run('return String(new WeakMap());')).toBe('[object WeakMap]'); + expect( + run('return [WeakMap.length, new WeakMap().set.length, new WeakMap().get.length];') + ).toEqual([0, 2, 1]); + }); + + it('keeps properties a script adds, and lists no entries', () => { + expect(run('const wm = new WeakMap(); wm.label = "x"; return wm.label;')).toBe('x'); + expect(run('return Object.keys(new WeakMap([[{}, 1]]));')).toEqual([]); + expect(run('return JSON.stringify(new WeakMap([[{}, 1]]));')).toBe('{}'); + expect(runAndCatch(maple, '[...new WeakMap()];').sandboxError?.name).toBe('TypeError'); + }); + + it('leaves the run as undefined', () => { + expect(run('return new WeakMap();')).toBeUndefined(); + expect(run('return { cache: new WeakMap(), n: 1 };')).toStrictEqual({ + cache: undefined, + n: 1 + }); + }); +}); diff --git a/packages/maple/src/builtins/weak-map/weak-map.ts b/packages/maple/src/builtins/weak-map/weak-map.ts new file mode 100644 index 0000000..9bffbf5 --- /dev/null +++ b/packages/maple/src/builtins/weak-map/weak-map.ts @@ -0,0 +1,46 @@ +import { canBeHeldWeakly } from '../../support/can-be-held-weakly.ts'; +import { Builtin } from '../../values/builtin.ts'; +import { describeValue } from '../../values/coercion.ts'; +import { iterate } from '../../values/iteration.ts'; +import { WeakMapWrapper } from '../../values/wrappers/weak-map-wrapper.ts'; +import { getMember } from '../method-dispatch.ts'; +import { INVALID_WEAK_MAP_KEY } from './methods/set.ts'; +import { WEAK_MAP_PROTOTYPE } from './weak-map-prototype.ts'; + +export const WEAK_MAP_BUILTIN: Builtin = new Builtin({ + name: 'WeakMap', + length: WeakMap.length, + call: (_args, { fail }) => fail('TypeError', "Constructor WeakMap requires 'new'"), + // `new WeakMap(iterable?)`; each entry is read like `entry[0]` and `entry[1]`, so any object + // works as a pair, and its key must be an object + construct: ([iterable], context) => { + const wrapper = new WeakMapWrapper(); + if (iterable === undefined || iterable === null) { + return wrapper; + } + + const iterator = iterate(iterable); + if (!iterator) { + return context.fail('TypeError', `${describeValue(iterable)} is not iterable`); + } + + for (let step = iterator.next(); !step.done; step = iterator.next()) { + const entry: unknown = step.value; + if (typeof entry !== 'object' || entry === null) { + return context.fail( + 'TypeError', + `Iterator value ${describeValue(entry)} is not an entry object` + ); + } + const key = getMember(entry, '0', context); + if (!canBeHeldWeakly(key)) { + return context.fail('TypeError', INVALID_WEAK_MAP_KEY); + } + wrapper.map.set(key, getMember(entry, '1', context)); + } + + return wrapper; + }, + isInstance: (value) => value instanceof WeakMapWrapper, + prototype: WEAK_MAP_PROTOTYPE +}); diff --git a/packages/maple/src/builtins/weak-set/methods/add.ts b/packages/maple/src/builtins/weak-set/methods/add.ts new file mode 100644 index 0000000..28aab6b --- /dev/null +++ b/packages/maple/src/builtins/weak-set/methods/add.ts @@ -0,0 +1,17 @@ +import { canBeHeldWeakly } from '../../../support/can-be-held-weakly.ts'; +import { method, type Method } from '../../../values/native.ts'; +import type { WeakSetWrapper } from '../../../values/wrappers/weak-set-wrapper.ts'; + +export const INVALID_WEAK_SET_VALUE = 'Invalid value used in weak set'; + +/** A primitive throws, as in JavaScript: `new WeakSet().add('a')`. */ +export const WEAK_SET_METHOD_ADD: Method = method( + WeakSet.prototype.add.length, + (s, [value], { fail }) => { + if (!canBeHeldWeakly(value)) { + return fail('TypeError', INVALID_WEAK_SET_VALUE); + } + s.set.add(value); + return s; + } +); diff --git a/packages/maple/src/builtins/weak-set/methods/delete.ts b/packages/maple/src/builtins/weak-set/methods/delete.ts new file mode 100644 index 0000000..bfac0e8 --- /dev/null +++ b/packages/maple/src/builtins/weak-set/methods/delete.ts @@ -0,0 +1,8 @@ +import { canBeHeldWeakly } from '../../../support/can-be-held-weakly.ts'; +import { method, type Method } from '../../../values/native.ts'; +import type { WeakSetWrapper } from '../../../values/wrappers/weak-set-wrapper.ts'; + +export const WEAK_SET_METHOD_DELETE: Method = method( + WeakSet.prototype.delete.length, + (s, [value]) => canBeHeldWeakly(value) && s.set.delete(value) +); diff --git a/packages/maple/src/builtins/weak-set/methods/has.ts b/packages/maple/src/builtins/weak-set/methods/has.ts new file mode 100644 index 0000000..7ca7980 --- /dev/null +++ b/packages/maple/src/builtins/weak-set/methods/has.ts @@ -0,0 +1,8 @@ +import { canBeHeldWeakly } from '../../../support/can-be-held-weakly.ts'; +import { method, type Method } from '../../../values/native.ts'; +import type { WeakSetWrapper } from '../../../values/wrappers/weak-set-wrapper.ts'; + +export const WEAK_SET_METHOD_HAS: Method = method( + WeakSet.prototype.has.length, + (s, [value]) => canBeHeldWeakly(value) && s.set.has(value) +); diff --git a/packages/maple/src/builtins/weak-set/weak-set-prototype.ts b/packages/maple/src/builtins/weak-set/weak-set-prototype.ts new file mode 100644 index 0000000..51f4c31 --- /dev/null +++ b/packages/maple/src/builtins/weak-set/weak-set-prototype.ts @@ -0,0 +1,19 @@ +import { Prototype } from '../../values/prototype.ts'; +import { WeakSetWrapper } from '../../values/wrappers/weak-set-wrapper.ts'; +import { WEAK_SET_METHOD_ADD } from './methods/add.ts'; +import { WEAK_SET_METHOD_DELETE } from './methods/delete.ts'; +import { WEAK_SET_METHOD_HAS } from './methods/has.ts'; + +/** + * Membership is identity in the host `WeakSet`, so `ws.has({})` is false for a different `{}`. + * Nothing lists the entries, so a script cannot tell whether one was collected. + */ +export const WEAK_SET_PROTOTYPE: Prototype = new Prototype( + 'WeakSet', + (value): value is WeakSetWrapper => value instanceof WeakSetWrapper, + { + add: WEAK_SET_METHOD_ADD, + delete: WEAK_SET_METHOD_DELETE, + has: WEAK_SET_METHOD_HAS + } +); diff --git a/packages/maple/src/builtins/weak-set/weak-set.spec.ts b/packages/maple/src/builtins/weak-set/weak-set.spec.ts new file mode 100644 index 0000000..39fd97d --- /dev/null +++ b/packages/maple/src/builtins/weak-set/weak-set.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { Maple } from '../../maple.ts'; +import { runAndCatch } from '../../spec-helpers.ts'; + +const maple = new Maple(); + +function run(code: string): unknown { + return maple.run(code).value; +} + +function errorOf(code: string): unknown { + const error = runAndCatch(maple, code).sandboxError; + return [error?.name, error?.message]; +} + +describe('WeakSet', () => { + it('holds objects by identity', () => { + expect(run('const k = {}; const ws = new WeakSet(); ws.add(k); return ws.has(k);')).toBe(true); + expect(run('const ws = new WeakSet(); ws.add({}); return ws.has({});')).toBe(false); + expect(run('const ws = new WeakSet(); return ws.add({}) === ws;')).toBe(true); + expect( + run('const k = {}; const ws = new WeakSet([k]); return [ws.delete(k), ws.has(k)];') + ).toEqual([true, false]); + expect(run('return new WeakSet().delete({});')).toBe(false); + }); + + it('takes any object, functions and built-ins included', () => { + expect(run('const f = () => 1; return new WeakSet([f]).has(f);')).toBe(true); + expect(run('return new WeakSet([Math, JSON]).has(JSON);')).toBe(true); + expect(run('const ws = new WeakSet(); ws.add(ws); return ws.has(ws);')).toBe(true); + }); + + it('refuses primitives when adding them', () => { + expect(errorOf('new WeakSet().add("a");')).toEqual([ + 'TypeError', + 'Invalid value used in weak set' + ]); + expect(errorOf('new WeakSet([{}, 1]);')).toEqual([ + 'TypeError', + 'Invalid value used in weak set' + ]); + }); + + it('looks primitives up without throwing', () => { + expect(run('return new WeakSet().has("a");')).toBe(false); + expect(run('return new WeakSet().delete(null);')).toBe(false); + }); + + it('constructs from an iterable', () => { + expect(run('const a = {}; return new WeakSet(new Set([a])).has(a);')).toBe(true); + expect(run('return new WeakSet(undefined).has({});')).toBe(false); + expect(errorOf('new WeakSet(5);')).toEqual(['TypeError', '5 is not iterable']); + }); + + it('requires new and reports its type', () => { + expect(errorOf('WeakSet();')).toEqual(['TypeError', "Constructor WeakSet requires 'new'"]); + expect(run('return new WeakSet() instanceof WeakSet;')).toBe(true); + expect(run('return new Set() instanceof WeakSet;')).toBe(false); + expect(run('return String(new WeakSet());')).toBe('[object WeakSet]'); + expect(run('return [WeakSet.length, new WeakSet().add.length];')).toEqual([0, 1]); + }); + + it('is no set-like for the set algebra and is not iterable', () => { + expect(errorOf('new Set().isSubsetOf(new WeakSet());')).toEqual([ + 'TypeError', + 'Set.prototype.isSubsetOf expects a Set or a Map' + ]); + expect(runAndCatch(maple, '[...new WeakSet()];').sandboxError?.name).toBe('TypeError'); + }); + + it('leaves the run as undefined', () => { + expect(run('return [new WeakSet()];')).toStrictEqual([undefined]); + }); +}); diff --git a/packages/maple/src/builtins/weak-set/weak-set.ts b/packages/maple/src/builtins/weak-set/weak-set.ts new file mode 100644 index 0000000..3af794d --- /dev/null +++ b/packages/maple/src/builtins/weak-set/weak-set.ts @@ -0,0 +1,33 @@ +import { canBeHeldWeakly } from '../../support/can-be-held-weakly.ts'; +import { Builtin } from '../../values/builtin.ts'; +import { describeValue } from '../../values/coercion.ts'; +import { iterate } from '../../values/iteration.ts'; +import { WeakSetWrapper } from '../../values/wrappers/weak-set-wrapper.ts'; +import { INVALID_WEAK_SET_VALUE } from './methods/add.ts'; +import { WEAK_SET_PROTOTYPE } from './weak-set-prototype.ts'; + +export const WEAK_SET_BUILTIN: Builtin = new Builtin({ + name: 'WeakSet', + length: WeakSet.length, + call: (_args, { fail }) => fail('TypeError', "Constructor WeakSet requires 'new'"), + // `new WeakSet(iterable?)`; every value must be an object + construct: ([iterable], context) => { + const wrapper = new WeakSetWrapper(); + if (iterable !== undefined && iterable !== null) { + const iterator = iterate(iterable); + if (!iterator) { + return context.fail('TypeError', `${describeValue(iterable)} is not iterable`); + } + for (let step = iterator.next(); !step.done; step = iterator.next()) { + const value: unknown = step.value; + if (!canBeHeldWeakly(value)) { + return context.fail('TypeError', INVALID_WEAK_SET_VALUE); + } + wrapper.set.add(value); + } + } + return wrapper; + }, + isInstance: (value) => value instanceof WeakSetWrapper, + prototype: WEAK_SET_PROTOTYPE +}); diff --git a/packages/maple/src/escapes.spec.ts b/packages/maple/src/escapes.spec.ts index 83f64ac..5cbb83a 100644 --- a/packages/maple/src/escapes.spec.ts +++ b/packages/maple/src/escapes.spec.ts @@ -57,6 +57,8 @@ describe('escape attempts', () => { expect(errorName('/x/.constructor;')).toBe('TypeError'); expect(errorName('new Date(0).constructor;')).toBe('TypeError'); expect(errorName('new Map().constructor;')).toBe('TypeError'); + expect(errorName('new WeakMap().constructor;')).toBe('TypeError'); + expect(errorName('new WeakSet().constructor;')).toBe('TypeError'); expect(errorName('new Error().constructor;')).toBe('TypeError'); expect(errorName('Math.constructor;')).toBe('TypeError'); expect(errorName('host.constructor;')).toBe('TypeError'); @@ -76,6 +78,9 @@ describe('escape attempts', () => { expect(errorName('(() => 1).prototype;')).toBe('TypeError'); expect(errorName('Array.prototype;')).toBe('TypeError'); expect(errorName('Object.prototype;')).toBe('TypeError'); + expect(errorName('WeakMap.prototype;')).toBe('TypeError'); + expect(errorName('new WeakMap().__proto__;')).toBe('TypeError'); + expect(errorName('new WeakSet().__proto__;')).toBe('TypeError'); expect(errorName('const k = ["__proto__"]; ({})[k];')).toBe('TypeError'); expect(errorName('({}).__proto__ = { polluted: true };')).toBe('TypeError'); expect(errorName('({})["constructor"] = 1;')).toBe('TypeError'); diff --git a/packages/maple/src/evaluator/host-bridge.ts b/packages/maple/src/evaluator/host-bridge.ts index 56f73d4..025fd39 100644 --- a/packages/maple/src/evaluator/host-bridge.ts +++ b/packages/maple/src/evaluator/host-bridge.ts @@ -178,9 +178,9 @@ export class HostBridge { /** * Copies a sandbox value out for the host (see `toHost`): plain data only, with functions left - * out. Two values the script built cannot be passed at all, and are a script error at the call - * instead: an iterator, which only works inside the sandbox, and a value nested deeper than - * `MAX_NESTING` levels. + * out. Some values the script built cannot be passed at all, and are a script error at the call + * instead: an iterator, a WeakMap and a WeakSet, which only work inside the sandbox, and a value + * nested deeper than `MAX_NESTING` levels. */ private toHostArgument(value: unknown, node: Node): unknown { try { diff --git a/packages/maple/src/evaluator/language/references.ts b/packages/maple/src/evaluator/language/references.ts index aff3bb6..96f3f1f 100644 --- a/packages/maple/src/evaluator/language/references.ts +++ b/packages/maple/src/evaluator/language/references.ts @@ -40,8 +40,6 @@ const HOST_ONLY_NAMES: ReadonlySet = new Set([ 'Proxy', 'Reflect', 'Symbol', - 'WeakMap', - 'WeakSet', 'Promise', 'WeakRef', 'FinalizationRegistry', diff --git a/packages/maple/src/host-functions.spec.ts b/packages/maple/src/host-functions.spec.ts index 9f75747..98fd580 100644 --- a/packages/maple/src/host-functions.spec.ts +++ b/packages/maple/src/host-functions.spec.ts @@ -107,6 +107,19 @@ describe('host functions', () => { ); }); + it('refuse WeakMaps and WeakSets as arguments', () => { + const maple = new Maple({ globals: { take: one } }); + + expect(sandboxError(maple, 'take({ cache: new WeakMap() });')).toMatchObject({ + name: 'TypeError', + message: 'A WeakMap cannot be passed to the host' + }); + expect(sandboxError(maple, 'take([new WeakSet()]);')).toMatchObject({ + name: 'TypeError', + message: 'A WeakSet cannot be passed to the host' + }); + }); + it('refuse cyclic arguments but keep shared references', () => { const seen: Array = []; const take = (value: unknown): void => { diff --git a/packages/maple/src/support/can-be-held-weakly.ts b/packages/maple/src/support/can-be-held-weakly.ts new file mode 100644 index 0000000..abdec00 --- /dev/null +++ b/packages/maple/src/support/can-be-held-weakly.ts @@ -0,0 +1,8 @@ +/** + * Whether a value may be a WeakMap key or a WeakSet entry. JavaScript also allows symbols that are + * not registered, but scripts have no symbols, so it comes down to objects and functions: every + * Maple object is a host object, while `'a'` or `1` would be a primitive, which nothing holds. + */ +export function canBeHeldWeakly(value: unknown): value is object { + return (typeof value === 'object' && value !== null) || typeof value === 'function'; +} diff --git a/packages/maple/src/values/wrappers/weak-map-wrapper.ts b/packages/maple/src/values/wrappers/weak-map-wrapper.ts new file mode 100644 index 0000000..728ab97 --- /dev/null +++ b/packages/maple/src/values/wrappers/weak-map-wrapper.ts @@ -0,0 +1,17 @@ +import { type FixedProperties, NO_FIXED_PROPERTIES, SandboxObject } from '../sandbox-object.ts'; + +/** + * A script's `new WeakMap()`. It never comes in with the input, since a host WeakMap cannot be + * copied, so unlike a Map it is never read-only. + */ +export class WeakMapWrapper extends SandboxObject { + public readonly map: WeakMap = new WeakMap(); + + public readonly tag = 'WeakMap'; + + protected readonly fixed: FixedProperties = NO_FIXED_PROPERTIES; + + constructor() { + super(false); + } +} diff --git a/packages/maple/src/values/wrappers/weak-set-wrapper.ts b/packages/maple/src/values/wrappers/weak-set-wrapper.ts new file mode 100644 index 0000000..a268409 --- /dev/null +++ b/packages/maple/src/values/wrappers/weak-set-wrapper.ts @@ -0,0 +1,17 @@ +import { type FixedProperties, NO_FIXED_PROPERTIES, SandboxObject } from '../sandbox-object.ts'; + +/** + * A script's `new WeakSet()`. It never comes in with the input, since a host WeakSet cannot be + * copied, so unlike a Set it is never read-only. + */ +export class WeakSetWrapper extends SandboxObject { + public readonly set: WeakSet = new WeakSet(); + + public readonly tag = 'WeakSet'; + + protected readonly fixed: FixedProperties = NO_FIXED_PROPERTIES; + + constructor() { + super(false); + } +} diff --git a/packages/maple/src/values/wrappers/wrapper.ts b/packages/maple/src/values/wrappers/wrapper.ts index 7aee210..692d53b 100644 --- a/packages/maple/src/values/wrappers/wrapper.ts +++ b/packages/maple/src/values/wrappers/wrapper.ts @@ -4,13 +4,22 @@ import { IteratorWrapper } from './iterator-wrapper.ts'; import { MapWrapper } from './map-wrapper.ts'; import { RegExpWrapper } from './regexp-wrapper.ts'; import { SetWrapper } from './set-wrapper.ts'; +import { WeakMapWrapper } from './weak-map-wrapper.ts'; +import { WeakSetWrapper } from './weak-set-wrapper.ts'; /** * Maple's own classes around host values. A script cannot construct them, so a plain object can * never pose as one, and the host value inside is only ever reached through a dispatch table. * `readOnly` marks wrappers that came in with the input, whose mutators must refuse. */ -export type Wrapper = RegExpWrapper | DateWrapper | MapWrapper | SetWrapper | IteratorWrapper; +export type Wrapper = + | RegExpWrapper + | DateWrapper + | MapWrapper + | SetWrapper + | WeakMapWrapper + | WeakSetWrapper + | IteratorWrapper; export function isWrapper(value: unknown): value is Wrapper { return ( @@ -18,6 +27,8 @@ export function isWrapper(value: unknown): value is Wrapper { value instanceof DateWrapper || value instanceof MapWrapper || value instanceof SetWrapper || + value instanceof WeakMapWrapper || + value instanceof WeakSetWrapper || value instanceof IteratorWrapper ); }