diff --git a/AGENTS.md b/AGENTS.md index 5f2682b..5f83d96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,8 @@ oxlint and oxfmt enforce most of the style. Fix what they report; don't silence `expect(followsJavaScript(code)).toEqual({ maple, jsInterpreter })`, and what Maple rejects as `expect(rejected(code)).toBe(code)`. Both assert all three engines; add the reason to the README. A divergence where Maple follows JavaScript needs no discussion; one where Maple departs from it does. +- Node runs in the test process, last in every helper. A case that changes a built-in (`Math.max = ...`) restores it in a + `finally` with its original descriptor. - `@netwin/maple` maps to `packages/maple/src/index.ts` through tsconfig `paths`, which Vitest reads too. ## Conventions diff --git a/packages/maple-parity/README.md b/packages/maple-parity/README.md index 74562b5..e8190f8 100644 --- a/packages/maple-parity/README.md +++ b/packages/maple-parity/README.md @@ -67,6 +67,9 @@ js-interpreter differs from JavaScript here; Maple does what Node does. | `f.bind(null, 1)`: Maple names it `bound f` with the rest of `f`'s length, js-interpreter `fBound`, 0 | A bound function's `name` and `length` are ES2015; js-interpreter follows ES5. | | `f.apply(null, 1)`, `f.apply(null, 'ab')` | A primitive is no list of arguments, so it is a `TypeError`. js-interpreter ignores a number and spreads a string. | | `JSON.parse(text, reviver)`: Maple applies the reviver, with the holder as `this` | As in JavaScript; js-interpreter ignores the reviver and returns the parsed value as it is. | +| `Object.getOwnPropertyNames(/a/g)`: Maple lists only `lastIndex`, and `delete r.source` keeps it | `source` and the flags are accessors on the prototype since ES2015; js-interpreter follows ES5, where they are own. | +| `delete f.name`: Maple leaves `''`, which cannot be written, js-interpreter `undefined`, which can | `Function.prototype.name` is `''` and read-only since ES2015; js-interpreter follows ES5, which has no `name`. | +| `delete Math.PI`, `delete r.lastIndex`: Maple throws a `TypeError` | Neither can be deleted, which strict mode reports; js-interpreter throws for neither and deletes `lastIndex`. | ### Maple departs from JavaScript diff --git a/packages/maple-parity/src/cases/errors.spec.ts b/packages/maple-parity/src/cases/errors.spec.ts index d06371d..15d875d 100644 --- a/packages/maple-parity/src/cases/errors.spec.ts +++ b/packages/maple-parity/src/cases/errors.spec.ts @@ -41,6 +41,22 @@ describe('Error.prototype.message', () => { }); }); +describe('delete:error.message', () => { + it('leaves the empty message of the prototype', () => { + expect( + parity('var e = new TypeError("m"); return [delete e.message, e.message, String(e)];') + ).toEqual([true, '', 'TypeError']); + }); + + it('takes a later assignment as a new own property', () => { + expect( + parity( + 'var e = new Error("m"); delete e.message; e.message = "n"; return [String(e), Object.keys(e)];' + ) + ).toEqual(['Error: n', ['message']]); + }); +}); + describe('instance:error.message', () => { it('Error message is converted to a string', () => { expect(parity('return [new Error(42).message, new Error(true).message];')).toEqual([ diff --git a/packages/maple-parity/src/cases/functions.spec.ts b/packages/maple-parity/src/cases/functions.spec.ts index f91740f..7a82197 100644 --- a/packages/maple-parity/src/cases/functions.spec.ts +++ b/packages/maple-parity/src/cases/functions.spec.ts @@ -297,6 +297,13 @@ describe('instance:function.length', () => { ) ).toStrictEqual([0, 2, 3]); }); + + it('can be deleted, leaving 0', () => { + expect(parity('function two(a, b) {} return [delete two.length, two.length];')).toEqual([ + true, + 0 + ]); + }); }); describe('length and name of built-in functions', () => { diff --git a/packages/maple-parity/src/cases/math.spec.ts b/packages/maple-parity/src/cases/math.spec.ts index 97da763..f1d022d 100644 --- a/packages/maple-parity/src/cases/math.spec.ts +++ b/packages/maple-parity/src/cases/math.spec.ts @@ -121,6 +121,27 @@ describe('Math.max', () => { ) ).toEqual(9); }); + it('can be replaced by a script', () => { + const max = Object.getOwnPropertyDescriptor(Math, 'max') ?? {}; + try { + expect( + parity('Math.max = function () { return 0; }; return [Math.max(1, 2), Object.keys(Math)];') + ).toEqual([0, []]); + } finally { + Object.defineProperty(Math, 'max', max); + } + }); + it('can be deleted by a script', () => { + const max = Object.getOwnPropertyDescriptor(Math, 'max') ?? {}; + try { + expect(parity('delete Math.max; return [typeof Math.max, "max" in Math];')).toEqual([ + 'undefined', + false + ]); + } finally { + Object.defineProperty(Math, 'max', max); + } + }); }); describe('Math.min', () => { diff --git a/packages/maple-parity/src/divergences.spec.ts b/packages/maple-parity/src/divergences.spec.ts index 851d4c0..bff0422 100644 --- a/packages/maple-parity/src/divergences.spec.ts +++ b/packages/maple-parity/src/divergences.spec.ts @@ -275,6 +275,56 @@ describe('JSON.parse', () => { }); }); +// A RegExp's source and flags are accessors on the prototype since ES2015, so lastIndex is its one +// own property; js-interpreter follows ES5, where they are own properties of every regexp. + +describe('Object.getOwnPropertyNames:RegExp', () => { + it('lists lastIndex only', () => { + expect(followsJavaScript('return Object.getOwnPropertyNames(/a/g);')).toEqual({ + maple: ['lastIndex'], + jsInterpreter: ['lastIndex', 'source', 'global', 'ignoreCase', 'multiline'] + }); + }); + + it('keeps source when a script deletes it', () => { + expect(followsJavaScript('var r = /a/g; return [delete r.source, r.source];')).toEqual({ + maple: [true, 'a'], + jsInterpreter: [true, '(?:)'] + }); + }); +}); + +describe('delete:function.name', () => { + it("leaves Function.prototype's empty name", () => { + expect(followsJavaScript('function f() {} return [delete f.name, f.name];')).toEqual({ + maple: [true, ''], + jsInterpreter: [true, undefined] + }); + }); + + it('leaves a name that cannot be written', () => { + expect( + followsJavaScript('function f() {} delete f.name; f.name = "g"; return f.name;') + ).toEqual({ maple: MapleErrorCode.RUNTIME_ERROR, jsInterpreter: 'g' }); + }); +}); + +describe('delete:not configurable', () => { + it('throws for a constant of a namespace', () => { + expect(followsJavaScript('delete Math.PI;')).toEqual({ + maple: MapleErrorCode.RUNTIME_ERROR, + jsInterpreter: undefined + }); + }); + + it('throws for the lastIndex of a regexp', () => { + expect(followsJavaScript('var r = /a/g; return [delete r.lastIndex, r.lastIndex];')).toEqual({ + maple: MapleErrorCode.RUNTIME_ERROR, + jsInterpreter: [true, undefined] + }); + }); +}); + // Maple departs from JavaScript on purpose. // The object model: Maple has no script-visible prototypes. An object's behaviour comes from its diff --git a/packages/maple-parity/src/parity.ts b/packages/maple-parity/src/parity.ts index fd17962..e4992ab 100644 --- a/packages/maple-parity/src/parity.ts +++ b/packages/maple-parity/src/parity.ts @@ -2,16 +2,20 @@ import { MapleError, MapleErrorCode } from '@netwin/maple'; import { expect } from 'vitest'; import { type Outcome, runJsInterpreter, runMaple, runNode } from './engines.ts'; +// Node runs last in every helper. It runs in this process, so a case that changes a built-in +// (`Math.max = ...`) changes it here until the case restores it, and no other engine may run +// in the meantime: js-interpreter and Maple use the host's built-ins themselves. + /** * Runs `code` in all three engines, asserts that Maple returns what Node and js-interpreter * return, and hands that value back so the test can spell out what it is. */ export function parity(code: string, data?: Record): unknown { const value = valueOf(runMaple(code, data), 'Maple'); - expect(value, 'Node').toStrictEqual(valueOf(runNode(code, data), 'Node')); - expect(value, 'js-interpreter').toStrictEqual( - valueOf(runJsInterpreter(code, data), 'js-interpreter') - ); + const jsInterpreter = valueOf(runJsInterpreter(code, data), 'js-interpreter'); + const node = valueOf(runNode(code, data), 'Node'); + expect(value, 'Node').toStrictEqual(node); + expect(value, 'js-interpreter').toStrictEqual(jsInterpreter); return value; } @@ -46,8 +50,9 @@ export function allThrow(code: string, data?: Record): string { */ export function rejected(code: string, { sloppy = false } = {}): MapleErrorCode { valueOf(runJsInterpreter(code, {}, !sloppy), 'js-interpreter'); + const rejection = rejectionOf(runMaple(code)); valueOf(runNode(code, {}, !sloppy), 'Node'); - return rejectionOf(runMaple(code)); + return rejection; } /** diff --git a/packages/maple/docs/boundary.md b/packages/maple/docs/boundary.md index b7d0c4f..9304c4b 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 | @@ -131,8 +132,10 @@ safe to pass to `JSON.stringify`. `undefined` at the top level is returned as `u A sandbox error becomes a plain object with `name`, `message` and `stack`, and with `properties` holding what the script added to it, absent when it added nothing. An error with an own `cause` -carries it as `cause`. `name` and `message` arrive as strings, as `toString()` reads them. The -properties a script added to a wrapper stay behind, since a native `Date` or `Map` takes its place. +carries it as `cause`. `name`, `message` and `stack` arrive as strings, as the script left them: a +deleted `stack` arrives as `''`, and one the script deleted and assigned again arrives in its own +field, not in `properties`. The properties a script added to a wrapper stay behind, since a native +`Date` or `Map` takes its place. A result nested deeper than 1000 levels ends the run with `RUNTIME_ERROR` and a `RangeError` without a position, since the program has already finished. In a nested run, the intrinsic receives diff --git a/packages/maple/docs/builtins.md b/packages/maple/docs/builtins.md index 373393d..4db94eb 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 @@ -166,6 +176,10 @@ on the right-hand side of `instanceof`, and it adds no frame to `stack`. with or without `new`. Errors Maple raises itself are the same kind of object, so a script can catch and inspect them. +`name`, `message` and `stack` can be deleted. Deleting `message` leaves `''` and deleting `name` +leaves the constructor's name, as the prototype has them in JavaScript; deleting `stack` leaves +nothing. Assigning one of them afterwards adds an own property, which `Object.keys` lists. + `stack` is read-only and fixed when the error is created. It lists the script's own frames and nothing of the host, innermost first: diff --git a/packages/maple/docs/language.md b/packages/maple/docs/language.md index ef79e64..fc2d1d7 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,18 +58,19 @@ 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 -Script functions and built-in functions have JavaScript's read-only `length` and `name`, names -inferred from bindings included. Host functions, and functions bound from them, have no properties +Script functions and built-in functions have JavaScript's `length` and `name`, names inferred from +bindings included. Neither can be written, but both can be deleted, which leaves what +`Function.prototype` has: `0` and `''`, which cannot be written either. Host functions, and functions bound from them, have no properties at all. Every function answers to `toString`, which gives `[function]`, `valueOf`, `call`, `apply` and `bind` ([Functions](builtins.md#functions)). The receiver these pass on reaches a built-in method, which checks it as for any method call, so `''.trim.call(' b ')` is `'b'`, and a script @@ -84,8 +85,10 @@ At the top level of the script `this` is `undefined`, since there is no global o function still cannot be constructed ([Constructors](#constructors)). Every function, and every namespace such as `Math`, takes the properties a script adds, as in -`fn.cache = {}`. Built-ins, host functions and namespaces are shared by every run, so what a run -adds to one, or `freeze`s on it, is gone when the next run starts. +`fn.cache = {}`. As in JavaScript, the methods of a built-in can be replaced and deleted, as in +`Math.max = fn`, while its constants, such as `Math.PI` or `Number.EPSILON`, can be neither. +Built-ins, host functions and namespaces are shared by every run, so what a run adds to one, +replaces on it, deletes from it or `freeze`s on it is gone when the next run starts. A tagged template `` tag`a${x}b` `` calls `tag` with the strings array and then each substitution as a value, not converted to a string, with `this` bound as for a call of the same form. The strings @@ -98,22 +101,24 @@ 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 -dispatch table. A wrapper has no own properties except the few listed with its type (`size`, the -RegExp flags and `lastIndex`), plus the ones the script adds, which are kept apart from the native -value. +dispatch table. A wrapper's only own property is a RegExp's `lastIndex`, plus the ones the script +adds, which are kept apart from the native value. `size` and the RegExp flags and `source` are +properties of the type, as JavaScript keeps them on the prototype: every value answers to them and +`in` finds them, but `Object.hasOwn` and `Object.getOwnPropertyNames` leave them out, and deleting +one changes nothing. ## Methods 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..e3f48a1 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 @@ -27,6 +29,9 @@ type UnconvertibleKind = 'iterator' | 'cyclic value'; */ const DROPPED_KEYS: ReadonlySet = new Set(['__proto__', 'constructor']); +/** What an error hands the host as fields of its own, so not again among its `properties`. */ +const ERROR_FIELDS: ReadonlySet = new Set(['name', 'message', 'stack', 'cause']); + /** Thrown by a strict `toHost` for a value that must not leave the sandbox. */ export class UnconvertibleValueError extends Error { public readonly kind: UnconvertibleKind; @@ -49,9 +54,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 +122,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) { @@ -209,8 +221,10 @@ function copyContainer(value: object, walk: Walk, depth: number): unknown { /** * `properties` is there only when the script added any, and `cause` only when the error has one, - * so an error without them keeps the shape it always had. `cause` has its own field whether it is - * enumerable or not, and never repeats in `properties`. A wrapper's added properties stay behind: + * so an error without them keeps the shape it always had. `name`, `message`, `stack` and `cause` + * have fields of their own and never repeat in `properties`, not even when the script deleted one + * and assigned it again, which makes it an added property. `cause` has its field whether it is + * enumerable or not. A wrapper's added properties stay behind: * a Date or a Map leaves as the host's own. */ function copyError(error: SandboxError, walk: Walk, depth: number): SandboxErrorInfo { @@ -222,7 +236,7 @@ function copyError(error: SandboxError, walk: Walk, depth: number): SandboxError walk.copies.set(error, copy); - const keys = error.properties.enumerableKeys().filter((key) => key !== 'cause'); + const keys = error.properties.enumerableKeys().filter((key) => !ERROR_FIELDS.has(key)); const cause = error.properties.getAdded('cause'); if (keys.length === 0 && cause === undefined) { return copy; diff --git a/packages/maple/src/builtins/json/json.spec.ts b/packages/maple/src/builtins/json/json.spec.ts index 4807f9d..28fafdd 100644 --- a/packages/maple/src/builtins/json/json.spec.ts +++ b/packages/maple/src/builtins/json/json.spec.ts @@ -258,9 +258,10 @@ describe('JSON.stringify', () => { expect(error.code).toBe('LIMIT_EXCEEDED'); }); - it('is a read-only namespace', () => { + it('is a namespace whose methods a run can replace', () => { expect(run('return typeof JSON;')).toBe('object'); expect(run('return typeof JSON.parse;')).toBe('function'); - expect(runAndCatch(maple, 'JSON.parse = 1;').sandboxError?.name).toBe('TypeError'); + expect(run('JSON.parse = 1; return JSON.parse;')).toBe(1); + expect(run('return typeof JSON.parse;')).toBe('function'); }); }); diff --git a/packages/maple/src/builtins/map/map.spec.ts b/packages/maple/src/builtins/map/map.spec.ts index 6370641..740f41e 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( @@ -75,7 +87,7 @@ describe('Map', () => { expect(run('return new Map().forEach.length;')).toBe(1); }); - it('has no own properties besides size but takes the ones a script adds', () => { + it('has no own properties but takes the ones a script adds', () => { expect(run('return ["size" in new Map(), "get" in new Map()];')).toEqual([true, true]); expect(run('return new Map().foo;')).toBeUndefined(); expect(run('return Object.keys(new Map([[1, 2]]));')).toEqual([]); @@ -94,6 +106,18 @@ describe('Map', () => { expect(run('return JSON.stringify(new Map([[1, 2]]));')).toBe('{}'); }); + it('answers to size without having it as its own, as JavaScript keeps it on the prototype', () => { + expect(run('return Object.hasOwn(new Map(), "size");')).toBe(false); + expect(run('return Object.getOwnPropertyNames(new Map([[1, 2]]));')).toEqual([]); + expect(run('const m = new Map([[1, 2]]); return [delete m.size, m.size];')).toEqual([true, 1]); + expect(run('const m = new Map(); Object.freeze(m); return Object.isFrozen(m);')).toBe(true); + expect(run('const s = new Set([1]); return [Object.hasOwn(s, "size"), "size" in s];')).toEqual([ + false, + true + ]); + expect(run('const s = new Set([1]); return [delete s.size, s.size];')).toEqual([true, 1]); + }); + it('crosses the boundary as a native Map and is read-only from frozen input', () => { const data = { lookup: new Map([['a', { n: 1 }]]) }; 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/math/math.spec.ts b/packages/maple/src/builtins/math/math.spec.ts index 008ce8b..6df065a 100644 --- a/packages/maple/src/builtins/math/math.spec.ts +++ b/packages/maple/src/builtins/math/math.spec.ts @@ -60,7 +60,7 @@ describe('Math', () => { expect(run('return Math.SQRT2;')).toBe(Math.SQRT2); }); - it('is a read-only namespace', () => { + it('is a namespace whose constants cannot be written or deleted', () => { expect(run('return typeof Math;')).toBe('object'); expect(run('return typeof Math.max;')).toBe('function'); expect(run('return Math.foo;')).toBeUndefined(); @@ -70,11 +70,37 @@ describe('Math', () => { expect(runAndCatch(maple, 'Math.PI = 3;').sandboxError?.message).toBe( "Cannot assign to read-only property 'PI' of a Math" ); - expect(runAndCatch(maple, 'delete Math.PI;').sandboxError?.name).toBe('TypeError'); + expect(runAndCatch(maple, 'delete Math.PI;').sandboxError?.message).toBe( + "Cannot delete property 'PI' of #" + ); expect(runAndCatch(maple, 'new Math();').code).toBe('DISALLOWED_SYNTAX'); expect(runAndCatch(maple, 'Math();').sandboxError?.message).toBe('Math is not a function'); }); + it('lets a run replace and delete its methods, as JavaScript does', () => { + expect( + run( + 'Math.max = () => 0; return [Math.max(1, 2), Object.keys(Math), Object.hasOwn(Math, "max")];' + ) + ).toEqual([0, [], true]); + expect(run('return [delete Math.max, Math.max, "max" in Math];')).toEqual([ + true, + undefined, + false + ]); + expect(run('delete Math.max; Math.max = 1; return [Math.max, Object.keys(Math)];')).toEqual([ + 1, + ['max'] + ]); + expect(run('const max = Math.max; Math.max = 1; return [max(1, 2), Math.min(1, 2)];')).toEqual([ + 2, 1 + ]); + expect(runAndCatch(maple, 'Object.freeze(Math); Math.max = 1;').sandboxError?.message).toBe( + "Cannot assign to read-only property 'max' of a Math" + ); + expect(run('return Math.max(1, 2);')).toBe(2); + }); + it('never leaves the sandbox', () => { expect(run('return Math;')).toBeUndefined(); expect(run('return [Math.max];')).toEqual([undefined]); 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/object/object.spec.ts b/packages/maple/src/builtins/object/object.spec.ts index 87c4fde..fc76362 100644 --- a/packages/maple/src/builtins/object/object.spec.ts +++ b/packages/maple/src/builtins/object/object.spec.ts @@ -180,8 +180,9 @@ describe('Object statics', () => { }); }); - it('cannot be written to', () => { - expect(runAndCatch(maple, 'Object.keys = 1;').sandboxError?.name).toBe('TypeError'); + it('can be replaced by a run', () => { + expect(run('Object.keys = 1; return Object.keys;')).toBe(1); + expect(run('return typeof Object.keys;')).toBe('function'); }); }); @@ -220,7 +221,7 @@ describe('Object.is, getOwnPropertyNames, freeze and isFrozen', () => { ).toEqual([true, false, true, false]); }); - it('lists own names the way in answers them', () => { + it('lists own names, enumerable or not', () => { expect(run('return Object.getOwnPropertyNames({ a: 1, b: 2 });')).toEqual(['a', 'b']); expect(run('return Object.getOwnPropertyNames([7]);')).toEqual(['0', 'length']); expect(run('return Object.getOwnPropertyNames("ab");')).toEqual(['0', '1', 'length']); @@ -229,12 +230,13 @@ describe('Object.is, getOwnPropertyNames, freeze and isFrozen', () => { 'message', 'stack' ]); - expect(run('return Object.getOwnPropertyNames(new Map());')).toEqual(['size']); + expect(run('return Object.getOwnPropertyNames(new Map());')).toEqual([]); + expect(run('return Object.getOwnPropertyNames(new Set());')).toEqual([]); expect(run('return Object.getOwnPropertyNames(() => 1);')).toEqual(['length', 'name']); expect(run('return Object.getOwnPropertyNames(Math.max);')).toEqual(['length', 'name']); expect(run('return Object.getOwnPropertyNames(host);', { host: () => 1 })).toEqual([]); expect(run('return Object.getOwnPropertyNames(5);')).toEqual([]); - expect(run('return Object.getOwnPropertyNames(/x/).includes("lastIndex");')).toBe(true); + expect(run('return Object.getOwnPropertyNames(/x/g);')).toEqual(['lastIndex']); expect(run('return Object.getOwnPropertyNames(Math).includes("max");')).toBe(true); expect(run('try { Object.getOwnPropertyNames(null); } catch (e) { return e.name; }')).toBe( 'TypeError' @@ -297,6 +299,12 @@ describe('Object.is, getOwnPropertyNames, freeze and isFrozen', () => { expect(errorMessage(maple, 'const m = new Map(); m.x = 1; Object.freeze(m); delete m.x;')).toBe( "Cannot delete property 'x' of #" ); + expect( + errorMessage(maple, 'const d = new Date(0); d.x = 1; Object.freeze(d); delete d.x;') + ).toBe("Cannot delete property 'x' of [object Date]"); + expect(errorMessage(maple, 'Math.x = 1; Object.freeze(Math); delete Math.x;')).toBe( + "Cannot delete property 'x' of #" + ); expect( errorMessage(maple, 'function f() { Object.freeze(arguments); delete arguments[0]; } f(1);') ).toBe("Cannot delete property '0' of #"); @@ -322,12 +330,18 @@ describe('Object.is, getOwnPropertyNames, freeze and isFrozen', () => { ).toEqual(["Cannot delete property 'x' of [object Error]", 'finally']); }); - it('still lets a delete of a fixed property that freeze did not lock change nothing', () => { - expect(run('const f = () => 1; return delete f.name;')).toBe(true); + it('deletes a configurable fixed property and refuses one that is not configurable', () => { + expect(run('const f = () => 1; return [delete f.name, f.name];')).toEqual([true, '']); expect(run('return [delete Object.freeze(new Map()).size, delete /a/.source];')).toEqual([ true, true ]); + expect(errorMessage(maple, 'delete /a/.lastIndex;')).toBe( + "Cannot delete property 'lastIndex' of [object RegExp]" + ); + expect(errorMessage(maple, 'delete Number.EPSILON;')).toBe( + "Cannot delete property 'EPSILON' of [function]" + ); }); }); @@ -434,6 +448,17 @@ describe('Object.seal and isSealed', () => { expect(run('Object.seal(Math); return Object.isSealed(Math);')).toBe(true); expect(run('Math.x = 1; return [Object.isSealed(Math), Math.x];')).toEqual([false, 1]); }); + + it('find a built-in that only takes no new properties neither sealed nor frozen', () => { + expect( + run('Object.preventExtensions(Math); return [Object.isSealed(Math), Object.isFrozen(Math)];') + ).toEqual([false, false]); + expect( + run( + 'Object.preventExtensions(Math.max); return [Object.isSealed(Math.max), Object.isFrozen(Math.max)];' + ) + ).toEqual([false, false]); + }); }); describe('Object.preventExtensions and isExtensible', () => { diff --git a/packages/maple/src/builtins/object/statics/has-own.ts b/packages/maple/src/builtins/object/statics/has-own.ts index 987ba46..fd65a7c 100644 --- a/packages/maple/src/builtins/object/statics/has-own.ts +++ b/packages/maple/src/builtins/object/statics/has-own.ts @@ -1,8 +1,16 @@ -import { hasMember, toArrayIndex, toPropertyKey } from '../../../values/members.ts'; +import { hasOwnMember, toArrayIndex, toPropertyKey } from '../../../values/members.ts'; import { NativeFunction } from '../../../values/native.ts'; import { asObjectCoercibleOrThrow } from '../utils/as-object-coercible-or-throw.ts'; -/** Also answers for strings, whose own keys are the indices and `length`. */ +/** + * `Object.hasOwn(target, key)`: whether `target` has `key` as its own property, not only through + * its type: + * + * - `Object.hasOwn([1], 'length')` is `true`; + * - `Object.hasOwn(new Map(), 'size')` is `false`. + * + * A string's own keys are its indices and `length`; `null` and `undefined` are a TypeError. + */ export const OBJECT_STATIC_HAS_OWN: NativeFunction = new NativeFunction( 'hasOwn', Object.hasOwn.length, @@ -15,6 +23,8 @@ export const OBJECT_STATIC_HAS_OWN: NativeFunction = new NativeFunction( return name === 'length' || (index !== undefined && index < object.length); } - return typeof object === 'object' && object !== null ? hasMember(object, name, context) : false; + return typeof object === 'object' && object !== null + ? hasOwnMember(object, name, context.runState) + : false; } ); diff --git a/packages/maple/src/builtins/regexp/regexp.spec.ts b/packages/maple/src/builtins/regexp/regexp.spec.ts index 61bd347..2d66a87 100644 --- a/packages/maple/src/builtins/regexp/regexp.spec.ts +++ b/packages/maple/src/builtins/regexp/regexp.spec.ts @@ -58,6 +58,18 @@ describe('RegExp', () => { expect(run('return /a/g + "";')).toBe('/a/g'); }); + it('has lastIndex as its one own property, as JavaScript keeps the rest on the prototype', () => { + expect(run('return Object.getOwnPropertyNames(/a/gi);')).toEqual(['lastIndex']); + expect(run('return [Object.hasOwn(/a/, "lastIndex"), Object.hasOwn(/a/, "source")];')).toEqual([ + true, + false + ]); + expect( + run('const r = /a/g; return [delete r.source, r.source, delete r.global, r.global];') + ).toEqual([true, 'a', true, true]); + expect(run('const r = /a/; Object.freeze(r); return Object.isFrozen(r);')).toBe(true); + }); + it('is read-only when it came from frozen input', () => { const data = { pattern: /a/g }; 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/errors.spec.ts b/packages/maple/src/errors.spec.ts index ebaec88..afef2e9 100644 --- a/packages/maple/src/errors.spec.ts +++ b/packages/maple/src/errors.spec.ts @@ -792,3 +792,46 @@ describe('errors from conversions', () => { expect(error.line).toBeUndefined(); }); }); + +describe('deleting the properties of an error', () => { + it('leaves what the prototype has, as JavaScript does', () => { + expect( + run( + 'const e = new TypeError("m"); return [delete e.message, e.message, "message" in e, Object.hasOwn(e, "message"), String(e)];' + ) + ).toEqual([true, '', true, false, 'TypeError']); + expect( + run('const e = new TypeError("m"); e.name = "X"; delete e.name; return [e.name, String(e)];') + ).toEqual(['TypeError', 'TypeError: m']); + expect( + run('const e = new Error("m"); return [delete e.stack, e.stack, "stack" in e];') + ).toEqual([true, undefined, false]); + }); + + it('reaches the host as the script left it, without repeating a field in properties', () => { + expect( + runAndCatch(maple, 'const e = new Error("m"); delete e.stack; throw e;').sandboxError + ).toStrictEqual({ name: 'Error', message: 'm', stack: '' }); + expect( + runAndCatch(maple, 'const e = new Error("m"); delete e.stack; e.stack = "s"; throw e;') + .sandboxError + ).toStrictEqual({ name: 'Error', message: 'm', stack: 's' }); + expect( + run('const e = new Error("m"); delete e.message; e.message = "n"; e.code = 1; return e;') + ).toMatchObject({ message: 'n', properties: { code: 1 } }); + expect( + run('const e = new Error("m"); delete e.message; e.message = "n"; return e;') + ).not.toHaveProperty('properties'); + }); + + it('takes an assignment after the delete as a new own property, listed last', () => { + expect( + run( + 'const e = new Error("m"); delete e.message; e.message = "n"; return [String(e), Object.keys(e), Object.getOwnPropertyNames(e)];' + ) + ).toEqual(['Error: n', ['message'], ['name', 'stack', 'message']]); + expect( + runAndCatch(maple, 'const e = new Error("m"); delete e.message; e.message = "n"; throw e;') + ).toMatchObject({ message: 'n', sandboxError: { name: 'Error', message: 'n' } }); + }); +}); diff --git a/packages/maple/src/escapes.spec.ts b/packages/maple/src/escapes.spec.ts index 83f64ac..743db96 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'); @@ -241,7 +246,7 @@ describe('escape attempts', () => { expect(frozenErrorName('when.setTime(1);', data)).toBe('TypeError'); expect(frozenErrorName('lookup.set("x", 1);', data)).toBe('TypeError'); expect(frozenErrorName('obj = {};', data)).toBe('TypeError'); - expect(errorName('Math.max = 1;')).toBe('TypeError'); + expect(errorName('Number.EPSILON = 1;')).toBe('TypeError'); expect(errorName('Math.PI = 3;')).toBe('TypeError'); expect(errorName('undefined = 1;')).toBe('TypeError'); expect(data.obj.a).toEqual([1]); @@ -522,7 +527,7 @@ describe('escape attempts', () => { expect(new Maple().run('return [].push.x;').value).toBeUndefined(); }); - it('keep what a run and its nested runs add to a shared value apart', () => { + it('keep what a run and its nested runs add to, replace on or delete from a shared value apart', () => { const nesting = new Maple({ globals: { exec: intrinsic(([code], sandbox) => sandbox.run(String(sandbox.toHost(code))).value) @@ -540,6 +545,20 @@ describe('escape attempts', () => { '[].push.planted = 1; const inner = exec("[].push.planted = 2; return [].push.planted;"); return [inner, [].push.planted];' ).value ).toEqual([2, 1]); + expect(nesting.run('Math.max = 1; return exec("return typeof Math.max;");').value).toBe( + 'function' + ); + expect(nesting.run('exec("delete Math.max;"); return typeof Math.max;').value).toBe( + 'function' + ); + expect( + nesting.run('delete [].push.name; return [exec("return [].push.name;"), [].push.name];') + .value + ).toEqual(['push', '']); + expect(new Maple().run('return [typeof Math.max, [].push.name];').value).toEqual([ + 'function', + 'push' + ]); }); it('keep a shared value frozen only for the run that froze it', () => { 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/functions.spec.ts b/packages/maple/src/evaluator/language/functions.spec.ts index ee5a0e5..19c5e47 100644 --- a/packages/maple/src/evaluator/language/functions.spec.ts +++ b/packages/maple/src/evaluator/language/functions.spec.ts @@ -362,7 +362,31 @@ describe('functions', () => { }); it('cannot be written', () => { - expect(runAndCatch(maple, 'Math.max.name = "x";').sandboxError?.name).toBe('TypeError'); + expect(errorMessage('Math.max.name = "x";')).toBe( + "Cannot assign to read-only property 'name' of a Function" + ); + }); + + it('can be deleted for the run, leaving what Function.prototype has', () => { + expect( + maple.run('return [delete Math.max.name, Math.max.name, delete Number.name, Number.name];') + .value + ).toEqual([true, '', true, '']); + expect( + maple.run( + 'delete Math.max.name; return [Object.hasOwn(Math.max, "name"), "name" in Math.max];' + ).value + ).toEqual([false, true]); + expect( + maple.run( + 'delete [].push.length; return [[].push.length, Object.getOwnPropertyNames([].push)];' + ).value + ).toEqual([0, ['name']]); + expect(maple.run('return [Math.max.name, Number.name, [].push.length];').value).toEqual([ + 'max', + 'Number', + 1 + ]); }); }); @@ -398,6 +422,28 @@ describe('functions', () => { 'TypeError' ); }); + + it('can be deleted, leaving what Function.prototype has, which cannot be written either', () => { + expect( + maple.run('function f(a, b) {} return [delete f.name, f.name, delete f.length, f.length];') + .value + ).toEqual([true, '', true, 0]); + expect( + maple.run( + 'function f() {} delete f.name; return [Object.getOwnPropertyNames(f), "name" in f, Object.hasOwn(f, "name")];' + ).value + ).toEqual([['length'], true, false]); + expect(errorMessage('function f() {} delete f.name; f.name = "g";')).toBe( + "Cannot assign to read-only property 'name' of a Function" + ); + expect(errorMessage('function f() {} Object.freeze(f); delete f.name;')).toBe( + "Cannot delete property 'name' of [function]" + ); + expect( + maple.run('function f() {} delete f.name; Object.freeze(f); return Object.isFrozen(f);') + .value + ).toBe(true); + }); }); describe('this', () => { 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/support/noop.ts b/packages/maple/src/support/noop.ts new file mode 100644 index 0000000..b976449 --- /dev/null +++ b/packages/maple/src/support/noop.ts @@ -0,0 +1,5 @@ +/** + * A callback that does nothing and returns `undefined`, for a parameter that must be a function + * where the caller has nothing to do, such as a method lookup for keys that are never methods. + */ +export const NOOP_CALLBACK = (): undefined => undefined; diff --git a/packages/maple/src/values/builtin.ts b/packages/maple/src/values/builtin.ts index e33a6f1..eece98f 100644 --- a/packages/maple/src/values/builtin.ts +++ b/packages/maple/src/values/builtin.ts @@ -1,5 +1,4 @@ import { FUNCTION_PLACEHOLDER, type Primitive, type PrimitiveHint } from './coercion.ts'; -import type { MemberContext } from './members.ts'; import type { CallContext } from './native.ts'; import type { Prototype } from './prototype.ts'; import { @@ -18,7 +17,10 @@ export interface BuiltinOptions { * read off the host's own; `Math` has none. */ length?: number; - /** Static members such as `Math.max`; read through member access, never written. */ + /** + * Static members such as `Math.max` and `Math.PI`. As in JavaScript, a method can be replaced and + * deleted, as in `Math.max = fn`, and a constant can be neither. + */ members?: Record; /** What a plain call does, as in `Error('x')` or `String(1)`. */ call?: BuiltinCall; @@ -31,8 +33,8 @@ export interface BuiltinOptions { } /** - * A standard global such as `Error`, `Array` or `Math`, whose members the script can only read. It - * is shared between runs, so what a script adds to one is kept by the run. + * A standard global such as `Error`, `Array` or `Math`. It is shared between runs, so what a script + * adds to one, replaces on it or deletes from it is kept by the run. */ export class Builtin extends SandboxObject { public readonly name: string; @@ -60,10 +62,7 @@ export class Builtin extends SandboxObject { this.name = options.name; this.length = options.length; const members = Object.entries(options.members ?? {}).map( - ([key, member]): [string, FixedProperty] => [ - key, - { get: () => member, configurable: false } - ] + ([key, member]): [string, FixedProperty] => [key, memberProperty(member)] ); this.fixed = new Map([ ...(options.length === undefined ? [] : FUNCTION_PROPERTIES), @@ -77,12 +76,9 @@ export class Builtin extends SandboxObject { this.tag = this.isCallable ? 'Function' : options.name; } - /** Its members cannot be deleted, so `Math.PI` stays; what the run added to it can. */ - public override deleteProperty(key: string, context: MemberContext): boolean { - if (this.fixed.has(key)) { - return context.fail('TypeError', `Cannot delete property '${key}' of a read-only object`); - } - return super.deleteProperty(key, context); + /** A namespace such as `Math` is a plain object in JavaScript, so it is named `#`. */ + public override describe(): string { + return this.isCallable ? super.describe() : '#'; } /** A namespace converts as in JavaScript, `[object Math]`. */ @@ -90,3 +86,12 @@ export class Builtin extends SandboxObject { return this.isCallable ? FUNCTION_PLACEHOLDER : super.toPrimitive(hint); } } + +/** + * A static member with the attributes JavaScript gives it: a method, such as `Math.max`, is + * writable and configurable, and a constant, such as `Math.PI` or `Number.EPSILON`, is neither. + */ +function memberProperty(member: unknown): FixedProperty { + const isMethod = member instanceof SandboxObject && member.isCallable; + return { get: () => member, writable: isMethod, configurable: isMethod }; +} diff --git a/packages/maple/src/values/coercion.ts b/packages/maple/src/values/coercion.ts index 6fd2864..656e3b4 100644 --- a/packages/maple/src/values/coercion.ts +++ b/packages/maple/src/values/coercion.ts @@ -138,16 +138,14 @@ export function describeValue(value: unknown): string { /** * An object as a failed `delete` names it in JavaScript: `#`, `[object Array]`, `#`, - * and a function or an error by its string. + * and a function or an error by its string (see {@link SandboxObject.describe}). */ export function describeObject(value: object): string { if (Array.isArray(value)) { return ARRAY_PLACEHOLDER; } if (value instanceof SandboxObject) { - return value.tag === 'Function' || value.tag === 'Error' - ? describeValue(value) - : `#<${value.tag}>`; + return value.describe(); } return '#'; } diff --git a/packages/maple/src/values/members.ts b/packages/maple/src/values/members.ts index 227d6e6..e2ae855 100644 --- a/packages/maple/src/values/members.ts +++ b/packages/maple/src/values/members.ts @@ -159,6 +159,14 @@ export function setMember( throw new Error('A value of an unknown kind was written'); } +/** + * `delete target[key]`, which gives `true`, or a TypeError where strict mode has one: + * + * - Deleting from `null` or `undefined`, or + * - Deleting a property that cannot be deleted, such as one of a frozen object. + * + * Deleting from any other primitive, as in `delete 'abc'.x`, does nothing. + */ export function deleteMember(target: unknown, key: string, context: MemberContext): boolean { const fail = context.fail; @@ -183,7 +191,15 @@ export function deleteMember(target: unknown, key: string, context: MemberContex return true; } -/** The `in` operator over own properties, which is all the sandbox has. */ +/** + * `key in target`. Maple walks no prototype chain, so: + * + * - A plain object or an array answers whether it has `key` as its own property; + * - A value Maple made answers itself, and also counts what its kind has without it being own, so + * `'size' in new Map()` is `true`. + * + * As in JavaScript, the right-hand side must be an object: `'a' in 'abc'` is a TypeError. + */ export function hasMember(target: unknown, key: string, context: MemberContext): boolean { const fail = context.fail; const runState = context.runState; @@ -202,7 +218,24 @@ export function hasMember(target: unknown, key: string, context: MemberContext): return Object.hasOwn(target, key); } -/** The own property names `in` answers `true` for, enumerable or not, for `getOwnPropertyNames`. */ +/** + * `Object.hasOwn(target, key)` for an object. It differs from `in` for a value Maple made, whose + * kind can have a property that is not its own: `'size' in m` is `true`, but + * `Object.hasOwn(m, 'size')` is `false` (see {@link SandboxObject.hasOwnProperty}). + */ +export function hasOwnMember(target: object, key: string, runState: RunState): boolean { + return target instanceof SandboxObject + ? target.hasOwnProperty(key, runState) + : Object.hasOwn(target, key); +} + +/** + * `Object.getOwnPropertyNames(target)`: every own property name, enumerable or not. + * + * - An object lists its own names, so `[7]` gives `['0', 'length']`. + * - A string lists its indices and `length`. + * - Any other primitive has none. + */ export function ownPropertyNames(target: unknown, runState: RunState): Array { if (typeof target === 'string') { return [...Object.keys(target), 'length']; diff --git a/packages/maple/src/values/own-properties.ts b/packages/maple/src/values/own-properties.ts index 6661c75..683f492 100644 --- a/packages/maple/src/values/own-properties.ts +++ b/packages/maple/src/values/own-properties.ts @@ -13,10 +13,25 @@ export interface AddedProperty { configurable: boolean; } -/** What `seal` or `freeze` took from a fixed property of this value. */ +/** + * What happened to a fixed property on this one value, where it departs from what its kind gives + * it: + * + * - `seal` or `freeze` took `writable` or `configurable` away; + * - `delete` removed it, as in `delete fn.name`; + * - The script wrote a property that keeps no value of its own, as in `Math.max = fn`. + */ export interface FixedChange { writable?: false; configurable?: false; + /** + * `delete` removed it. A read then finds what the script added under the key afterwards, or + * otherwise what JavaScript inherits from the prototype, such as `''` for a function's + * `name`. + */ + deleted?: true; + /** What the script wrote, for a writable fixed property without a `set`, such as `Math.max`. */ + replaced?: { readonly value: unknown }; } /** diff --git a/packages/maple/src/values/run-state.ts b/packages/maple/src/values/run-state.ts index 2bea426..7567858 100644 --- a/packages/maple/src/values/run-state.ts +++ b/packages/maple/src/values/run-state.ts @@ -18,6 +18,16 @@ export class RunState { */ readonly #templateObjects = new WeakMap>(); + /** + * What this run changed on `value`, or `undefined` while it changed nothing. For reads, which + * leave no entry behind, so reading `Math.max` costs no allocation. + */ + public peekOwnPropertiesOf(value: SandboxObject): OwnProperties | undefined { + // Most runs change no shared value, which spares every read the lookup + return this.#ownProperties.size === 0 ? undefined : this.#ownProperties.get(value); + } + + /** What this run changed on `value`, with an entry made on the first call, for writes. */ public getOwnPropertiesOf(value: SandboxObject): OwnProperties { let properties = this.#ownProperties.get(value); if (properties === undefined) { diff --git a/packages/maple/src/values/sandbox-error.ts b/packages/maple/src/values/sandbox-error.ts index 77e3189..2f3d6d2 100644 --- a/packages/maple/src/values/sandbox-error.ts +++ b/packages/maple/src/values/sandbox-error.ts @@ -1,30 +1,45 @@ import type { Node } from 'acorn'; -import { Conversion, type Primitive, type PrimitiveHint, toString } from './coercion.ts'; +import { NOOP_CALLBACK } from '../support/noop.ts'; +import { + Conversion, + describeValue, + type Primitive, + type PrimitiveHint, + toString +} from './coercion.ts'; import { type FixedProperties, SandboxObject } from './sandbox-object.ts'; -/** `name` and `message` keep any value, as in JavaScript; converting the error reads them. */ +/** + * `name` and `message` keep any value, as in JavaScript; converting the error reads them. Once + * deleted, they read what the error's prototype has: its kind's name, such as `'TypeError'`, and + * an empty message. + */ const ERROR_PROPERTIES: FixedProperties = new Map([ [ 'name', { get: (error) => error.name, - configurable: true, set: (error, value) => { error.name = value; - } + }, + writable: true, + configurable: true, + inherited: { get: (error) => error.kind, writable: true } } ], [ 'message', { get: (error) => error.message, - configurable: true, set: (error, value) => { error.message = value; - } + }, + writable: true, + configurable: true, + inherited: { get: () => '', writable: true } } ], - ['stack', { get: (error) => error.stack, configurable: true }] + ['stack', { get: (error) => error.stack, writable: false, configurable: true }] ]); /** An error object as the script sees it: name, message and a stack of script positions. */ @@ -71,8 +86,10 @@ export class SandboxError extends SandboxObject { conversion: Conversion = new Conversion() ): Primitive { return conversion.convertError(() => { - const name = this.name === undefined ? 'Error' : toString(this.name, conversion); - const message = this.message === undefined ? '' : toString(this.message, conversion); + const nameValue = this.read('name'); + const messageValue = this.read('message'); + const name = nameValue === undefined ? 'Error' : toString(nameValue, conversion); + const message = messageValue === undefined ? '' : toString(messageValue, conversion); if (name === '') { return message; } @@ -83,14 +100,38 @@ export class SandboxError extends SandboxObject { }); } + /** An error names itself by its string, as in `Cannot delete property 'x' of Error: oops`. */ + public override describe(): string { + return describeValue(this); + } + /** The fixed part of the error as the host receives it; the boundary adds `properties`. */ public toInfo(): { name: string; message: string; stack: string } { return { - name: hostText(this.name, 'Error'), - message: hostText(this.message, ''), - stack: this.stack + name: hostText(this.read('name'), 'Error'), + message: hostText(this.read('message'), ''), + stack: hostText(this.read('stack'), '') }; } + + /** + * `name`, `message` or `stack` as a script reads it: what it was given or assigned, or after + * `delete`, what was assigned again or what the prototype has, which for `stack` is nothing. + */ + private read(key: 'name' | 'message' | 'stack'): unknown { + // The common case, a property the script never deleted, read straight from the field + if (this.properties.getFixedChange(key)?.deleted !== true) { + switch (key) { + case 'name': + return this.name; + case 'message': + return this.message; + case 'stack': + return this.stack; + } + } + return this.readProperty(key, this.properties, NOOP_CALLBACK); + } } /** diff --git a/packages/maple/src/values/sandbox-object.ts b/packages/maple/src/values/sandbox-object.ts index 1ff8f19..16c07b5 100644 --- a/packages/maple/src/values/sandbox-object.ts +++ b/packages/maple/src/values/sandbox-object.ts @@ -1,5 +1,11 @@ -import { type Conversion, describeObject, type Primitive, type PrimitiveHint } from './coercion.ts'; -import type { MemberContext, MethodLookup } from './members.ts'; +import { + type Conversion, + describeObject, + describeValue, + type Primitive, + type PrimitiveHint +} from './coercion.ts'; +import type { Fail, MemberContext, MethodLookup } from './members.ts'; import { type Attributes, type FixedChange, @@ -8,116 +14,276 @@ import { } from './own-properties.ts'; import type { RunState } from './run-state.ts'; +/* + * The objects Maple makes itself, apart from arrays and plain objects, which stay the host's own: + * + * - errors, + * - functions, + * - globals such as `Math`, and + * - the wrappers around a native Map, Set, RegExp, Date and so on. + * + * A script never reaches a host prototype, so each of these objects answers for its properties + * itself. Reading `m.label` off a Map looks in three places, in this order: + * + * 1. its fixed properties: what every value of its kind has, such as `size`, `lastIndex`, `name`; + * 2. what the script added: `m.label = 'x'` stores `label` per value, in {@link OwnProperties}; + * 3. its type's methods: `m.get`, which a {@link MethodLookup} finds, never stored per value. + * + * Every own property has the attributes JavaScript gives it (writable, enumerable, configurable), + * and assigning, deleting, `Object.seal` and `Object.freeze` follow the specification's rules for + * ordinary objects, so `Object.freeze(m); m.label = 'y'` throws as it does in Node. + */ + /** - * A property every value of a kind has, such as a Map's `size` or a RegExp's `lastIndex`, with the - * attributes JavaScript gives it. It is never enumerable. + * A property every value of a kind has from the moment it is made, with the attributes JavaScript + * gives it, such as a Map's `size`, a RegExp's `lastIndex` or a function's `name`. Its value is not + * stored: {@link FixedProperty.get} reads it off the value each time, so `m.size` is always the + * Map's current size. A fixed property is never enumerable, so `Object.keys` and `for...in` skip + * it. */ export interface FixedProperty { + /** Reads the current value off `target`, as `(wrapper) => wrapper.map.size` does. */ get(target: T): unknown; - /** Absent for a read-only property. */ + /** + * Writes it to `target`, as `r.lastIndex = 0` does. A writable property without one, such as + * `Math.max`, keeps what the script writes in the value's {@link OwnProperties} instead. + */ set?(target: T, value: unknown): void; - /** Its `configurable` attribute in JavaScript, until `seal` or `freeze` turns it off. */ + /** + * Its `writable` and `configurable` attributes as the kind starts it. `seal` and `freeze` can + * turn them off for a single value, which that value's {@link OwnProperties} records, not this + * shared table. + */ + readonly writable: boolean; readonly configurable: boolean; + /** + * `false` for a property JavaScript keeps as an accessor on the prototype instead of on each + * value, such as a Map's `size` or a RegExp's `source`. Every value of the kind still answers to + * it and `in` finds it, but it is not an own property: + * + * Object.hasOwn(m, 'size') → false + * Object.getOwnPropertyNames(m) → [] + * delete m.size → true, and `m.size` is unchanged + * + * Absent for an own property such as a RegExp's `lastIndex`. + */ + readonly own?: false; + /** + * What a read finds once `delete` removed the property, which in JavaScript comes from the + * prototype: `delete fn.name` leaves `''`, as `Function.prototype.name` is. Absent where the + * prototype has nothing, so `delete Math.max` leaves `undefined`. + */ + readonly inherited?: InheritedProperty; } +/** What JavaScript keeps on the prototype under the name of a fixed property. */ +export interface InheritedProperty { + get(target: T): unknown; + /** + * Whether the script can write it. If not, as for a function's `name`, assigning to the deleted + * property fails; if so, as for an error's `message`, the assignment adds an own property. + */ + readonly writable: boolean; +} + +/** The fixed properties of a kind by name, in the order `Object.getOwnPropertyNames` lists them. */ export type FixedProperties = ReadonlyMap>; /** For the kinds that have no fixed properties, such as Dates and host functions. */ export const NO_FIXED_PROPERTIES: FixedProperties = new Map(); -/** What script functions, built-ins and callable globals show, in the order JavaScript lists them. */ +/** + * The fixed properties of every function, in the order JavaScript lists them: + * + * - `length`, the number of parameters it declares: 2 for `function f(a, b) {}`; + * - `name`: `'f'` for the same function. + * + * Script functions, built-in functions and callable globals such as `String` share them. + */ export const FUNCTION_PROPERTIES: FixedProperties<{ readonly length: number | undefined; readonly name: string; }> = new Map([ - ['length', { get: (target) => target.length, configurable: true }], - ['name', { get: (target) => target.name, configurable: true }] + [ + 'length', + { + get: (target) => target.length, + writable: false, + configurable: true, + inherited: { get: () => 0, writable: false } + } + ], + [ + 'name', + { + get: (target) => target.name, + writable: false, + configurable: true, + inherited: { get: () => '', writable: false } + } + ] ]); /** - * What a shared value answers when asked for its own properties without the run: nothing, and it - * takes nothing, so a missed `getOwnProperties` call fails instead of carrying a property over. + * The {@link SandboxObject.properties} of a shared value (see {@link SandboxObject.isShared}). A + * shared value keeps nothing itself, because each run keeps what it adds in its {@link RunState}. + * This stand-in is empty and takes no new properties, so code that reads the field instead of + * asking {@link SandboxObject.getOwnProperties} fails at once, instead of carrying one run's + * property over into the next. */ const SHARED_STAND_IN = new OwnProperties(); SHARED_STAND_IN.preventExtensions(); -/** How far `Object.seal` and `Object.freeze` lock a value down. */ +/** + * How far `Object.seal` and `Object.freeze` lock a value down: + * + * - `sealed`: it takes no new properties, and none of its own can be deleted; + * - `frozen`: sealed, and none of its own can be written either. + */ export type IntegrityLevel = 'sealed' | 'frozen'; /** - * A value Maple made, other than arrays and plain objects, which stay the host's own: errors, - * functions, globals and wrappers. Each answers for its properties itself, so how a Map behaves - * is read off the Map. Fixed properties come first, then what the script added, then the methods - * of the value's type. Every own property has the attributes of JavaScript, and the rules below - * follow the spec's ordinary objects. + * The base of every value Maple makes itself, apart from arrays and plain objects (see the top of + * this file). A subclass names its {@link SandboxObject.tag} and its {@link SandboxObject.fixed} + * properties; reading, writing and deleting properties, `in` and the `Object` statics all go + * through the methods here. */ export abstract class SandboxObject { /** - * Whether every run shares the value, as `Math` and `[].push` are, so what a script adds to it is - * kept by the run instead of the value. + * Whether every run shares this one value, as they share `Math` and `[].push`: built-ins are + * made once when Maple loads, not for each run. A script may still add to one, as in + * `Math.cache = {}`, but the next run must not see that, so the run keeps what it adds (see + * {@link RunState}), not the value. A value a run makes, such as `new Map()`, is not shared. */ public readonly isShared: boolean; - /** The state of the own properties of a value it owns; for a shared one, the stand-in. */ + /** + * What the script added to a value that is not shared, and how it changed the value's fixed + * properties ({@link FixedChange}). A shared value has the empty {@link SHARED_STAND_IN} here; + * {@link SandboxObject.getOwnProperties} works for both. + */ public readonly properties: OwnProperties; - /** The type as `[object Map]` prints it, the name JavaScript keeps as `Symbol.toStringTag`. */ + /** + * The type's name as `Object.prototype.toString` prints it, the `Map` in `[object Map]`. + * JavaScript keeps it as `Symbol.toStringTag`. + */ public abstract readonly tag: string; - /** Whether the script can call it, which is what `typeof` reports as `function`. */ + /** Whether the script can call it, as in `fn()`; `typeof` then reports it as `'function'`. */ public readonly isCallable: boolean = false; - /** Shared by every value of the kind, apart from globals, whose members differ. */ + /** + * The fixed properties of the value's kind (see {@link FixedProperty}). Every value of a kind, + * such as every Map, shares one table; only the globals have one each, as their members differ. + */ protected abstract readonly fixed: FixedProperties; - /** `isShared` for a value every run uses, whose own properties each run keeps for itself. */ + /** `isShared` is `true` for a value every run uses (see {@link SandboxObject.isShared}). */ protected constructor(isShared: boolean) { this.isShared = isShared; this.properties = isShared ? SHARED_STAND_IN : new OwnProperties(); } - /** The state of its own properties, during `runState`'s run if the value is shared. */ + /** + * What the script added to this value, and how it changed the value's fixed properties + * ({@link FixedChange}). For a shared value that is what `runState`'s run did, so two runs never + * see each other's changes. Writes use it, as it makes the run's entry on first use. + */ public getOwnProperties(runState: RunState): OwnProperties { return this.isShared ? runState.getOwnPropertiesOf(this) : this.properties; } /** - * ToPrimitive without hooks, so a script cannot influence a conversion: `[object Map]`. A value - * whose conversion converts others, as an error does, hands them `conversion`. + * What the script changed on this value, or `undefined` while a run changed nothing on a shared + * value. Reads use it, so reading `Math.max` makes no entry in the run. + */ + public peekOwnProperties(runState: RunState): OwnProperties | undefined { + return this.isShared ? runState.peekOwnPropertiesOf(this) : this.properties; + } + + /** + * Converts the value to a primitive, as `String(m)` and `m + ''` do: `'[object Map]'`. JavaScript + * would first call a `toString` or `valueOf` the script defined; Maple never does, so a script + * cannot run code in the middle of a conversion (see `docs/divergences.md`). A value whose + * conversion converts other values, as an error converts its `name` and `message`, hands them + * `conversion`. */ public toPrimitive(_hint: PrimitiveHint, _conversion?: Conversion): Primitive { return `[object ${this.tag}]`; } - /** The attributes of the own property under `key`, or `undefined` if there is none. */ + /** + * How a failed `delete` names the value in JavaScript, as in + * `Cannot delete property 'x' of #`: a function by its string, anything else by its tag. + */ + public describe(): string { + return this.isCallable ? describeValue(this) : `#<${this.tag}>`; + } + + /** + * The attributes (writable, enumerable, configurable) of the own property under `key`, or + * `undefined` when the value has no such own property. A fixed property that is not own, such as + * a Map's `size`, has none, and neither has one that `delete` removed. + */ public getOwnPropertyAttributes(key: string, runState: RunState): Attributes | undefined { - const properties = this.getOwnProperties(runState); + const properties = this.peekOwnProperties(runState); const fixed = this.fixed.get(key); - return fixed ? fixedAttributes(key, fixed, properties) : properties.getAdded(key); + if (fixed !== undefined && fixed.own !== false) { + const change = properties?.getFixedChange(key); + if (change?.deleted !== true) { + return fixedAttributes(fixed, change); + } + } + + return properties?.getAdded(key); } + /** + * Reads `value[key]`, looking in this order: + * + * 1. A fixed property, unless `delete` removed it; + * 2. One the script added; + * 3. What a removed fixed property inherits, such as `''` for a function's `name`; + * 4. A method of the value's type, which `methodLookup` finds. + */ public getProperty(key: string, runState: RunState, methodLookup: MethodLookup): unknown { - const fixed = this.fixed.get(key); - if (fixed) { - return fixed.get(this); - } - const added = this.getOwnProperties(runState).getAdded(key); - return added ? added.value : methodLookup(this, key); + return this.readProperty(key, this.peekOwnProperties(runState), methodLookup); } + /** + * Assigns `value` to the property under `key`, as `obj[key] = value` does in strict mode. It is a + * TypeError to: + * + * - Write a read-only property, such as `m.size = 1` or one that `freeze` locked; + * - Write a property that `delete` removed where what it inherits is read-only, as `delete fn.name; + * fn.name = 'x'` does; + * - Add a property to a value that takes no new ones. + * + * Anything else overwrites the property or adds it. + */ public setProperty(key: string, value: unknown, { fail, runState }: MemberContext): void { - const properties = this.getOwnProperties(runState); const fixed = this.fixed.get(key); - if (fixed) { - if (!fixed.set || !fixedAttributes(key, fixed, properties).writable) { - const article = /^[AEIOU]/.test(this.tag) ? 'an' : 'a'; - return fail( - 'TypeError', - `Cannot assign to read-only property '${key}' of ${article} ${this.tag}` - ); + if (fixed !== undefined) { + const change = this.peekOwnProperties(runState)?.getFixedChange(key); + if (change?.deleted !== true) { + if (!fixedAttributes(fixed, change).writable) { + return this.failReadOnly(key, fail); + } + if (fixed.set) { + return fixed.set(this, value); + } + return this.getOwnProperties(runState).changeFixed(key, { replaced: { value } }); + } + if ( + fixed.inherited?.writable === false && + this.getOwnProperties(runState).getAdded(key) === undefined + ) { + return this.failReadOnly(key, fail); } - return fixed.set(this, value); } + + const properties = this.getOwnProperties(runState); const added = properties.getAdded(key); if (added && !added.writable) { return fail('TypeError', `Cannot assign to read-only property '${key}'`); @@ -128,69 +294,135 @@ export abstract class SandboxObject { if (added) { added.value = value; } else { - // What an assignment creates + // An assignment creates a property that is writable, enumerable and configurable properties.add(key, value, { writable: true, enumerable: true, configurable: true }); } } /** - * A fixed property stays: deleting it changes nothing, unless `seal` or `freeze` locked it, which - * makes the delete fail as in JavaScript. + * `delete value[key]`, which gives `true`: + * + * - A configurable property is removed, whether the kind gives it, as `fn.name`, or the script + * added it; + * - One that is not own, such as `size`, was never there to delete. + * + * As in JavaScript, deleting a property that is not configurable is a TypeError: a RegExp's + * `lastIndex`, `Math.PI`, or anything `seal` or `freeze` locked. */ public deleteProperty(key: string, { fail, runState }: MemberContext): boolean { - const properties = this.getOwnProperties(runState); const fixed = this.fixed.get(key); - - let locked: boolean; - if (fixed) { - locked = fixed.configurable && !fixedAttributes(key, fixed, properties).configurable; - } else { - locked = properties.getAdded(key)?.configurable === false; + if (fixed !== undefined && fixed.own !== false) { + const change = this.peekOwnProperties(runState)?.getFixedChange(key); + if (change?.deleted !== true) { + if (!fixedAttributes(fixed, change).configurable) { + return this.failDelete(key, fail); + } + this.getOwnProperties(runState).changeFixed(key, { deleted: true, replaced: undefined }); + return true; + } } - if (locked) { - return fail('TypeError', `Cannot delete property '${key}' of ${describeObject(this)}`); + const properties = this.peekOwnProperties(runState); + const added = properties?.getAdded(key); + if (properties === undefined || added === undefined) { + return true; + } + if (!added.configurable) { + return this.failDelete(key, fail); } - properties.deleteAdded(key); return true; } + /** + * What `key in value` answers for a property: + * + * - A fixed one counts even when it is not own, so `'size' in new Map()` is `true`; + * - So does one the script added; + * - And one that `delete` removed still counts when it inherits a value, as `'name' in fn` does. + */ public hasProperty(key: string, runState: RunState): boolean { - return this.fixed.has(key) || this.getOwnProperties(runState).getAdded(key) !== undefined; + const properties = this.peekOwnProperties(runState); + const fixed = this.fixed.get(key); + if (fixed !== undefined && properties?.getFixedChange(key)?.deleted !== true) { + return true; + } + return properties?.getAdded(key) !== undefined || fixed?.inherited !== undefined; + } + + /** + * What `Object.hasOwn(value, key)` answers: like {@link SandboxObject.hasProperty}, except that a + * fixed property that is not own, or was deleted, does not count, so + * `Object.hasOwn(new Map(), 'size')` is `false`. + */ + public hasOwnProperty(key: string, runState: RunState): boolean { + const properties = this.peekOwnProperties(runState); + const fixed = this.fixed.get(key); + if ( + fixed !== undefined && + fixed.own !== false && + properties?.getFixedChange(key)?.deleted !== true + ) { + return true; + } + return properties?.getAdded(key) !== undefined; } /** - * In the order JavaScript lists them: array indices first, then the fixed properties, which - * exist from the start, then the rest of what the script added. + * The own property names, in the order `Object.getOwnPropertyNames` lists them in JavaScript: + * + * 1. Array indices such as `'0'`; + * 2. The own fixed properties, which exist from the moment the value is made, unless deleted; + * 3. The other names the script added, in the order it added them. + * + * After `fn.extra = 1`, a function lists `['length', 'name', 'extra']`. A fixed property deleted + * and then assigned again counts as added, so it moves to the end, as in JavaScript. */ public ownKeys(runState: RunState): Array { - const added = this.getOwnProperties(runState).addedKeys(); - return [ - ...added.filter(isArrayIndex), - ...this.fixed.keys(), - ...added.filter((key) => !isArrayIndex(key)) - ]; + const properties = this.peekOwnProperties(runState); + const added = properties?.addedKeys() ?? []; + const keys = added.filter(isArrayIndex); + for (const [key, fixed] of this.fixed) { + if (fixed.own !== false && properties?.getFixedChange(key)?.deleted !== true) { + keys.push(key); + } + } + for (const key of added) { + if (!isArrayIndex(key)) { + keys.push(key); + } + } + return keys; } - /** `Object.preventExtensions`: no new property from now on, which cannot be undone. */ + /** + * `Object.preventExtensions(value)`: from now on, adding a property is a TypeError. The existing + * ones can still be written and deleted. It cannot be undone. + */ public preventExtensions(runState: RunState): void { this.getOwnProperties(runState).preventExtensions(); } + /** `Object.isExtensible(value)`: whether the value still takes new properties. */ public isExtensible(runState: RunState): boolean { - return this.getOwnProperties(runState).isExtensible; + return this.peekOwnProperties(runState)?.isExtensible ?? true; } - /** `Object.seal` and `Object.freeze`: not extensible, and every own property locked down. */ + /** + * `Object.seal` and `Object.freeze`: the value takes no new properties and none of its own can be + * deleted; `freeze` also makes each of them read-only. + */ public setIntegrityLevel(level: IntegrityLevel, runState: RunState): void { this.lockDown(level, this.getOwnProperties(runState)); } - /** `Object.isSealed` and `Object.isFrozen`: derived from the properties, as JavaScript does. */ + /** + * `Object.isSealed` and `Object.isFrozen`. As in JavaScript, they look at the properties, not at + * whether `seal` or `freeze` was called: a value that takes no new properties and has none that + * can be deleted (or, for frozen, written) counts as sealed (or frozen). + */ public testIntegrityLevel(level: IntegrityLevel, runState: RunState): boolean { - const properties = this.getOwnProperties(runState); - if (properties.isExtensible) { + if (this.isExtensible(runState)) { return false; } return this.ownKeys(runState).every((key) => { @@ -203,17 +435,70 @@ export abstract class SandboxObject { }); } - /** For a value frozen from the start, such as a wrapper that came in with the input. */ + /** Freezes the value as it is made, for a wrapper that came in with the input. */ protected freezeOwnProperties(): void { this.lockDown('frozen', this.properties); } + /** + * {@link SandboxObject.getProperty} with the value's own properties at hand, for an error, which + * converts itself by reading its `name` and `message` the way a script does. + */ + protected readProperty( + key: string, + properties: OwnProperties | undefined, + methodLookup: MethodLookup + ): unknown { + const fixed = this.fixed.get(key); + if (fixed !== undefined) { + const change = properties?.getFixedChange(key); + // The common case, a fixed property the script left as it is + if (change === undefined) { + return fixed.get(this); + } + if (change.deleted !== true) { + return change.replaced ? change.replaced.value : fixed.get(this); + } + } + const added = properties?.getAdded(key); + if (added !== undefined) { + return added.value; + } + return fixed?.inherited ? fixed.inherited.get(this) : methodLookup(this, key); + } + + private failReadOnly(key: string, fail: Fail): never { + const article = /^[AEIOU]/.test(this.tag) ? 'an' : 'a'; + return fail( + 'TypeError', + `Cannot assign to read-only property '${key}' of ${article} ${this.tag}` + ); + } + + private failDelete(key: string, fail: Fail): never { + return fail('TypeError', `Cannot delete property '${key}' of ${describeObject(this)}`); + } + + /** + * What `seal` and `freeze` do to `properties`: + * + * - No new properties; + * - Every own property becomes non-configurable; + * - For `frozen`, every own property becomes read-only too. + * + * The kind's table of fixed properties is shared by every value of the kind, so what changes for + * a fixed property on this one value is recorded in `properties` + * ({@link OwnProperties.changeFixed}). A fixed property that is not own, or was deleted, is left + * alone. + */ private lockDown(level: IntegrityLevel, properties: OwnProperties): void { properties.preventExtensions(); const change: FixedChange = level === 'frozen' ? { writable: false, configurable: false } : { configurable: false }; - for (const key of this.fixed.keys()) { - properties.changeFixed(key, change); + for (const [key, fixed] of this.fixed) { + if (fixed.own !== false && properties.getFixedChange(key)?.deleted !== true) { + properties.changeFixed(key, change); + } } for (const key of properties.addedKeys()) { const added = properties.getAdded(key); @@ -225,15 +510,13 @@ export abstract class SandboxObject { } } -/** A fixed property's attributes: what its kind gives it, less what `seal` or `freeze` took. */ -function fixedAttributes( - key: string, - fixed: FixedProperty, - properties: OwnProperties -): Attributes { - const change = properties.getFixedChange(key); +/** + * A fixed property's attributes on one value: what its kind starts it with, less what `seal` or + * `freeze` took away on this value (`change`). It is never enumerable. + */ +function fixedAttributes(fixed: FixedProperty, change: FixedChange | undefined): Attributes { return { - writable: fixed.set !== undefined && change?.writable !== false, + writable: fixed.writable && change?.writable !== false, enumerable: false, configurable: fixed.configurable && change?.configurable !== false }; diff --git a/packages/maple/src/values/wrappers/date-wrapper.ts b/packages/maple/src/values/wrappers/date-wrapper.ts index 75f39da..95fd41a 100644 --- a/packages/maple/src/values/wrappers/date-wrapper.ts +++ b/packages/maple/src/values/wrappers/date-wrapper.ts @@ -21,6 +21,11 @@ export class DateWrapper extends SandboxObject { } /** The one value that converts differently for a number than for a string. */ + /** JavaScript names a Date as `[object Date]` when a `delete` fails on it. */ + public override describe(): string { + return `[object Date]`; + } + public override toPrimitive(hint: PrimitiveHint): Primitive { return hint === 'number' ? this.date.getTime() : this.date.toString(); } diff --git a/packages/maple/src/values/wrappers/map-wrapper.ts b/packages/maple/src/values/wrappers/map-wrapper.ts index 1196b4b..27aefd5 100644 --- a/packages/maple/src/values/wrappers/map-wrapper.ts +++ b/packages/maple/src/values/wrappers/map-wrapper.ts @@ -1,7 +1,12 @@ import { type FixedProperties, SandboxObject } from '../sandbox-object.ts'; +/** + * A Map's one fixed property, `size`, its number of entries. JavaScript keeps it as an accessor on + * `Map.prototype`, not on each Map, so its entry says `own: false`. It has no setter, so + * `m.size = 1` is a TypeError. + */ const MAP_PROPERTIES: FixedProperties = new Map([ - ['size', { get: (wrapper) => wrapper.map.size, configurable: false }] + ['size', { get: (wrapper) => wrapper.map.size, writable: false, configurable: true, own: false }] ]); export class MapWrapper extends SandboxObject { diff --git a/packages/maple/src/values/wrappers/regexp-wrapper.ts b/packages/maple/src/values/wrappers/regexp-wrapper.ts index a81a8db..9db56d3 100644 --- a/packages/maple/src/values/wrappers/regexp-wrapper.ts +++ b/packages/maple/src/values/wrappers/regexp-wrapper.ts @@ -1,28 +1,52 @@ import { type Primitive, toNumber } from '../coercion.ts'; import { type FixedProperties, SandboxObject } from '../sandbox-object.ts'; -/** What a regexp shows; `lastIndex` is the one that can be written. */ +/** + * A regexp's fixed properties: + * + * - `lastIndex`, where the next `exec` or `test` with the `g` or `y` flag starts to search. It is the + * one own property, and the only one a script can write. + * - `source`, the pattern's text. + * - `flags`, all flags as a string such as `'gi'`. + * - One per flag, such as `global` for `g`. + * + * JavaScript keeps all but `lastIndex` as accessors on `RegExp.prototype`, so their entries say + * `own: false`, and they have no setter. + */ const REGEXP_PROPERTIES: FixedProperties = new Map([ [ 'lastIndex', { get: (r) => r.regexp.lastIndex, + writable: true, configurable: false, set: (r, value) => { r.regexp.lastIndex = toNumber(value); } } ], - ['source', { get: (r) => r.regexp.source, configurable: false }], - ['flags', { get: (r) => r.regexp.flags, configurable: false }], - ['global', { get: (r) => r.regexp.global, configurable: false }], - ['ignoreCase', { get: (r) => r.regexp.ignoreCase, configurable: false }], - ['multiline', { get: (r) => r.regexp.multiline, configurable: false }], - ['sticky', { get: (r) => r.regexp.sticky, configurable: false }], - ['unicode', { get: (r) => r.regexp.unicode, configurable: false }], - ['dotAll', { get: (r) => r.regexp.dotAll, configurable: false }], - ['hasIndices', { get: (r) => r.regexp.hasIndices, configurable: false }], - ['unicodeSets', { get: (r) => r.regexp.unicodeSets, configurable: false }] + ['source', { get: (r) => r.regexp.source, writable: false, configurable: true, own: false }], + ['flags', { get: (r) => r.regexp.flags, writable: false, configurable: true, own: false }], + ['global', { get: (r) => r.regexp.global, writable: false, configurable: true, own: false }], + [ + 'ignoreCase', + { get: (r) => r.regexp.ignoreCase, writable: false, configurable: true, own: false } + ], + [ + 'multiline', + { get: (r) => r.regexp.multiline, writable: false, configurable: true, own: false } + ], + ['sticky', { get: (r) => r.regexp.sticky, writable: false, configurable: true, own: false }], + ['unicode', { get: (r) => r.regexp.unicode, writable: false, configurable: true, own: false }], + ['dotAll', { get: (r) => r.regexp.dotAll, writable: false, configurable: true, own: false }], + [ + 'hasIndices', + { get: (r) => r.regexp.hasIndices, writable: false, configurable: true, own: false } + ], + [ + 'unicodeSets', + { get: (r) => r.regexp.unicodeSets, writable: false, configurable: true, own: false } + ] ]); export class RegExpWrapper extends SandboxObject { @@ -44,6 +68,11 @@ export class RegExpWrapper extends SandboxObject { } } + /** JavaScript names a RegExp as `[object RegExp]` when a `delete` fails on it. */ + public override describe(): string { + return `[object RegExp]`; + } + public override toPrimitive(): Primitive { return String(this.regexp); } diff --git a/packages/maple/src/values/wrappers/set-wrapper.ts b/packages/maple/src/values/wrappers/set-wrapper.ts index 072d432..28f8828 100644 --- a/packages/maple/src/values/wrappers/set-wrapper.ts +++ b/packages/maple/src/values/wrappers/set-wrapper.ts @@ -1,7 +1,12 @@ import { type FixedProperties, SandboxObject } from '../sandbox-object.ts'; +/** + * A Set's one fixed property, `size`, its number of values. JavaScript keeps it as an accessor on + * `Set.prototype`, not on each Set, so its entry says `own: false`. It has no setter, so + * `s.size = 1` is a TypeError. + */ const SET_PROPERTIES: FixedProperties = new Map([ - ['size', { get: (wrapper) => wrapper.set.size, configurable: false }] + ['size', { get: (wrapper) => wrapper.set.size, writable: false, configurable: true, own: false }] ]); export class SetWrapper extends SandboxObject { 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 ); }