From c726ab5bf9061d2fed11c2f94db229780d2fb9b2 Mon Sep 17 00:00:00 2001 From: NetWin Date: Sat, 24 Oct 2026 18:13:47 +0200 Subject: [PATCH 1/4] test(parity): run Node last so a case can restore what it changes Node runs each case in the test process, so a case that replaces or deletes a built-in changes it for js-interpreter and Maple too. Every helper now runs Node last, and such a case restores the built-in in a finally before anything else runs. --- AGENTS.md | 2 ++ packages/maple-parity/src/parity.ts | 15 ++++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) 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/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; } /** From b4d9048d85065520a3383d5e0368c3a149d9ef29 Mon Sep 17 00:00:00 2001 From: NetWin Date: Sat, 24 Oct 2026 21:42:43 +0200 Subject: [PATCH 2/4] fix(properties): stop listing type accessors as own properties A Map's or Set's size and a RegExp's source and flags are accessors on the prototype in JavaScript, not own properties. Every value still answers to them and in finds them, but Object.hasOwn and Object.getOwnPropertyNames now leave them out, and deleting one changes nothing. lastIndex stays a RegExp's one own property. --- packages/maple-parity/README.md | 1 + packages/maple-parity/src/divergences.spec.ts | 19 ++ packages/maple/docs/language.md | 8 +- packages/maple/src/builtins/map/map.spec.ts | 14 +- .../maple/src/builtins/object/object.spec.ts | 7 +- .../src/builtins/object/statics/has-own.ts | 16 +- .../maple/src/builtins/regexp/regexp.spec.ts | 12 + packages/maple/src/values/members.ts | 37 ++- packages/maple/src/values/sandbox-object.ts | 252 +++++++++++++++--- .../maple/src/values/wrappers/map-wrapper.ts | 7 +- .../src/values/wrappers/regexp-wrapper.ts | 33 ++- .../maple/src/values/wrappers/set-wrapper.ts | 7 +- 12 files changed, 345 insertions(+), 68 deletions(-) diff --git a/packages/maple-parity/README.md b/packages/maple-parity/README.md index 74562b5..a8dcb2a 100644 --- a/packages/maple-parity/README.md +++ b/packages/maple-parity/README.md @@ -67,6 +67,7 @@ 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. | ### Maple departs from JavaScript diff --git a/packages/maple-parity/src/divergences.spec.ts b/packages/maple-parity/src/divergences.spec.ts index 851d4c0..e61db41 100644 --- a/packages/maple-parity/src/divergences.spec.ts +++ b/packages/maple-parity/src/divergences.spec.ts @@ -275,6 +275,25 @@ 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, '(?:)'] + }); + }); +}); + // 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/docs/language.md b/packages/maple/docs/language.md index 739a8f4..2d9c4a6 100644 --- a/packages/maple/docs/language.md +++ b/packages/maple/docs/language.md @@ -111,9 +111,11 @@ Inside the sandbox exactly these values exist: 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/builtins/map/map.spec.ts b/packages/maple/src/builtins/map/map.spec.ts index 2bcf204..740f41e 100644 --- a/packages/maple/src/builtins/map/map.spec.ts +++ b/packages/maple/src/builtins/map/map.spec.ts @@ -87,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([]); @@ -106,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/object/object.spec.ts b/packages/maple/src/builtins/object/object.spec.ts index 87c4fde..76740b1 100644 --- a/packages/maple/src/builtins/object/object.spec.ts +++ b/packages/maple/src/builtins/object/object.spec.ts @@ -220,7 +220,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 +229,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' 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/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/sandbox-object.ts b/packages/maple/src/values/sandbox-object.ts index 1ff8f19..456aa34 100644 --- a/packages/maple/src/values/sandbox-object.ts +++ b/packages/maple/src/values/sandbox-object.ts @@ -8,24 +8,71 @@ 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, as `r.lastIndex = 0` does; absent for a read-only property such as `size`. */ set?(target: T, value: unknown): void; - /** Its `configurable` attribute in JavaScript, until `seal` or `freeze` turns it off. */ + /** + * Its `configurable` attribute as the kind starts it. `seal` and `freeze` can turn it off for a + * single value, which that value's {@link OwnProperties} records, not this shared table. + */ 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; } +/** 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; @@ -35,67 +82,111 @@ export const FUNCTION_PROPERTIES: FixedProperties<{ ]); /** - * 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 what `seal` or `freeze` changed on it. + * 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 what `seal` or `freeze` changed on it. For a shared + * value that is what `runState`'s run did, so two runs never see each other's changes. + */ 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`. + * 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. */ + /** + * 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 either. + */ public getOwnPropertyAttributes(key: string, runState: RunState): Attributes | undefined { const properties = this.getOwnProperties(runState); const fixed = this.fixed.get(key); - return fixed ? fixedAttributes(key, fixed, properties) : properties.getAdded(key); + if (fixed?.own === false) { + return undefined; + } + + if (fixed) { + return fixedAttributes(key, fixed, properties); + } + + return properties.getAdded(key); } + /** + * Reads `value[key]`, looking in this order: + * + * 1. A fixed property; + * 2. One the script added; + * 3. 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) { @@ -105,6 +196,15 @@ export abstract class SandboxObject { return added ? added.value : methodLookup(this, key); } + /** + * 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, or + * - 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); @@ -128,14 +228,20 @@ 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 property the script added is removed; + * - A fixed property stays, so deleting it changes nothing; + * - One that is not own, such as `size`, was never there to delete. + * + * As in JavaScript, deleting a property that `seal` or `freeze` locked is a TypeError: + * `Object.freeze(e); delete e.message` throws. */ public deleteProperty(key: string, { fail, runState }: MemberContext): boolean { const properties = this.getOwnProperties(runState); @@ -143,7 +249,10 @@ export abstract class SandboxObject { let locked: boolean; if (fixed) { - locked = fixed.configurable && !fixedAttributes(key, fixed, properties).configurable; + locked = + fixed.own !== false && + fixed.configurable && + !fixedAttributes(key, fixed, properties).configurable; } else { locked = properties.getAdded(key)?.configurable === false; } @@ -156,38 +265,78 @@ export abstract class SandboxObject { 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`, and so does one the script added. + */ public hasProperty(key: string, runState: RunState): boolean { return this.fixed.has(key) || this.getOwnProperties(runState).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. + * What `Object.hasOwn(value, key)` answers: like {@link SandboxObject.hasProperty}, except that a + * fixed property that is not own does not count, so `Object.hasOwn(new Map(), 'size')` is + * `false`. + */ + public hasOwnProperty(key: string, runState: RunState): boolean { + const fixed = this.fixed.get(key); + if (fixed) { + return fixed.own !== false; + } + return this.getOwnProperties(runState).getAdded(key) !== undefined; + } + + /** + * 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; + * 3. The other names the script added, in the order it added them. + * + * After `fn.extra = 1`, a function lists `['length', 'name', 'extra']`. */ public ownKeys(runState: RunState): Array { const added = this.getOwnProperties(runState).addedKeys(); - return [ - ...added.filter(isArrayIndex), - ...this.fixed.keys(), - ...added.filter((key) => !isArrayIndex(key)) - ]; + const keys = added.filter(isArrayIndex); + for (const [key, fixed] of this.fixed) { + if (fixed.own !== false) { + 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; } - /** `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) { @@ -203,17 +352,30 @@ 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); } + /** + * 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}). + */ 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.changeFixed(key, change); + } } for (const key of properties.addedKeys()) { const added = properties.getAdded(key); @@ -225,7 +387,11 @@ export abstract class SandboxObject { } } -/** A fixed property's attributes: what its kind gives it, less what `seal` or `freeze` took. */ +/** + * A fixed property's attributes on one value: what its kind starts it with, less what `seal` or + * `freeze` took away on this value. It is writable only if it has a {@link FixedProperty.set}, and + * never enumerable. + */ function fixedAttributes( key: string, fixed: FixedProperty, diff --git a/packages/maple/src/values/wrappers/map-wrapper.ts b/packages/maple/src/values/wrappers/map-wrapper.ts index 1196b4b..d5dca2d 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, 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..eedaccd 100644 --- a/packages/maple/src/values/wrappers/regexp-wrapper.ts +++ b/packages/maple/src/values/wrappers/regexp-wrapper.ts @@ -1,7 +1,18 @@ 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', @@ -13,16 +24,16 @@ const REGEXP_PROPERTIES: FixedProperties = new Map([ } } ], - ['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, configurable: true, own: false }], + ['flags', { get: (r) => r.regexp.flags, configurable: true, own: false }], + ['global', { get: (r) => r.regexp.global, configurable: true, own: false }], + ['ignoreCase', { get: (r) => r.regexp.ignoreCase, configurable: true, own: false }], + ['multiline', { get: (r) => r.regexp.multiline, configurable: true, own: false }], + ['sticky', { get: (r) => r.regexp.sticky, configurable: true, own: false }], + ['unicode', { get: (r) => r.regexp.unicode, configurable: true, own: false }], + ['dotAll', { get: (r) => r.regexp.dotAll, configurable: true, own: false }], + ['hasIndices', { get: (r) => r.regexp.hasIndices, configurable: true, own: false }], + ['unicodeSets', { get: (r) => r.regexp.unicodeSets, configurable: true, own: false }] ]); export class RegExpWrapper extends SandboxObject { diff --git a/packages/maple/src/values/wrappers/set-wrapper.ts b/packages/maple/src/values/wrappers/set-wrapper.ts index 072d432..a6e7827 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, configurable: true, own: false }] ]); export class SetWrapper extends SandboxObject { From 2f1ef5727d2a3c835ba4bd620e6a583b65853f1a Mon Sep 17 00:00:00 2001 From: NetWin Date: Sat, 24 Oct 2026 23:49:34 +0200 Subject: [PATCH 3/4] fix(properties): let scripts delete and replace configurable built-in properties As in JavaScript, a function's name and length can be deleted, which leaves what Function.prototype has, and the methods of a built-in such as Math.max can be replaced and deleted, while constants such as Math.PI and a RegExp's lastIndex cannot be deleted. An error's name, message and stack can be deleted too. What a run changes on a shared built-in stays in that run. --- packages/maple-parity/README.md | 2 + .../maple-parity/src/cases/errors.spec.ts | 16 + .../maple-parity/src/cases/functions.spec.ts | 7 + packages/maple-parity/src/cases/math.spec.ts | 21 ++ packages/maple-parity/src/divergences.spec.ts | 31 ++ packages/maple/docs/builtins.md | 4 + packages/maple/docs/language.md | 11 +- packages/maple/src/builtins/json/json.spec.ts | 5 +- packages/maple/src/builtins/math/math.spec.ts | 30 +- .../maple/src/builtins/object/object.spec.ts | 32 +- packages/maple/src/errors.spec.ts | 27 ++ packages/maple/src/escapes.spec.ts | 18 +- .../src/evaluator/language/functions.spec.ts | 48 ++- packages/maple/src/support/noop.ts | 5 + packages/maple/src/values/builtin.ts | 33 ++- packages/maple/src/values/coercion.ts | 6 +- packages/maple/src/values/own-properties.ts | 17 +- packages/maple/src/values/run-state.ts | 10 + packages/maple/src/values/sandbox-error.ts | 56 +++- packages/maple/src/values/sandbox-object.ts | 279 +++++++++++++----- .../maple/src/values/wrappers/date-wrapper.ts | 5 + .../maple/src/values/wrappers/map-wrapper.ts | 2 +- .../src/values/wrappers/regexp-wrapper.ts | 38 ++- .../maple/src/values/wrappers/set-wrapper.ts | 2 +- 24 files changed, 567 insertions(+), 138 deletions(-) create mode 100644 packages/maple/src/support/noop.ts diff --git a/packages/maple-parity/README.md b/packages/maple-parity/README.md index a8dcb2a..e8190f8 100644 --- a/packages/maple-parity/README.md +++ b/packages/maple-parity/README.md @@ -68,6 +68,8 @@ js-interpreter differs from JavaScript here; Maple does what Node does. | `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 e61db41..bff0422 100644 --- a/packages/maple-parity/src/divergences.spec.ts +++ b/packages/maple-parity/src/divergences.spec.ts @@ -294,6 +294,37 @@ describe('Object.getOwnPropertyNames:RegExp', () => { }); }); +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/docs/builtins.md b/packages/maple/docs/builtins.md index 0021e73..4db94eb 100644 --- a/packages/maple/docs/builtins.md +++ b/packages/maple/docs/builtins.md @@ -176,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 2d9c4a6..fc2d1d7 100644 --- a/packages/maple/docs/language.md +++ b/packages/maple/docs/language.md @@ -68,8 +68,9 @@ side, a script function, a bound function or `String` included, throws a `TypeEr ## 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 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/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/object/object.spec.ts b/packages/maple/src/builtins/object/object.spec.ts index 76740b1..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'); }); }); @@ -298,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 #"); @@ -323,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]" + ); }); }); @@ -435,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/errors.spec.ts b/packages/maple/src/errors.spec.ts index ebaec88..10f5ebe 100644 --- a/packages/maple/src/errors.spec.ts +++ b/packages/maple/src/errors.spec.ts @@ -792,3 +792,30 @@ 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('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 5cbb83a..743db96 100644 --- a/packages/maple/src/escapes.spec.ts +++ b/packages/maple/src/escapes.spec.ts @@ -246,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]); @@ -527,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) @@ -545,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/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/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/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..41eec47 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,31 @@ 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, ''), + name: hostText(this.read('name'), 'Error'), + message: hostText(this.read('message'), ''), stack: this.stack }; } + + /** + * `name` or `message` as a script reads it: what it was given or assigned, or after `delete`, + * what was assigned again or what the prototype has. + */ + private read(key: 'name' | 'message'): unknown { + // The common case, a property the script never deleted, read straight from the field + if (this.properties.getFixedChange(key)?.deleted !== true) { + return key === 'name' ? this.name : this.message; + } + 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 456aa34..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, @@ -38,12 +44,17 @@ import type { RunState } from './run-state.ts'; export interface FixedProperty { /** Reads the current value off `target`, as `(wrapper) => wrapper.map.size` does. */ get(target: T): unknown; - /** Writes it, as `r.lastIndex = 0` does; absent for a read-only property such as `size`. */ + /** + * 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 as the kind starts it. `seal` and `freeze` can turn it off for a - * single value, which that value's {@link OwnProperties} records, not this shared table. + * 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 @@ -57,6 +68,22 @@ export interface FixedProperty { * 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. */ @@ -77,8 +104,24 @@ 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 } + } + ] ]); /** @@ -115,8 +158,8 @@ export abstract class SandboxObject { public readonly isShared: boolean; /** - * What the script added to a value that is not shared, and what `seal` or `freeze` changed on it. - * A shared value has the empty {@link SHARED_STAND_IN} here; + * 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; @@ -143,13 +186,22 @@ export abstract class SandboxObject { } /** - * What the script added to this value, and what `seal` or `freeze` changed on it. For a shared - * value that is what `runState`'s run did, so two runs never see each other's changes. + * 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; } + /** + * 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 @@ -161,63 +213,77 @@ export abstract class SandboxObject { return `[object ${this.tag}]`; } + /** + * 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 either. + * 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); - if (fixed?.own === false) { - return undefined; - } - - if (fixed) { - return fixedAttributes(key, fixed, properties); + if (fixed !== undefined && fixed.own !== false) { + const change = properties?.getFixedChange(key); + if (change?.deleted !== true) { + return fixedAttributes(fixed, change); + } } - return properties.getAdded(key); + return properties?.getAdded(key); } /** * Reads `value[key]`, looking in this order: * - * 1. A fixed property; + * 1. A fixed property, unless `delete` removed it; * 2. One the script added; - * 3. A method of the value's type, which `methodLookup` finds. + * 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, or + * - 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}'`); @@ -236,70 +302,88 @@ export abstract class SandboxObject { /** * `delete value[key]`, which gives `true`: * - * - A property the script added is removed; - * - A fixed property stays, so deleting it changes nothing; + * - 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 `seal` or `freeze` locked is a TypeError: - * `Object.freeze(e); delete e.message` throws. + * 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.own !== false && - 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`, and so does one the script added. + * 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 does not count, so `Object.hasOwn(new Map(), 'size')` is - * `false`. + * 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) { - return fixed.own !== false; + if ( + fixed !== undefined && + fixed.own !== false && + properties?.getFixedChange(key)?.deleted !== true + ) { + return true; } - return this.getOwnProperties(runState).getAdded(key) !== undefined; + return properties?.getAdded(key) !== undefined; } /** * 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; + * 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']`. + * 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(); + 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) { + if (fixed.own !== false && properties?.getFixedChange(key)?.deleted !== true) { keys.push(key); } } @@ -321,7 +405,7 @@ export abstract class SandboxObject { /** `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; } /** @@ -338,8 +422,7 @@ export abstract class SandboxObject { * 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) => { @@ -357,6 +440,45 @@ export abstract class SandboxObject { 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`: * @@ -366,14 +488,15 @@ export abstract class SandboxObject { * * 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}). + * ({@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, fixed] of this.fixed) { - if (fixed.own !== false) { + if (fixed.own !== false && properties.getFixedChange(key)?.deleted !== true) { properties.changeFixed(key, change); } } @@ -389,17 +512,11 @@ export abstract class SandboxObject { /** * A fixed property's attributes on one value: what its kind starts it with, less what `seal` or - * `freeze` took away on this value. It is writable only if it has a {@link FixedProperty.set}, and - * never enumerable. + * `freeze` took away on this value (`change`). It is never enumerable. */ -function fixedAttributes( - key: string, - fixed: FixedProperty, - properties: OwnProperties -): Attributes { - const change = properties.getFixedChange(key); +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 d5dca2d..27aefd5 100644 --- a/packages/maple/src/values/wrappers/map-wrapper.ts +++ b/packages/maple/src/values/wrappers/map-wrapper.ts @@ -6,7 +6,7 @@ import { type FixedProperties, SandboxObject } from '../sandbox-object.ts'; * `m.size = 1` is a TypeError. */ const MAP_PROPERTIES: FixedProperties = new Map([ - ['size', { get: (wrapper) => wrapper.map.size, configurable: true, own: 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 eedaccd..9db56d3 100644 --- a/packages/maple/src/values/wrappers/regexp-wrapper.ts +++ b/packages/maple/src/values/wrappers/regexp-wrapper.ts @@ -18,22 +18,35 @@ 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: true, own: false }], - ['flags', { get: (r) => r.regexp.flags, configurable: true, own: false }], - ['global', { get: (r) => r.regexp.global, configurable: true, own: false }], - ['ignoreCase', { get: (r) => r.regexp.ignoreCase, configurable: true, own: false }], - ['multiline', { get: (r) => r.regexp.multiline, configurable: true, own: false }], - ['sticky', { get: (r) => r.regexp.sticky, configurable: true, own: false }], - ['unicode', { get: (r) => r.regexp.unicode, configurable: true, own: false }], - ['dotAll', { get: (r) => r.regexp.dotAll, configurable: true, own: false }], - ['hasIndices', { get: (r) => r.regexp.hasIndices, configurable: true, own: false }], - ['unicodeSets', { get: (r) => r.regexp.unicodeSets, configurable: true, own: 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 { @@ -55,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 a6e7827..28f8828 100644 --- a/packages/maple/src/values/wrappers/set-wrapper.ts +++ b/packages/maple/src/values/wrappers/set-wrapper.ts @@ -6,7 +6,7 @@ import { type FixedProperties, SandboxObject } from '../sandbox-object.ts'; * `s.size = 1` is a TypeError. */ const SET_PROPERTIES: FixedProperties = new Map([ - ['size', { get: (wrapper) => wrapper.set.size, configurable: true, own: false }] + ['size', { get: (wrapper) => wrapper.set.size, writable: false, configurable: true, own: false }] ]); export class SetWrapper extends SandboxObject { From 792b4650eb2152c52c2d193a26fd55052bac4b7d Mon Sep 17 00:00:00 2001 From: NetWin Date: Sun, 25 Oct 2026 00:16:43 +0200 Subject: [PATCH 4/4] fixup! fix(properties): let scripts delete and replace configurable built-in properties --- packages/maple/docs/boundary.md | 6 ++++-- packages/maple/src/boundary/to-host.ts | 11 ++++++++--- packages/maple/src/errors.spec.ts | 16 ++++++++++++++++ packages/maple/src/values/sandbox-error.ts | 17 ++++++++++++----- 4 files changed, 40 insertions(+), 10 deletions(-) diff --git a/packages/maple/docs/boundary.md b/packages/maple/docs/boundary.md index 72bb184..9304c4b 100644 --- a/packages/maple/docs/boundary.md +++ b/packages/maple/docs/boundary.md @@ -132,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/src/boundary/to-host.ts b/packages/maple/src/boundary/to-host.ts index 757b3cf..e3f48a1 100644 --- a/packages/maple/src/boundary/to-host.ts +++ b/packages/maple/src/boundary/to-host.ts @@ -29,6 +29,9 @@ type UnconvertibleKind = 'iterator' | 'WeakMap' | 'WeakSet' | '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; @@ -218,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 { @@ -231,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/errors.spec.ts b/packages/maple/src/errors.spec.ts index 10f5ebe..afef2e9 100644 --- a/packages/maple/src/errors.spec.ts +++ b/packages/maple/src/errors.spec.ts @@ -808,6 +808,22 @@ describe('deleting the properties of an error', () => { ).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( diff --git a/packages/maple/src/values/sandbox-error.ts b/packages/maple/src/values/sandbox-error.ts index 41eec47..2f3d6d2 100644 --- a/packages/maple/src/values/sandbox-error.ts +++ b/packages/maple/src/values/sandbox-error.ts @@ -110,18 +110,25 @@ export class SandboxError extends SandboxObject { return { name: hostText(this.read('name'), 'Error'), message: hostText(this.read('message'), ''), - stack: this.stack + stack: hostText(this.read('stack'), '') }; } /** - * `name` or `message` as a script reads it: what it was given or assigned, or after `delete`, - * what was assigned again or what the prototype has. + * `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'): unknown { + 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) { - return key === 'name' ? this.name : this.message; + 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); }