diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index a9e24b4..6650588 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -77,3 +77,27 @@ jobs: conventional-changelog-conventionalcommits@9 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Brings the release commit and tag back to increment, where pull requests branch off, so their + # changelog preview starts from the latest release + sync: + name: Sync increment + needs: [release] + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + permissions: + contents: write # push to increment + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: increment + + # Fast-forwards when increment has nothing new; otherwise the merge commit is not a Conventional + # Commit, so it stays out of the next changelog. A conflict fails the job and leaves increment + # as it was. + - run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git merge --no-edit origin/master + git push origin increment diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index af1eae2..0a9a2cd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,6 +35,11 @@ It runs `test` on Node 22, 24 and 26: the minimum from the library's `engines` f Commits must follow [Conventional Commits](https://www.conventionalcommits.org), which commitlint enforces in the `commit-msg` hook. Every commit needs a scope: the package it changes, such as `maple`, `playground` or `parity`, or `*` when it spans the repository. + +Pull requests target `increment`, the default branch, and are merged with a merge commit or rebased, never squashed, so their commits reach `master` as they are. +A release is a merge of `increment` into `master`, with a merge commit as well. +After it, the `sync` job in `.github/workflows/push.yml` merges `master` back into `increment`, so the release commit and its tag are there for the next pull requests. + On every push to `master`, [semantic-release](https://semantic-release.gitbook.io) derives the version and changelog of `@netwin/maple` from them, so never edit its `version` or `CHANGELOG.md` by hand. Tags are plain versions (`1.2.3`). While Maple is on 0.x, breaking changes bump the minor version: @@ -71,6 +76,7 @@ The declared exceptions are the guarded `performance.now()` behind `durationMs`, If the method takes a callback, invoke it through `context.call` so it runs under the budget. Never hand a script closure to a host implementation. 3. If the method needs no callback and cannot observe prototypes, it may delegate to the host implementation on a Maple-owned value (for example `Array.prototype.with`). + Call it through the prototype, as `Array.prototype.with.call(a, …)`, never as `a.with(…)`: a script can give its array an own property of that name. Wrap it in `guardHostErrors` when the spec requires a `RangeError`, `URIError` or `SyntaxError` for it, or when it can build a string or array longer than the host allows. 4. Return only sandbox values: primitives, Maple-created objects and arrays, or wrappers. Never return a native `Date`, `RegExp`, `Map`, `Set` or function. diff --git a/packages/maple-parity/README.md b/packages/maple-parity/README.md index 9e3b858..a69af50 100644 --- a/packages/maple-parity/README.md +++ b/packages/maple-parity/README.md @@ -62,6 +62,10 @@ js-interpreter differs from JavaScript here; Maple does what Node does. | `new Error('m', { cause: 1 }).cause`: Maple gives `1`, js-interpreter `undefined` | The `cause` option is ES2022; js-interpreter follows ES5 and ignores the second argument. | | `'abc'.split(undefined, 0)`: Maple gives `[]`, js-interpreter `['abc']` | A limit of 0 gives no parts, as in JavaScript (and ES5); js-interpreter checks the separator first. | | An error with `name` set to `''` or `undefined`: Maple prints `oops` and `Error: x` | As in JavaScript (and ES5); js-interpreter joins `name` and `message` with `: ` regardless. | +| An error whose `name` is itself: Maple throws a `RangeError` | Nothing cuts that cycle in JavaScript, so it runs out of stack; js-interpreter stops quietly. | +| An error named by an array that holds it: Maple gives `m: m` | `join` cuts the cycle at the array, as in JavaScript; js-interpreter gives `...: m`. | +| `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. | ### Maple departs from JavaScript @@ -70,6 +74,8 @@ On purpose: these are what the sandbox leaves out. | Divergence | Why | | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | | `this`, `new` on a script function, `F.prototype`, `.constructor`, `instanceof F` | Maple has no script-visible prototypes or receivers. An object's behaviour comes from its type. | +| `new` on a bound function: `new (Error.bind(null, 'x'))()` | Only the listed built-in constructors can be constructed, and a bound function is not one of them. | +| A built-in method called on a receiver of another type: `''.trim.call({})` | A method's behaviour comes from its receiver's type, so it refuses another type with a `TypeError`. | | `Object.create`, `Object.getPrototypeOf`, `isPrototypeOf` | The same object model: there is no prototype chain to create or inspect. | | Getters and setters | They would run script code on a plain property read or write, host conversion included. | | `new String`, `new Number`, `new Boolean`, `Object(primitive)`, `instanceof String` / `Number` / `Boolean` | There are no primitive wrapper objects. They are a well-known trap and would add a value kind. | @@ -82,5 +88,4 @@ Planned; each entry goes once Maple supports it. | Divergence | Why | | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -| `call`, `apply`, `bind` | Every built-in method checks its receiver now, so they can come. Spread arguments and closures cover the uses until then. | | `defineProperty`, `defineProperties`, `getOwnPropertyDescriptor`, `propertyIsEnumerable` | Data descriptors need per-property attributes on Maple's own values first. Accessors stay out, as getters and setters do. | diff --git a/packages/maple-parity/src/cases/functions.spec.ts b/packages/maple-parity/src/cases/functions.spec.ts index 350d14f..67b02d1 100644 --- a/packages/maple-parity/src/cases/functions.spec.ts +++ b/packages/maple-parity/src/cases/functions.spec.ts @@ -363,6 +363,78 @@ describe('Function.prototype.valueOf', () => { }); }); +describe('Function.prototype.call', () => { + it('calls a script function with the arguments after the receiver', () => { + expect(parity('function f(a, b) { return a + b; } return f.call(null, 1, 2);')).toBe(3); + }); + + it('passes the receiver to a built-in method', () => { + expect(parity("return ''.trim.call(' b ');")).toBe('b'); + expect(parity('var a = [1]; [].push.call(a, 2, 3); return a;')).toEqual([1, 2, 3]); + }); + + it('calls a global function', () => { + expect(parity('return [String.call(null, 5), Math.max.call(null, 1, 3, 2)];')).toEqual([ + '5', + 3 + ]); + }); + + it('calls call itself', () => { + expect(parity('function f(a) { return a; } return f.call.call(f, null, 1);')).toBe(1); + }); + + it('needs a function as its receiver', () => { + expect(allThrow('var c = Math.max.call; return c();')).toBe('TypeError'); + }); +}); + +// js-interpreter throws for `f.apply(null, null)`, so the case for no arguments is in Maple's own spec +describe('Function.prototype.apply', () => { + it('spreads an array into the arguments', () => { + expect(parity('function f(a, b) { return a + b; } return f.apply(null, [1, 2]);')).toBe(3); + expect(parity('return Math.max.apply(null, [1, 3, 2]);')).toBe(3); + }); + + it('spreads an array-like and arguments', () => { + expect( + parity( + "function f(a, b) { return a + b; } return f.apply(null, { length: 2, 0: 'a', 1: 'b' });" + ) + ).toBe('ab'); + expect( + parity( + 'function f(a, b) { return a + b; } function g() { return f.apply(null, arguments); } return g(1, 2);' + ) + ).toBe(3); + }); + + it('passes the receiver to a built-in method', () => { + expect(parity("return 'a-b'.split.apply('x-y', ['-']);")).toEqual(['x', 'y']); + }); +}); + +// js-interpreter throws for `f.bind()` without a receiver, so every case passes one +describe('Function.prototype.bind', () => { + it('fixes leading arguments', () => { + expect(parity('function f(a, b) { return a + b; } return f.bind(null, 1)(2);')).toBe(3); + expect( + parity('function f(a, b, c) { return [a, b, c]; } return f.bind(null, 1).bind(null, 2)(3);') + ).toEqual([1, 2, 3]); + }); + + it('binds the receiver of a built-in method', () => { + expect(parity("var trim = ''.trim.bind(' b '); return trim();")).toBe('b'); + expect(parity('var a = []; var push = [].push.bind(a); push(1); push(2); return a;')).toEqual([ + 1, 2 + ]); + }); + + it('gives a function', () => { + expect(parity('function f() {} return typeof f.bind(null);')).toBe('function'); + }); +}); + describe('method calls', () => { it('read the same function for every value of a type', () => { expect( diff --git a/packages/maple-parity/src/divergences.spec.ts b/packages/maple-parity/src/divergences.spec.ts index db502ae..8b06e56 100644 --- a/packages/maple-parity/src/divergences.spec.ts +++ b/packages/maple-parity/src/divergences.spec.ts @@ -172,6 +172,22 @@ describe('Error.prototype.toString', () => { ) ).toEqual({ maple: ['oops', 'Error: x'], jsInterpreter: [': oops', 'undefined: x'] }); }); + + it('throws a RangeError for an error that names itself', () => { + expect( + followsJavaScript( + "var e = new Error('m'); e.name = e; try { String(e); } catch (t) { return t.name; }" + ) + ).toEqual({ maple: 'RangeError', jsInterpreter: undefined }); + }); + + // join cuts a cycle through an array, nested conversions included; js-interpreter cuts it as '...' + it('converts an error named by an array that holds it', () => { + expect(followsJavaScript("var e = new Error('m'); e.name = [e]; return String(e);")).toEqual({ + maple: 'm: m', + jsInterpreter: '...: m' + }); + }); }); describe('Error:cause', () => { @@ -183,6 +199,36 @@ describe('Error:cause', () => { }); }); +describe('Function.prototype.bind', () => { + // A bound function's name and length are ES2015; js-interpreter follows ES5 and gives neither + it('names a bound function after its target', () => { + expect(followsJavaScript('function f(a, b, c) {} return f.bind(null, 1).name;')).toEqual({ + maple: 'bound f', + jsInterpreter: 'fBound' + }); + }); + + it('leaves a bound function the rest of its length', () => { + expect(followsJavaScript('function f(a, b, c) {} return f.bind(null, 1).length;')).toEqual({ + maple: 2, + jsInterpreter: 0 + }); + }); +}); + +describe('Function.prototype.apply', () => { + // A primitive is no array-like of arguments; js-interpreter takes a string's characters + it('refuses a primitive as the arguments', () => { + expect(followsJavaScript('function f() {} return f.apply(null, 1);')).toEqual({ + maple: MapleErrorCode.RUNTIME_ERROR, + jsInterpreter: undefined + }); + expect( + followsJavaScript('function f() { return arguments.length; } return f.apply(null, "ab");') + ).toEqual({ maple: MapleErrorCode.RUNTIME_ERROR, jsInterpreter: 2 }); + }); +}); + describe('String.prototype.split', () => { // A limit of 0 gives no parts, separator or not; js-interpreter checks the separator first it('gives no parts for a limit of 0 without a separator', () => { @@ -221,6 +267,19 @@ describe('syntax:NewExpression', () => { MapleErrorCode.DISALLOWED_SYNTAX ); }); + + it('rejects new on a bound function', () => { + expect(rejected("var B = Error.bind(null, 'x'); return new B().message;")).toBe( + MapleErrorCode.DISALLOWED_SYNTAX + ); + }); +}); + +describe('Function.prototype.call', () => { + // A method's behaviour comes from its type, so it refuses a receiver of another type + it('refuses a receiver of another type for a built-in method', () => { + expect(rejected("return ''.trim.call({ a: 1 });")).toBe(MapleErrorCode.RUNTIME_ERROR); + }); }); describe('instance:function.prototype', () => { @@ -375,33 +434,6 @@ describe('constructor', () => { // Not supported yet: each entry goes once Maple supports it. -// Every built-in method checks its receiver now, so these can come; spread arguments and closures -// cover the uses until then. - -describe('Function.prototype.call', () => { - it('has no call', () => { - expect(rejected('function f(a) { return a; } return f.call(null, 1);')).toBe( - MapleErrorCode.RUNTIME_ERROR - ); - }); -}); - -describe('Function.prototype.apply', () => { - it('has no apply', () => { - expect(rejected('function f(a, b) { return a + b; } return f.apply(null, [1, 2]);')).toBe( - MapleErrorCode.RUNTIME_ERROR - ); - }); -}); - -describe('Function.prototype.bind', () => { - it('has no bind', () => { - expect(rejected('function f(a, b) { return a + b; } return f.bind(null, 1)(2);')).toBe( - MapleErrorCode.RUNTIME_ERROR - ); - }); -}); - // Data descriptors need per-property attributes on Maple's own values first. Accessors stay out, as // getters and setters do. diff --git a/packages/maple/docs/builtins.md b/packages/maple/docs/builtins.md index f0d6b1d..95f8688 100644 --- a/packages/maple/docs/builtins.md +++ b/packages/maple/docs/builtins.md @@ -56,12 +56,14 @@ Boolean instance: `toString`, `valueOf`. Static: `Object.keys`, `Object.values`, `Object.entries`, `Object.fromEntries`, `Object.assign`, `Object.hasOwn`, `Object.groupBy`, `Object.is`, `Object.getOwnPropertyNames`, `Object.freeze`, -`Object.isFrozen`, `Object.preventExtensions`, `Object.isExtensible`. - -- The descriptor and prototype functions (`defineProperty`, `getPrototypeOf`, `create`, …) and - `seal` are not available. -- `freeze`, `preventExtensions` and their checks work on Maple's own values (functions, errors, - wrappers) as well. For a built-in function or a namespace, they last for the current run only. +`Object.isFrozen`, `Object.seal`, `Object.isSealed`, `Object.preventExtensions`, +`Object.isExtensible`. + +- The descriptor and prototype functions (`defineProperty`, `getPrototypeOf`, `create`, …) are not + available. +- `freeze`, `seal`, `preventExtensions` and their checks work on Maple's own values (functions, + errors, wrappers) as well. For a built-in function or a namespace, they last for the current run + only. - `Object.groupBy` converts each key hook-free, so a script object always groups as `[object Object]`. @@ -137,6 +139,19 @@ Arrays, strings, Maps, Sets and iterators are the iterables, in `for...of`, spre and wherever a built-in takes an iterable. Plain objects are not iterable. An iterator never leaves the sandbox (see [Boundary](boundary.md)). +## Functions + +Every function, script, built-in or host, answers to `call`, `apply`, `bind`, `toString` and +`valueOf`. + +`apply` takes `null` or `undefined` for no arguments, or an array-like: an array, `arguments` or +any object with a `length`. A primitive is a `TypeError`. + +A bound function is named `bound ` followed by the name of the function it calls, and its `length` +is what is left of that function's after the bound arguments. One bound from a host function has no +properties, as host functions have none. A bound function cannot be constructed with `new` or used +on the right-hand side of `instanceof`, and it adds no frame to `stack`. + ## Errors `Error`, `TypeError`, `RangeError`, `ReferenceError`, `SyntaxError`, `URIError` and `EvalError`, diff --git a/packages/maple/docs/divergences.md b/packages/maple/docs/divergences.md index edce11d..18eb2d1 100644 --- a/packages/maple/docs/divergences.md +++ b/packages/maple/docs/divergences.md @@ -23,21 +23,21 @@ one that does; the linked section has the detail. - `in` sees own properties and the methods of the value's type, so `'valueOf' in []` is `false` ([Methods](language.md#methods)). - Plain objects have only `hasOwnProperty`, `toString`, `toLocaleString` and `valueOf`. -- `defineProperty`, `getPrototypeOf`, `create`, `seal` and the other descriptor and prototype - functions are not available ([Object](builtins.md#object)). +- `defineProperty`, `getPrototypeOf`, `create` and the other descriptor and prototype functions + are not available ([Object](builtins.md#object)). - Input data is frozen unless the host passes `freeze: false` ([Boundary](boundary.md#into-the-run)). ## Functions and constructors - `new` works only for the [listed constructors](language.md#constructors); script functions - cannot be constructed, and `instanceof` accepts only those constructors and `Iterator`. + and bound functions cannot be constructed, so `new (Error.bind(null, 'x'))()` is rejected, and + `instanceof` accepts only those constructors and `Iterator`. - There are no primitive wrapper objects: `new String('a')` is rejected and `Object('a')` is a `TypeError`. -- There is no `call`, `apply` or `bind`. - A function converts to `[function]`, not to its source. -- Host functions have no `length` or `name`. -- A method refuses a receiver of another type, so `({ t: 'x'.trim }).t()` is a `TypeError` - ([Methods](language.md#methods)). +- Host functions, and functions bound from them, have no `length` or `name`. +- A method refuses a receiver of another type, so `({ t: 'x'.trim }).t()`, `''.trim.call({})` and + `[].slice.call(arguments)` are `TypeError`s ([Methods](language.md#methods)). ## Conversion diff --git a/packages/maple/docs/language.md b/packages/maple/docs/language.md index 9677918..3b25e18 100644 --- a/packages/maple/docs/language.md +++ b/packages/maple/docs/language.md @@ -59,19 +59,22 @@ name. `typeof` never throws for a name that is not bound, so `typeof window !== `new` is allowed for `Error`, `TypeError`, `RangeError`, `ReferenceError`, `SyntaxError`, `URIError`, `EvalError`, `Date`, `RegExp`, `Map`, `Set`, `Array` and `Object`. Any other `new` -target, including a script function, is `DISALLOWED_SYNTAX`. There are no primitive wrapper -objects: `String`, `Number` and `Boolean` are conversion functions only, so `new String('a')` is -rejected, and `Object('a')` is a `TypeError`. +target, including a script function or a bound function, is `DISALLOWED_SYNTAX`. There are no +primitive wrapper objects: `String`, `Number` and `Boolean` are conversion functions only, so +`new String('a')` is rejected, and `Object('a')` is a `TypeError`. `instanceof` accepts those thirteen plus `Iterator` on the right-hand side. Any other right-hand -side, a script function or `String` included, throws a `TypeError`. +side, a script function, a bound function or `String` included, throws a `TypeError`. ## Functions Script functions and built-in functions have JavaScript's read-only `length` and `name`, names -inferred from bindings included. Host functions have no properties at all. Every function answers -to `toString`, which gives `[function]`, and `valueOf`. There is no `call`, `apply` or `bind`, since -there is no `this` for them to set. +inferred from bindings included. 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'`. A script +function never sees it, since there is no `this`, which is how JavaScript behaves for a strict +function that does not use `this`. Inside a function that is not an arrow, `arguments` is the strict-mode arguments object. @@ -106,7 +109,9 @@ A method such as `s.trim` is resolved by the receiver's type, never through a ho method is one function shared by every value of the type, so `'x'.trim === 'y'.trim`. The receiver comes from the call as in JavaScript, so `const p = a.push; p(1)` has none and is a `TypeError`. Unlike JavaScript, a receiver of another type is refused too: `({ t: 'x'.trim }).t()` is a -`TypeError`. Own data properties win, so `obj.toString()` calls an own `toString` key. +`TypeError`, and so is `''.trim.call({})`. Array methods take arrays only, so +`[].slice.call(arguments)` is a `TypeError` as well. Own data properties win, so `obj.toString()` +calls an own `toString` key. Plain objects have four methods: `hasOwnProperty`, `toString`, `toLocaleString` and `valueOf`. An object without a prototype, such as `Object.groupBy` returns or a match gives as `groups`, has none diff --git a/packages/maple/docs/limits.md b/packages/maple/docs/limits.md index 5e2d4da..1cff022 100644 --- a/packages/maple/docs/limits.md +++ b/packages/maple/docs/limits.md @@ -6,9 +6,11 @@ included. Exceeding either throws `LIMIT_EXCEEDED`. - **Operations** (`maxOps`). A run is charged for each statement, each evaluated expression, each loop iteration and each callback a built-in or an intrinsic invokes. Copying or growing an array or object element by element, as spread, rest, `for...in`, `Array.from`, `flat` or a larger - `length` do, costs one operation per element. -- **Depth** (`maxDepth`). Each closure call, each callback invoked by a built-in and each nested - `sandbox.run` adds one level. + `length` do, costs one operation per element. So does each argument `apply` takes from its + array-like, before any is read, and each bound argument a bound function passes on a call. +- **Depth** (`maxDepth`). Each closure call and each nested `sandbox.run` adds one level. So does + each call a built-in or an intrinsic makes to a function that is not a closure, as `apply` or a + bound function can, so built-ins calling one another stay under the limit too. A limit error aborts the run outright: it skips the script's `catch` and `finally` blocks, and the script executes nothing after it. @@ -22,8 +24,14 @@ responsibility (see [Security](security.md#out-of-scope)). Wherever Maple walks a value recursively, it refuses one nested deeper than 1000 levels instead of overflowing the host stack: when copying values across the [boundary](boundary.md), in -`JSON.stringify` and in `JSON.parse` with a reviver, and when converting a nested array to a string -or flattening it. Inside the script this is a catchable `RangeError`. +`JSON.stringify` and in `JSON.parse` with a reviver, when converting a nested array to a string or +flattening it, and when converting an error whose `name` or `message` holds another error. Inside +the script this is a catchable `RangeError`. + +Converting an array cuts a cycle back to it with an empty string, as JavaScript does, even where +the cycle runs through an error's `name` or `message`. Nothing cuts a cycle from one error to +another, such as an error whose `name` is the error itself, so converting it fails with +JavaScript's `RangeError: Maximum call stack size exceeded`. The host itself running out of stack is not a script error: the run ends with `INTERNAL_ERROR`, and no `catch` sees it. diff --git a/packages/maple/src/builtins/array/array-prototype.ts b/packages/maple/src/builtins/array/array-prototype.ts index 1d44c14..88ca227 100644 --- a/packages/maple/src/builtins/array/array-prototype.ts +++ b/packages/maple/src/builtins/array/array-prototype.ts @@ -40,7 +40,9 @@ import { ARRAY_METHOD_WITH } from './methods/with.ts'; /** * Callbacks always run through the interpreter, so they stay under the budget and the host never - * holds a script function. Methods that mutate refuse read-only input arrays. + * holds a script function. Methods that mutate refuse read-only input arrays. A method that runs + * the host's implementation calls it through `Array.prototype`, never as `a.slice()`, since a + * script can give its array an own property of that name. */ export const ARRAY_PROTOTYPE: Prototype> = new Prototype( 'Array', diff --git a/packages/maple/src/builtins/array/array.spec.ts b/packages/maple/src/builtins/array/array.spec.ts index 30661b0..a6bc66b 100644 --- a/packages/maple/src/builtins/array/array.spec.ts +++ b/packages/maple/src/builtins/array/array.spec.ts @@ -269,6 +269,70 @@ describe('array methods', () => { expect(run('return [1, 2].join();')).toBe('1,2'); expect(run('return [{ toString: "x" }, () => 1].join();')).toBe('[object Object],[function]'); expect(run('return [1, [2]].toString();')).toBe('1,2'); + expect(run('return [1, , 3].join();')).toBe('1,,3'); + }); + + it('join an array that has its own map, which the conversion does not use', () => { + expect( + run('const a = [1, 2]; a.map = 1; return [a.join("-"), String(a), [a].toString(), `${a}`];') + ).toEqual(['1-2', '1,2', '1,2', '1,2']); + }); + + it('call the host implementation even where the array has an own property of that name', () => { + expect(run('const a = [1, 2, 3]; a.at = 1; return [].at.call(a, -1);')).toBe(3); + expect(run('const a = [1]; a.concat = 1; return [].concat.call(a, [2], 3);')).toEqual([ + 1, 2, 3 + ]); + expect( + run('const a = [1, 2, 3]; a.copyWithin = 1; [].copyWithin.call(a, 0, 1); return [...a];') + ).toEqual([2, 3, 3]); + expect(run('const a = ["x"]; a.entries = 1; return [...[].entries.call(a)];')).toEqual([ + [0, 'x'] + ]); + expect(run('const a = [1, 2]; a.fill = 1; [].fill.call(a, 0); return [...a];')).toEqual([ + 0, 0 + ]); + expect(run('const a = [1, 2]; a.includes = 1; return [].includes.call(a, 2);')).toBe(true); + expect(run('const a = [1, 2]; a.indexOf = 1; return [].indexOf.call(a, 2);')).toBe(1); + expect(run('const a = ["x", "y"]; a.keys = 1; return [...[].keys.call(a)];')).toEqual([0, 1]); + expect( + run( + 'const a = [1, 2, 1]; a.lastIndexOf = 1; return [[].lastIndexOf.call(a, 1), [].lastIndexOf.call(a, 1, 1)];' + ) + ).toEqual([2, 0]); + expect(run('const a = [1, 2]; a.pop = 1; return [[].pop.call(a), [...a]];')).toEqual([ + 2, + [1] + ]); + expect(run('const a = [1]; a.push = 1; [].push.call(a, 2); return [...a];')).toEqual([1, 2]); + expect(run('const a = [1, 2]; a.reverse = 1; [].reverse.call(a); return [...a];')).toEqual([ + 2, 1 + ]); + expect(run('const a = [1, 2]; a.shift = 1; return [[].shift.call(a), [...a]];')).toEqual([ + 1, + [2] + ]); + expect(run('const a = [3, 1, 2]; a.slice = 1; return [].slice.call(a, 1);')).toEqual([1, 2]); + expect( + run( + 'const a = [1, 2, 3]; a.splice = 1; return [[].splice.call(a, 1), [].splice.call(a, 0, 1, 9), [...a]];' + ) + ).toEqual([[2, 3], [1], [9]]); + expect(run('const a = [1, 2]; a.map = 1; return [].toLocaleString.call(a);')).toBe('1,2'); + expect(run('const a = [1, 2]; a.toReversed = 1; return [].toReversed.call(a);')).toEqual([ + 2, 1 + ]); + expect(run('const a = [2, 1]; a.toSorted = 1; return [].toSorted.call(a);')).toEqual([1, 2]); + expect( + run( + 'const a = [1, 2, 3]; a.toSpliced = 1; return [[].toSpliced.call(a, 1), [].toSpliced.call(a, 0, 1, 9)];' + ) + ).toEqual([[1], [9, 2, 3]]); + expect(run('const a = [1]; a.unshift = 1; [].unshift.call(a, 0); return [...a];')).toEqual([ + 0, 1 + ]); + expect(run('const a = ["x"]; a.values = 1; return [...[].values.call(a)];')).toEqual(['x']); + expect(run('const a = [1, 2]; a.with = 1; return [].with.call(a, 0, 9);')).toEqual([9, 2]); }); it('mutate script arrays', () => { diff --git a/packages/maple/src/builtins/array/methods/at.ts b/packages/maple/src/builtins/array/methods/at.ts index c7148ef..de0e567 100644 --- a/packages/maple/src/builtins/array/methods/at.ts +++ b/packages/maple/src/builtins/array/methods/at.ts @@ -3,5 +3,5 @@ import { method, type Method } from '../../../values/native.ts'; export const ARRAY_METHOD_AT: Method> = method( Array.prototype.at.length, - (a, [index]) => a.at(toNumber(index)) + (a, [index]) => Array.prototype.at.call(a, toNumber(index)) ); diff --git a/packages/maple/src/builtins/array/methods/concat.ts b/packages/maple/src/builtins/array/methods/concat.ts index 6fbd505..3f0852a 100644 --- a/packages/maple/src/builtins/array/methods/concat.ts +++ b/packages/maple/src/builtins/array/methods/concat.ts @@ -2,5 +2,5 @@ import { guardHostErrors, method, type Method } from '../../../values/native.ts' export const ARRAY_METHOD_CONCAT: Method> = method( Array.prototype.concat.length, - (a, items, { fail }) => guardHostErrors(fail, () => a.concat(...items)) + (a, items, { fail }) => guardHostErrors(fail, () => Array.prototype.concat.call(a, ...items)) ); diff --git a/packages/maple/src/builtins/array/methods/copy-within.ts b/packages/maple/src/builtins/array/methods/copy-within.ts index efe2a38..ead9184 100644 --- a/packages/maple/src/builtins/array/methods/copy-within.ts +++ b/packages/maple/src/builtins/array/methods/copy-within.ts @@ -1,7 +1,7 @@ import { optionalNumberArgument } from '../../../values/arguments.ts'; import { toNumber } from '../../../values/coercion.ts'; +import { guardIntegrityErrors } from '../../../values/guard-integrity-errors.ts'; import { method, type Method } from '../../../values/native.ts'; -import { guardIntegrityErrors } from '../utils/guard-integrity-errors.ts'; export const ARRAY_METHOD_COPY_WITHIN: Method> = method( Array.prototype.copyWithin.length, @@ -9,6 +9,8 @@ export const ARRAY_METHOD_COPY_WITHIN: Method> = method( const to = toNumber(target); const from = toNumber(start); const until = optionalNumberArgument(end); - return guardIntegrityErrors(context.fail, a, () => a.copyWithin(to, from, until)); + return guardIntegrityErrors(context.fail, a, () => + Array.prototype.copyWithin.call(a, to, from, until) + ); } ); diff --git a/packages/maple/src/builtins/array/methods/entries.ts b/packages/maple/src/builtins/array/methods/entries.ts index 532410d..7b0f24b 100644 --- a/packages/maple/src/builtins/array/methods/entries.ts +++ b/packages/maple/src/builtins/array/methods/entries.ts @@ -3,5 +3,5 @@ import { IteratorWrapper } from '../../../values/wrappers/iterator-wrapper.ts'; export const ARRAY_METHOD_ENTRIES: Method> = method( Array.prototype.entries.length, - (a) => new IteratorWrapper(a.entries()) + (a) => new IteratorWrapper(Array.prototype.entries.call(a)) ); diff --git a/packages/maple/src/builtins/array/methods/fill.ts b/packages/maple/src/builtins/array/methods/fill.ts index 7b29bf5..7e8e098 100644 --- a/packages/maple/src/builtins/array/methods/fill.ts +++ b/packages/maple/src/builtins/array/methods/fill.ts @@ -1,12 +1,14 @@ import { optionalNumberArgument } from '../../../values/arguments.ts'; +import { guardIntegrityErrors } from '../../../values/guard-integrity-errors.ts'; import { method, type Method } from '../../../values/native.ts'; -import { guardIntegrityErrors } from '../utils/guard-integrity-errors.ts'; export const ARRAY_METHOD_FILL: Method> = method( Array.prototype.fill.length, (a, [value, start, end], context) => { const from = optionalNumberArgument(start); const to = optionalNumberArgument(end); - return guardIntegrityErrors(context.fail, a, () => a.fill(value, from, to)); + return guardIntegrityErrors(context.fail, a, () => + Array.prototype.fill.call(a, value, from, to) + ); } ); diff --git a/packages/maple/src/builtins/array/methods/includes.ts b/packages/maple/src/builtins/array/methods/includes.ts index 56ed975..62c78f9 100644 --- a/packages/maple/src/builtins/array/methods/includes.ts +++ b/packages/maple/src/builtins/array/methods/includes.ts @@ -3,5 +3,5 @@ import { method, type Method } from '../../../values/native.ts'; export const ARRAY_METHOD_INCLUDES: Method> = method( Array.prototype.includes.length, - (a, [value, from]) => a.includes(value, optionalNumberArgument(from)) + (a, [value, from]) => Array.prototype.includes.call(a, value, optionalNumberArgument(from)) ); diff --git a/packages/maple/src/builtins/array/methods/index-of.ts b/packages/maple/src/builtins/array/methods/index-of.ts index aae6943..cdd5e23 100644 --- a/packages/maple/src/builtins/array/methods/index-of.ts +++ b/packages/maple/src/builtins/array/methods/index-of.ts @@ -3,5 +3,5 @@ import { method, type Method } from '../../../values/native.ts'; export const ARRAY_METHOD_INDEX_OF: Method> = method( Array.prototype.indexOf.length, - (a, [value, from]) => a.indexOf(value, optionalNumberArgument(from)) + (a, [value, from]) => Array.prototype.indexOf.call(a, value, optionalNumberArgument(from)) ); diff --git a/packages/maple/src/builtins/array/methods/keys.ts b/packages/maple/src/builtins/array/methods/keys.ts index 48b36b0..ec6105e 100644 --- a/packages/maple/src/builtins/array/methods/keys.ts +++ b/packages/maple/src/builtins/array/methods/keys.ts @@ -3,5 +3,5 @@ import { IteratorWrapper } from '../../../values/wrappers/iterator-wrapper.ts'; export const ARRAY_METHOD_KEYS: Method> = method( Array.prototype.keys.length, - (a) => new IteratorWrapper(a.keys()) + (a) => new IteratorWrapper(Array.prototype.keys.call(a)) ); diff --git a/packages/maple/src/builtins/array/methods/last-index-of.ts b/packages/maple/src/builtins/array/methods/last-index-of.ts index 2140a6a..b804dd2 100644 --- a/packages/maple/src/builtins/array/methods/last-index-of.ts +++ b/packages/maple/src/builtins/array/methods/last-index-of.ts @@ -4,5 +4,7 @@ import { method, type Method } from '../../../values/native.ts'; export const ARRAY_METHOD_LAST_INDEX_OF: Method> = method( Array.prototype.lastIndexOf.length, (a, args) => - args.length < 2 ? a.lastIndexOf(args[0]) : a.lastIndexOf(args[0], toNumber(args[1])) + args.length < 2 + ? Array.prototype.lastIndexOf.call(a, args[0]) + : Array.prototype.lastIndexOf.call(a, args[0], toNumber(args[1])) ); diff --git a/packages/maple/src/builtins/array/methods/pop.ts b/packages/maple/src/builtins/array/methods/pop.ts index 77202b5..bd270a1 100644 --- a/packages/maple/src/builtins/array/methods/pop.ts +++ b/packages/maple/src/builtins/array/methods/pop.ts @@ -1,7 +1,7 @@ +import { guardIntegrityErrors } from '../../../values/guard-integrity-errors.ts'; import { method, type Method } from '../../../values/native.ts'; -import { guardIntegrityErrors } from '../utils/guard-integrity-errors.ts'; export const ARRAY_METHOD_POP: Method> = method( Array.prototype.pop.length, - (a, _args, context) => guardIntegrityErrors(context.fail, a, () => a.pop()) + (a, _args, context) => guardIntegrityErrors(context.fail, a, () => Array.prototype.pop.call(a)) ); diff --git a/packages/maple/src/builtins/array/methods/push.ts b/packages/maple/src/builtins/array/methods/push.ts index 40bf3ec..0c3029b 100644 --- a/packages/maple/src/builtins/array/methods/push.ts +++ b/packages/maple/src/builtins/array/methods/push.ts @@ -1,10 +1,10 @@ +import { guardIntegrityErrors } from '../../../values/guard-integrity-errors.ts'; import { guardHostErrors, method, type Method } from '../../../values/native.ts'; -import { guardIntegrityErrors } from '../utils/guard-integrity-errors.ts'; export const ARRAY_METHOD_PUSH: Method> = method( Array.prototype.push.length, (a, items, context) => guardHostErrors(context.fail, () => - guardIntegrityErrors(context.fail, a, () => a.push(...items)) + guardIntegrityErrors(context.fail, a, () => Array.prototype.push.call(a, ...items)) ) ); diff --git a/packages/maple/src/builtins/array/methods/reverse.ts b/packages/maple/src/builtins/array/methods/reverse.ts index 5be7029..a922235 100644 --- a/packages/maple/src/builtins/array/methods/reverse.ts +++ b/packages/maple/src/builtins/array/methods/reverse.ts @@ -1,10 +1,9 @@ +import { guardIntegrityErrors } from '../../../values/guard-integrity-errors.ts'; import { method, type Method } from '../../../values/native.ts'; -import { guardIntegrityErrors } from '../utils/guard-integrity-errors.ts'; export const ARRAY_METHOD_REVERSE: Method> = method( Array.prototype.reverse.length, (a, _args, context) => // The host's own reverse runs no script code and keeps holes where JavaScript puts them - // oxlint-disable-next-line unicorn/no-array-reverse - reversing in place is what this method does - guardIntegrityErrors(context.fail, a, () => a.reverse()) + guardIntegrityErrors(context.fail, a, () => Array.prototype.reverse.call(a)) ); diff --git a/packages/maple/src/builtins/array/methods/shift.ts b/packages/maple/src/builtins/array/methods/shift.ts index 6026141..d8f5063 100644 --- a/packages/maple/src/builtins/array/methods/shift.ts +++ b/packages/maple/src/builtins/array/methods/shift.ts @@ -1,7 +1,7 @@ +import { guardIntegrityErrors } from '../../../values/guard-integrity-errors.ts'; import { method, type Method } from '../../../values/native.ts'; -import { guardIntegrityErrors } from '../utils/guard-integrity-errors.ts'; export const ARRAY_METHOD_SHIFT: Method> = method( Array.prototype.shift.length, - (a, _args, context) => guardIntegrityErrors(context.fail, a, () => a.shift()) + (a, _args, context) => guardIntegrityErrors(context.fail, a, () => Array.prototype.shift.call(a)) ); diff --git a/packages/maple/src/builtins/array/methods/slice.ts b/packages/maple/src/builtins/array/methods/slice.ts index feb0ff3..affc1b6 100644 --- a/packages/maple/src/builtins/array/methods/slice.ts +++ b/packages/maple/src/builtins/array/methods/slice.ts @@ -3,5 +3,6 @@ import { method, type Method } from '../../../values/native.ts'; export const ARRAY_METHOD_SLICE: Method> = method( Array.prototype.slice.length, - (a, [start, end]) => a.slice(optionalNumberArgument(start), optionalNumberArgument(end)) + (a, [start, end]) => + Array.prototype.slice.call(a, optionalNumberArgument(start), optionalNumberArgument(end)) ); diff --git a/packages/maple/src/builtins/array/methods/sort.ts b/packages/maple/src/builtins/array/methods/sort.ts index 63f950a..a0605c1 100644 --- a/packages/maple/src/builtins/array/methods/sort.ts +++ b/packages/maple/src/builtins/array/methods/sort.ts @@ -1,7 +1,7 @@ +import { guardIntegrityErrors } from '../../../values/guard-integrity-errors.ts'; import { method, type Method } from '../../../values/native.ts'; import { comparator } from '../utils/comparator.ts'; import { presentEntries } from '../utils/entries.ts'; -import { guardIntegrityErrors } from '../utils/guard-integrity-errors.ts'; export const ARRAY_METHOD_SORT: Method> = method( Array.prototype.sort.length, diff --git a/packages/maple/src/builtins/array/methods/splice.ts b/packages/maple/src/builtins/array/methods/splice.ts index df229e0..5a76e1c 100644 --- a/packages/maple/src/builtins/array/methods/splice.ts +++ b/packages/maple/src/builtins/array/methods/splice.ts @@ -1,13 +1,18 @@ +import { guardIntegrityErrors } from '../../../values/guard-integrity-errors.ts'; import { method, type Method } from '../../../values/native.ts'; -import { guardIntegrityErrors } from '../utils/guard-integrity-errors.ts'; import { splice } from '../utils/splice.ts'; +// `call` is typed by the last overload, which needs a delete count, so `splice(start)` gets its own +const spliceToEnd: (this: Array, start: number) => Array = Array.prototype.splice; + export const ARRAY_METHOD_SPLICE: Method> = method( Array.prototype.splice.length, (a, args, context) => { return splice(a, args, (start, count, items) => { return guardIntegrityErrors(context.fail, a, () => - count === undefined ? a.splice(start) : a.splice(start, count, ...items) + count === undefined + ? spliceToEnd.call(a, start) + : Array.prototype.splice.call(a, start, count, ...items) ); }); } diff --git a/packages/maple/src/builtins/array/methods/to-locale-string.ts b/packages/maple/src/builtins/array/methods/to-locale-string.ts index 8092a6d..f41da9e 100644 --- a/packages/maple/src/builtins/array/methods/to-locale-string.ts +++ b/packages/maple/src/builtins/array/methods/to-locale-string.ts @@ -8,7 +8,7 @@ export const ARRAY_METHOD_TO_LOCALE_STRING: Method> = method( (a, [locales, options], { fail }) => { const tags = localesArgument(locales); const intlOptions = intlOptionsArgument(options); - const mapped = a.map((element) => { + const mapped = Array.prototype.map.call(a, (element) => { if (element === null || element === undefined) { return ''; } diff --git a/packages/maple/src/builtins/array/methods/to-reversed.ts b/packages/maple/src/builtins/array/methods/to-reversed.ts index fa83e7b..c0681f1 100644 --- a/packages/maple/src/builtins/array/methods/to-reversed.ts +++ b/packages/maple/src/builtins/array/methods/to-reversed.ts @@ -2,5 +2,5 @@ import { method, type Method } from '../../../values/native.ts'; export const ARRAY_METHOD_TO_REVERSED: Method> = method( Array.prototype.toReversed.length, - (a) => a.toReversed() + (a) => Array.prototype.toReversed.call(a) ); diff --git a/packages/maple/src/builtins/array/methods/to-sorted.ts b/packages/maple/src/builtins/array/methods/to-sorted.ts index c0f85a9..f651d21 100644 --- a/packages/maple/src/builtins/array/methods/to-sorted.ts +++ b/packages/maple/src/builtins/array/methods/to-sorted.ts @@ -3,5 +3,5 @@ import { comparator } from '../utils/comparator.ts'; export const ARRAY_METHOD_TO_SORTED: Method> = method( Array.prototype.toSorted.length, - (a, [fn], context) => a.toSorted(comparator(fn, context)) + (a, [fn], context) => Array.prototype.toSorted.call(a, comparator(fn, context)) ); diff --git a/packages/maple/src/builtins/array/methods/to-spliced.ts b/packages/maple/src/builtins/array/methods/to-spliced.ts index 7f63459..e87658e 100644 --- a/packages/maple/src/builtins/array/methods/to-spliced.ts +++ b/packages/maple/src/builtins/array/methods/to-spliced.ts @@ -1,11 +1,21 @@ import { method, type Method } from '../../../values/native.ts'; import { splice } from '../utils/splice.ts'; +// `call` is typed by the last overload, which takes no items, so the one with items gets its own +const toSplicedWithItems: ( + this: ReadonlyArray, + start: number, + skipCount: number, + ...items: Array +) => Array = Array.prototype.toSpliced; + export const ARRAY_METHOD_TO_SPLICED: Method> = method( Array.prototype.toSpliced.length, (a, args) => { return splice(a, args, (start, count, items) => { - return count === undefined ? a.toSpliced(start) : a.toSpliced(start, count, ...items); + return count === undefined + ? Array.prototype.toSpliced.call(a, start) + : toSplicedWithItems.call(a, start, count, ...items); }); } ); diff --git a/packages/maple/src/builtins/array/methods/unshift.ts b/packages/maple/src/builtins/array/methods/unshift.ts index c9ae42a..81f5e5f 100644 --- a/packages/maple/src/builtins/array/methods/unshift.ts +++ b/packages/maple/src/builtins/array/methods/unshift.ts @@ -1,10 +1,10 @@ +import { guardIntegrityErrors } from '../../../values/guard-integrity-errors.ts'; import { guardHostErrors, method, type Method } from '../../../values/native.ts'; -import { guardIntegrityErrors } from '../utils/guard-integrity-errors.ts'; export const ARRAY_METHOD_UNSHIFT: Method> = method( Array.prototype.unshift.length, (a, items, context) => guardHostErrors(context.fail, () => - guardIntegrityErrors(context.fail, a, () => a.unshift(...items)) + guardIntegrityErrors(context.fail, a, () => Array.prototype.unshift.call(a, ...items)) ) ); diff --git a/packages/maple/src/builtins/array/methods/values.ts b/packages/maple/src/builtins/array/methods/values.ts index 670d718..1ee1c10 100644 --- a/packages/maple/src/builtins/array/methods/values.ts +++ b/packages/maple/src/builtins/array/methods/values.ts @@ -3,5 +3,5 @@ import { IteratorWrapper } from '../../../values/wrappers/iterator-wrapper.ts'; export const ARRAY_METHOD_VALUES: Method> = method( Array.prototype.values.length, - (a) => new IteratorWrapper(a.values()) + (a) => new IteratorWrapper(Array.prototype.values.call(a)) ); diff --git a/packages/maple/src/builtins/array/methods/with.ts b/packages/maple/src/builtins/array/methods/with.ts index 3ffc491..0bd5415 100644 --- a/packages/maple/src/builtins/array/methods/with.ts +++ b/packages/maple/src/builtins/array/methods/with.ts @@ -3,5 +3,6 @@ import { guardHostErrors, method, type Method } from '../../../values/native.ts' export const ARRAY_METHOD_WITH: Method> = method( Array.prototype.with.length, - (a, [index, value], { fail }) => guardHostErrors(fail, () => a.with(toNumber(index), value)) + (a, [index, value], { fail }) => + guardHostErrors(fail, () => Array.prototype.with.call(a, toNumber(index), value)) ); diff --git a/packages/maple/src/builtins/array/statics/from.ts b/packages/maple/src/builtins/array/statics/from.ts index 1f8a084..951950f 100644 --- a/packages/maple/src/builtins/array/statics/from.ts +++ b/packages/maple/src/builtins/array/statics/from.ts @@ -13,7 +13,9 @@ export const ARRAY_STATIC_FROM: NativeFunction = new NativeFunction( } const items: Array = []; const add = (item: unknown): void => { - items.push(mapper === undefined ? item : context.call(mapper, [item, items.length])); + items.push( + mapper === undefined ? item : context.call(undefined, mapper, [item, items.length]) + ); }; const iterator = iterate(source); if (iterator) { diff --git a/packages/maple/src/builtins/array/utils/array-callback.ts b/packages/maple/src/builtins/array/utils/array-callback.ts index 05f1bce..ea880c3 100644 --- a/packages/maple/src/builtins/array/utils/array-callback.ts +++ b/packages/maple/src/builtins/array/utils/array-callback.ts @@ -8,5 +8,5 @@ export function arrayCallback(fn: unknown, context: CallContext): ArrayCallback if (!isCallable(fn)) { context.fail('TypeError', `${describeValue(fn)} is not a function`); } - return (...args) => context.call(fn, args); + return (...args) => context.call(undefined, fn, args); } diff --git a/packages/maple/src/builtins/function/function-prototype.ts b/packages/maple/src/builtins/function/function-prototype.ts index c0a18fe..ea929cd 100644 --- a/packages/maple/src/builtins/function/function-prototype.ts +++ b/packages/maple/src/builtins/function/function-prototype.ts @@ -1,16 +1,28 @@ -import { isCallable } from '../../values/native.ts'; +import type { Builtin } from '../../values/builtin.ts'; +import type { Closure } from '../../values/closure.ts'; +import { isCallable, type NativeFunction } from '../../values/native.ts'; import { Prototype } from '../../values/prototype.ts'; +import { FUNCTION_METHOD_APPLY } from './methods/apply.ts'; +import { FUNCTION_METHOD_BIND } from './methods/bind.ts'; +import { FUNCTION_METHOD_CALL } from './methods/call.ts'; import { FUNCTION_METHOD_TO_STRING } from './methods/to-string.ts'; import { FUNCTION_METHOD_VALUE_OF } from './methods/value-of.ts'; +/** What a script can call: a script function, a built-in or host function, or a callable global. */ +export type SandboxFunction = Closure | NativeFunction | Builtin; + /** - * The methods every function answers to, closures, built-ins and host functions alike. `call`, - * `apply` and `bind`, which would let a script pick the receiver, are left out. + * The methods every function answers to, closures, built-ins and host functions alike. The + * receiver `call`, `apply` and `bind` pass on reaches a built-in method, which checks it, and is + * invisible to a script function, which has no `this`. */ -export const FUNCTION_PROTOTYPE: Prototype = new Prototype( +export const FUNCTION_PROTOTYPE: Prototype = new Prototype( 'Function', - (value): value is unknown => isCallable(value), + (value): value is SandboxFunction => isCallable(value), { + apply: FUNCTION_METHOD_APPLY, + bind: FUNCTION_METHOD_BIND, + call: FUNCTION_METHOD_CALL, toString: FUNCTION_METHOD_TO_STRING, valueOf: FUNCTION_METHOD_VALUE_OF } diff --git a/packages/maple/src/builtins/function/function.spec.ts b/packages/maple/src/builtins/function/function.spec.ts new file mode 100644 index 0000000..a8d3828 --- /dev/null +++ b/packages/maple/src/builtins/function/function.spec.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from 'vitest'; +import { intrinsic } from '../../boundary/host.ts'; +import { Maple } from '../../maple.ts'; +import { runAndCatch } from '../../spec-helpers.ts'; + +const maple = new Maple({ + globals: { + sum: (...values: Array): number => values.reduce((a, b) => a + b, 0), + count: intrinsic((args) => args.length) + } +}); + +function run(code: string, data?: unknown): unknown { + return maple.run(code, data).value; +} + +function sandboxError(code: string): { name?: string; message?: string } | undefined { + return runAndCatch(maple, code).sandboxError; +} + +describe('Function.prototype.call', () => { + it('calls script functions with the arguments after the receiver', () => { + expect(run('function f(a, b) { return a + b; } return f.call(null, 1, 2);')).toBe(3); + expect(run('return ((a) => a * 2).call(undefined, 4);')).toBe(8); + expect(run('function f() { return arguments.length; } return f.call();')).toBe(0); + expect(run('return (function () { return 1; }).call({ a: 1 });')).toBe(1); + }); + + it('passes the receiver to built-in methods', () => { + expect(run('return "".trim.call(" b ");')).toBe('b'); + expect(run('const a = [1]; [].push.call(a, 2, 3); return a;')).toEqual([1, 2, 3]); + expect(run('return Math.max.call(null, 1, 3, 2);')).toBe(3); + expect(run('return [String.call(null, 5), Error.call(null, "m").message];')).toEqual([ + '5', + 'm' + ]); + }); + + it('still checks the receiver of a built-in method', () => { + expect(sandboxError('"".trim.call({});')).toMatchObject({ + name: 'TypeError', + message: 'String.prototype.trim called on incompatible receiver [object Object]' + }); + expect(sandboxError('[].push.call("x", 1);')?.name).toBe('TypeError'); + expect(sandboxError('Map.call();')).toMatchObject({ + name: 'TypeError', + message: "Constructor Map requires 'new'" + }); + }); + + it('calls host functions and intrinsics', () => { + expect(run('return sum.call(null, 1, 2);')).toBe(3); + expect(run('return count.call(null, 1, 2, 3);')).toBe(3); + }); + + it('needs a function as its receiver', () => { + expect(sandboxError('const c = (() => 1).call; c();')).toMatchObject({ + name: 'TypeError', + message: 'Function.prototype.call called on incompatible receiver undefined' + }); + expect(sandboxError('(() => 1).call.call(1);')?.name).toBe('TypeError'); + expect(run('function f(a) { return a; } return f.call.call(f, null, 1);')).toBe(1); + }); + + it('has the length of JavaScript', () => { + expect(run('return [Math.max.call.length, Math.max.call.name];')).toEqual([1, 'call']); + }); +}); + +describe('Function.prototype.apply', () => { + const collect = 'function f() { return [...arguments]; }'; + + it('spreads an array-like into the arguments', () => { + expect(run(`${collect} return f.apply(null, [1, 2]);`)).toEqual([1, 2]); + expect(run(`${collect} return f.apply(null, [1, , 3]);`)).toEqual([1, undefined, 3]); + expect(run(`${collect} return f.apply(null, { length: 2, 0: "a", 1: "b" });`)).toEqual([ + 'a', + 'b' + ]); + expect( + run(`${collect} function g() { return f.apply(null, arguments); } return g(1, 2);`) + ).toEqual([1, 2]); + expect(run(`${collect} return f.apply(null, (a, b) => 1);`)).toEqual([undefined, undefined]); + expect(run(`${collect} return f.apply(null, new Map());`)).toEqual([]); + }); + + it('takes length as JavaScript converts it', () => { + expect(run(`${collect} return f.apply(null, { length: -1 });`)).toEqual([]); + expect(run(`${collect} return f.apply(null, { length: 2.7, 0: 1 });`)).toEqual([1, undefined]); + expect(run(`${collect} return f.apply(null, { length: "1", 0: 1 });`)).toEqual([1]); + }); + + it('calls with no arguments for null and undefined', () => { + expect( + run(`${collect} return [f.apply(), f.apply(null, null), f.apply(null, undefined)];`) + ).toEqual([[], [], []]); + }); + + it('refuses a primitive as the arguments', () => { + expect(sandboxError('(() => 1).apply(null, 1);')).toMatchObject({ + name: 'TypeError', + message: 'CreateListFromArrayLike called on non-object' + }); + expect(sandboxError('(() => 1).apply(null, "ab");')?.name).toBe('TypeError'); + }); + + it('passes the receiver to built-in methods and calls everything else', () => { + expect(run('return Math.max.apply(null, [1, 3, 2]);')).toBe(3); + expect(run('return "a-b".split.apply("x-y", ["-"]);')).toEqual(['x', 'y']); + expect(sandboxError('[].push.apply({}, [1]);')?.name).toBe('TypeError'); + expect(run('return [sum.apply(null, [1, 2]), count.apply(null, [1, 2])];')).toEqual([3, 2]); + }); + + it('charges the arguments before it builds them', () => { + const bounded = new Maple({ maxOps: 1000 }); + + expect(runAndCatch(bounded, '(() => 1).apply(null, { length: 1e9 });').code).toBe( + 'LIMIT_EXCEEDED' + ); + const ops = (code: string): number => bounded.run(code).stats.ops; + expect( + ops('(() => 1).apply(null, { length: 100 });') - ops('(() => 1).apply(null, { length: 0 });') + ).toBe(100); + }); + + it('has the length of JavaScript', () => { + expect(run('return [Math.max.apply.length, Math.max.apply.name];')).toEqual([2, 'apply']); + }); +}); + +describe('Function.prototype.bind', () => { + it('fixes leading arguments', () => { + expect(run('function f(a, b) { return a + b; } return f.bind(null, 1)(2);')).toBe(3); + expect(run('function f(a, b) { return [a, b]; } return f.bind()(1, 2);')).toEqual([1, 2]); + expect(run('function f(...a) { return a; } return f.bind(null, 1).bind(null, 2)(3);')).toEqual([ + 1, 2, 3 + ]); + expect( + run('const atLeast2 = Math.max.bind(null, 2); return [atLeast2(1), atLeast2(5)];') + ).toEqual([2, 5]); + }); + + it('names a bound function after its target and leaves the rest of its length', () => { + expect( + run('function f(a, b, c) {} const b = f.bind(null, 1); return [b.name, b.length];') + ).toEqual(['bound f', 2]); + expect(run('return [(() => 1).bind().name, (() => 1).bind(null, 1, 2).length];')).toEqual([ + 'bound ', + 0 + ]); + expect(run('function f(a, b) {} return f.bind().bind().name;')).toBe('bound bound f'); + expect(run('return [Math.max.bind().name, Math.max.bind().length];')).toEqual(['bound max', 2]); + expect(run('return [String.bind().name, "".trim.bind().name];')).toEqual([ + 'bound String', + 'bound trim' + ]); + expect(run('return Object.getOwnPropertyNames((() => 1).bind());')).toEqual(['length', 'name']); + }); + + it('shows no properties for a bound host function', () => { + expect(run('const b = sum.bind(null, 1); return [b.name, b.length, Object.keys(b)];')).toEqual([ + undefined, + undefined, + [] + ]); + expect(run('return [sum.bind(null, 1)(2), count.bind(null, 1)(2, 3)];')).toEqual([3, 3]); + }); + + it('passes the bound receiver to a built-in method, which checks it', () => { + expect(run('return "".trim.bind(" b ")();')).toBe('b'); + expect(run('const a = []; const push = [].push.bind(a); push(1); push(2); return a;')).toEqual([ + 1, 2 + ]); + expect(sandboxError('[].push.bind({})(1);')).toMatchObject({ + name: 'TypeError', + message: 'Array.prototype.push called on incompatible receiver [object Object]' + }); + }); + + it('is a function that takes properties and freezes', () => { + expect(run('return typeof (() => 1).bind();')).toBe('function'); + expect(run('return String((() => 1).bind());')).toBe('[function]'); + expect( + run( + 'const b = (() => 1).bind(); b.x = 1; Object.freeze(b); return [b.x, Object.isFrozen(b)];' + ) + ).toEqual([1, true]); + expect(sandboxError('const b = (() => 1).bind(); Object.freeze(b); b.x = 1;')?.name).toBe( + 'TypeError' + ); + expect(sandboxError('(() => 1).bind().name = "x";')?.name).toBe('TypeError'); + }); + + it('cannot be constructed or used with instanceof', () => { + expect(runAndCatch(maple, 'const B = Error.bind(null, "x"); new B();').code).toBe( + 'DISALLOWED_SYNTAX' + ); + expect(sandboxError('({}) instanceof (() => 1).bind();')).toMatchObject({ + name: 'TypeError', + message: "Right-hand side of 'instanceof' is not a constructor" + }); + }); + + it('needs a function as its receiver', () => { + expect(sandboxError('const b = (() => 1).bind; b();')).toMatchObject({ + name: 'TypeError', + message: 'Function.prototype.bind called on incompatible receiver undefined' + }); + }); + + it('keeps the name of the target in the stack', () => { + expect( + run('function f() { return new Error("e").stack; } return f.bind()().split("\\n")[1];') + ).toMatch(/^ {4}at f /); + }); + + it('has the length of JavaScript', () => { + expect(run('return [Math.max.bind.length, Math.max.bind.name];')).toEqual([1, 'bind']); + }); +}); + +describe('calls through call, apply and bind', () => { + it('count toward maxDepth like the calls they make', () => { + const shallow = new Maple({ maxDepth: 10 }); + + expect( + shallow.run('function f(n) { return n && f.call(null, n - 1); } return f(9);').stats.maxDepth + ).toBe(10); + expect(runAndCatch(shallow, 'function f(n) { return f.apply(null, [n]); } f(1);').code).toBe( + 'LIMIT_EXCEEDED' + ); + expect(runAndCatch(shallow, 'function f() { return g(); } const g = f.bind(); f();').code).toBe( + 'LIMIT_EXCEEDED' + ); + }); + + it('count a level for each built-in that one built-in calls', () => { + const shallow = new Maple({ maxDepth: 10 }); + + expect(shallow.run('return Math.max.call(null, 1);').stats.maxDepth).toBe(1); + expect( + runAndCatch( + shallow, + 'const ap = (() => 1).apply; const a = [ap, null]; a[1] = a; ap.apply(ap, a);' + ).code + ).toBe('LIMIT_EXCEEDED'); + }); +}); diff --git a/packages/maple/src/builtins/function/methods/apply.ts b/packages/maple/src/builtins/function/methods/apply.ts new file mode 100644 index 0000000..2060f5b --- /dev/null +++ b/packages/maple/src/builtins/function/methods/apply.ts @@ -0,0 +1,28 @@ +import { toLength } from '../../../values/coercion.ts'; +import { method, type Method } from '../../../values/native.ts'; +import { getMember } from '../../method-dispatch.ts'; +import type { SandboxFunction } from '../function-prototype.ts'; + +export const FUNCTION_METHOD_APPLY: Method = method( + Function.prototype.apply.length, + (fn, [thisArg, argArray], context) => { + const list: Array = []; + + if (argArray === null || argArray === undefined) { + return context.call(thisArg, fn, list); + } + + if (typeof argArray !== 'object') { + return context.fail('TypeError', 'CreateListFromArrayLike called on non-object'); + } + + const length = toLength(getMember(argArray, 'length', context)); + // Charged before the list is built, so a huge `length` is stopped before anything is allocated + context.charge(length); + for (let index = 0; index < length; index++) { + list.push(getMember(argArray, String(index), context)); + } + + return context.call(thisArg, fn, list); + } +); diff --git a/packages/maple/src/builtins/function/methods/bind.ts b/packages/maple/src/builtins/function/methods/bind.ts new file mode 100644 index 0000000..a13b909 --- /dev/null +++ b/packages/maple/src/builtins/function/methods/bind.ts @@ -0,0 +1,21 @@ +import { method, type Method, NativeFunction } from '../../../values/native.ts'; +import type { SandboxFunction } from '../function-prototype.ts'; + +/** + * A bound function is a built-in of the run that made it. A host function bound keeps showing no + * properties, as host functions do. + */ +export const FUNCTION_METHOD_BIND: Method = method( + Function.prototype.bind.length, + (fn, [thisArg, ...boundArgs]) => + new NativeFunction( + `bound ${fn.name}`, + fn.length === undefined ? undefined : Math.max(0, fn.length - boundArgs.length), + (_, args, context) => { + // Copying the bound arguments into each call costs one operation apiece + context.charge(boundArgs.length); + return context.call(thisArg, fn, [...boundArgs, ...args]); + }, + false + ) +); diff --git a/packages/maple/src/builtins/function/methods/call.ts b/packages/maple/src/builtins/function/methods/call.ts new file mode 100644 index 0000000..0b5ad1b --- /dev/null +++ b/packages/maple/src/builtins/function/methods/call.ts @@ -0,0 +1,7 @@ +import { method, type Method } from '../../../values/native.ts'; +import type { SandboxFunction } from '../function-prototype.ts'; + +export const FUNCTION_METHOD_CALL: Method = method( + Function.prototype.call.length, + (fn, [thisArg, ...args], context) => context.call(thisArg, fn, args) +); diff --git a/packages/maple/src/builtins/iterator/utils/iterator-callback.ts b/packages/maple/src/builtins/iterator/utils/iterator-callback.ts index 4a855e4..a6e31cb 100644 --- a/packages/maple/src/builtins/iterator/utils/iterator-callback.ts +++ b/packages/maple/src/builtins/iterator/utils/iterator-callback.ts @@ -18,5 +18,5 @@ export function iteratorCallback( closeIterator(wrapper); return context.fail('TypeError', `${describeValue(fn)} is not a function`); } - return (...args) => context.call(fn, args); + return (...args) => context.call(undefined, fn, args); } diff --git a/packages/maple/src/builtins/json/statics/parse.ts b/packages/maple/src/builtins/json/statics/parse.ts index 5c05d18..b164aa5 100644 --- a/packages/maple/src/builtins/json/statics/parse.ts +++ b/packages/maple/src/builtins/json/statics/parse.ts @@ -61,5 +61,5 @@ function revive( } } } - return context.call(reviver, [key, value, holder]); + return context.call(undefined, reviver, [key, value, holder]); } diff --git a/packages/maple/src/builtins/json/statics/stringify.ts b/packages/maple/src/builtins/json/statics/stringify.ts index f85992f..3250f8f 100644 --- a/packages/maple/src/builtins/json/statics/stringify.ts +++ b/packages/maple/src/builtins/json/statics/stringify.ts @@ -75,7 +75,7 @@ function replacerOf( context: CallContext ): ((key: string, current: unknown) => unknown) | undefined { return isCallable(replacer) - ? (key, current) => context.call(replacer, [key, current]) + ? (key, current) => context.call(undefined, replacer, [key, current]) : undefined; } diff --git a/packages/maple/src/builtins/map/methods/for-each.ts b/packages/maple/src/builtins/map/methods/for-each.ts index 1ca2dba..5e17c66 100644 --- a/packages/maple/src/builtins/map/methods/for-each.ts +++ b/packages/maple/src/builtins/map/methods/for-each.ts @@ -10,7 +10,7 @@ export const MAP_METHOD_FOR_EACH: Method = method( } for (const [key, value] of m.map) { - context.call(fn, [value, key, m]); + context.call(undefined, fn, [value, key, m]); } return undefined; diff --git a/packages/maple/src/builtins/map/statics/group-by.ts b/packages/maple/src/builtins/map/statics/group-by.ts index 7e768fd..2f6d4f2 100644 --- a/packages/maple/src/builtins/map/statics/group-by.ts +++ b/packages/maple/src/builtins/map/statics/group-by.ts @@ -16,7 +16,7 @@ export const MAP_STATIC_GROUP_BY: NativeFunction = new NativeFunction( return context.fail('TypeError', `${describeValue(callback)} is not a function`); } return new MapWrapper( - Map.groupBy(iterable, (item, index) => context.call(callback, [item, index])) + Map.groupBy(iterable, (item, index) => context.call(undefined, callback, [item, index])) ); } ); diff --git a/packages/maple/src/builtins/object/object-statics.ts b/packages/maple/src/builtins/object/object-statics.ts index 1f77994..e3a454a 100644 --- a/packages/maple/src/builtins/object/object-statics.ts +++ b/packages/maple/src/builtins/object/object-statics.ts @@ -8,9 +8,11 @@ import { OBJECT_STATIC_GROUP_BY } from './statics/group-by.ts'; import { OBJECT_STATIC_HAS_OWN } from './statics/has-own.ts'; import { OBJECT_STATIC_IS_EXTENSIBLE } from './statics/is-extensible.ts'; import { OBJECT_STATIC_IS_FROZEN } from './statics/is-frozen.ts'; +import { OBJECT_STATIC_IS_SEALED } from './statics/is-sealed.ts'; import { OBJECT_STATIC_IS } from './statics/is.ts'; import { OBJECT_STATIC_KEYS } from './statics/keys.ts'; import { OBJECT_STATIC_PREVENT_EXTENSIONS } from './statics/prevent-extensions.ts'; +import { OBJECT_STATIC_SEAL } from './statics/seal.ts'; import { OBJECT_STATIC_VALUES } from './statics/values.ts'; /** The `Object.*` functions; all of them see own enumerable properties and nothing else. */ @@ -25,6 +27,8 @@ export const OBJECT_STATICS: Readonly> = { getOwnPropertyNames: OBJECT_STATIC_GET_OWN_PROPERTY_NAMES, freeze: OBJECT_STATIC_FREEZE, isFrozen: OBJECT_STATIC_IS_FROZEN, + seal: OBJECT_STATIC_SEAL, + isSealed: OBJECT_STATIC_IS_SEALED, preventExtensions: OBJECT_STATIC_PREVENT_EXTENSIONS, isExtensible: OBJECT_STATIC_IS_EXTENSIBLE, groupBy: OBJECT_STATIC_GROUP_BY diff --git a/packages/maple/src/builtins/object/object.spec.ts b/packages/maple/src/builtins/object/object.spec.ts index 91e10c8..193b44b 100644 --- a/packages/maple/src/builtins/object/object.spec.ts +++ b/packages/maple/src/builtins/object/object.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { Maple } from '../../maple.ts'; -import { runAndCatch } from '../../spec-helpers.ts'; +import { errorMessage, runAndCatch } from '../../spec-helpers.ts'; const maple = new Maple(); @@ -35,6 +35,14 @@ describe('Object statics', () => { expect(run('return Object.fromEntries([[1, "n"]]);')).toEqual({ 1: 'n' }); expect(run('const o = Object.fromEntries([]); o.x = 1; return o;')).toEqual({ x: 1 }); expect(run('try { Object.fromEntries([1]); } catch (e) { return e.name; }')).toBe('TypeError'); + expect( + run( + 'const f = () => 1; f[0] = "k"; f[1] = "v"; return Object.entries(Object.fromEntries([f, Math.max]));' + ) + ).toEqual([ + ['k', 'v'], + ['undefined', undefined] + ]); expect(run('try { Object.fromEntries(5); } catch (e) { return e.message; }')).toBe( '5 is not iterable' ); @@ -268,6 +276,159 @@ describe('Object.is, getOwnPropertyNames, freeze and isFrozen', () => { a: [1, 2] }); }); + + it('refuses deleting what freeze locked, naming the object as JavaScript does', () => { + expect(errorMessage(maple, 'delete Object.freeze({ a: 1 }).a;')).toBe( + "Cannot delete property 'a' of #" + ); + expect(errorMessage(maple, 'delete Object.freeze([1])[0];')).toBe( + "Cannot delete property '0' of [object Array]" + ); + expect(errorMessage(maple, 'const f = Object.freeze(() => 1); delete f.name;')).toBe( + "Cannot delete property 'name' of [function]" + ); + expect(errorMessage(maple, 'delete Object.freeze(new Error("m")).message;')).toBe( + "Cannot delete property 'message' of Error: m" + ); + 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, 'function f() { Object.freeze(arguments); delete arguments[0]; } f(1);') + ).toBe("Cannot delete property '0' of #"); + }); + + it('refuses deleting from a frozen error that does not convert, as a script error', () => { + expect( + run(` + const e = new Error("m"); + e.name = e; + e.x = 1; + Object.freeze(e); + const ran = []; + try { + delete e.x; + } catch (t) { + ran.push(t.message); + } finally { + ran.push("finally"); + } + return ran; + `) + ).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); + expect(run('return [delete Object.freeze(new Map()).size, delete /a/.source];')).toEqual([ + true, + true + ]); + }); +}); + +describe('Object.seal and isSealed', () => { + it('keep existing properties writable but take no new one and delete none', () => { + expect(run('const o = Object.seal({ a: 1 }); o.a = 2; return o;')).toEqual({ a: 2 }); + expect(errorMessage(maple, 'Object.seal({ a: 1 }).b = 2;')).toBe( + 'Cannot add property b, object is not extensible' + ); + expect(errorMessage(maple, 'delete Object.seal({ a: 1 }).a;')).toBe( + "Cannot delete property 'a' of #" + ); + expect(run('return Object.assign(Object.seal({ a: 1 }), { a: 2 });')).toEqual({ a: 2 }); + expect( + runAndCatch(maple, 'Object.assign(Object.seal({ a: 1 }), { b: 2 });').sandboxError?.name + ).toBe('TypeError'); + }); + + it('let the array methods reorder and overwrite but neither add nor remove', () => { + const sealed = 'const a = Object.seal([1, 2, 3]);'; + + expect( + run( + 'const a = Object.seal([3, 1, 2]); a[0] = 9; a.sort(); a.reverse(); a.copyWithin(0, 2); return a;' + ) + ).toEqual([1, 2, 1]); + expect(run('const a = Object.seal([1, 2]); a.fill(0); a.length = 4; return a.length;')).toBe(4); + expect(errorMessage(maple, `${sealed} a.push(4);`)).toBe( + 'Cannot add property 3, object is not extensible' + ); + expect(errorMessage(maple, `${sealed} a.pop();`)).toBe( + "Cannot delete property '2' of [object Array]" + ); + expect(errorMessage(maple, `${sealed} a.shift();`)).toBe( + "Cannot delete property '2' of [object Array]" + ); + expect(errorMessage(maple, `${sealed} a.splice(0, 1);`)).toBe( + "Cannot delete property '2' of [object Array]" + ); + expect(errorMessage(maple, `${sealed} a.length = 0;`)).toBe( + "Cannot delete property '2' of [object Array]" + ); + expect(errorMessage(maple, `${sealed} delete a[0];`)).toBe( + "Cannot delete property '0' of [object Array]" + ); + }); + + it('refuse deleting from a sealed error that does not convert, as a script error', () => { + expect( + run( + 'const e = new Error("m"); e.name = e; e.x = 1; Object.seal(e); const ran = []; try { delete e.x; } catch (t) { ran.push(t.message); } finally { ran.push("finally"); } return ran;' + ) + ).toEqual(["Cannot delete property 'x' of [object Error]", 'finally']); + }); + + it('seal Maple own values, which keep working', () => { + expect( + run( + 'const m = Object.seal(new Map()); m.set(1, 2); const r = Object.seal(/a/g); r.lastIndex = 3; const d = Object.seal(new Date(0)); d.setTime(5); return [m.size, r.lastIndex, d.getTime()];' + ) + ).toEqual([1, 3, 5]); + expect( + run( + 'const e = Object.seal(new Error("m")); e.message = "x"; return [e.message, Object.isSealed(e)];' + ) + ).toEqual(['x', true]); + expect(errorMessage(maple, 'delete Object.seal(new Error("m")).message;')).toBe( + "Cannot delete property 'message' of Error: m" + ); + expect(errorMessage(maple, 'Object.seal(() => 1).x = 1;')).toBe( + 'Cannot add property x, object is not extensible' + ); + expect( + run( + 'function f() { Object.seal(arguments); arguments[0] = 5; return arguments[0]; } return f(1);' + ) + ).toBe(5); + }); + + it('report it, true for primitives and for anything frozen', () => { + expect( + run( + 'return [Object.isSealed(1), Object.isSealed(null), Object.isSealed({}), Object.isSealed(Object.preventExtensions({})), Object.isSealed(Object.preventExtensions({ a: 1 })), Object.isSealed(Object.freeze({ a: 1 })), Object.isSealed(obj)];', + { obj: { a: 1 } } + ) + ).toEqual([true, true, false, true, false, true, true]); + expect( + run('return [Object.isFrozen(Object.seal({ a: 1 })), Object.isFrozen(Object.seal({}))];') + ).toEqual([false, true]); + expect( + run('const f = Object.seal(() => 1); return [Object.isSealed(f), Object.isFrozen(f)];') + ).toEqual([true, true]); + expect(run('return [Object.seal(1), Object.seal(null), Object.seal(undefined)];')).toEqual([ + 1, + null, + undefined + ]); + expect(run('const o = {}; return Object.seal(o) === o;')).toBe(true); + expect(run('return [Object.seal.length, Object.isSealed.length];')).toEqual([1, 1]); + }); + + it('keep a built-in sealed only for the run that sealed it', () => { + 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]); + }); }); describe('Object.preventExtensions and isExtensible', () => { diff --git a/packages/maple/src/builtins/object/statics/from-entries.ts b/packages/maple/src/builtins/object/statics/from-entries.ts index d17d7ec..108986f 100644 --- a/packages/maple/src/builtins/object/statics/from-entries.ts +++ b/packages/maple/src/builtins/object/statics/from-entries.ts @@ -1,5 +1,4 @@ import { defineOwn } from '../../../support/own-property.ts'; -import { Closure } from '../../../values/closure.ts'; import { describeValue } from '../../../values/coercion.ts'; import { iterate } from '../../../values/iteration.ts'; import { toPropertyKey } from '../../../values/members.ts'; @@ -20,7 +19,7 @@ export const OBJECT_STATIC_FROM_ENTRIES: NativeFunction = new NativeFunction( const result: Record = {}; for (let step = iterator.next(); !step.done; step = iterator.next()) { const entry: unknown = step.value; - if (typeof entry !== 'object' || entry === null || entry instanceof Closure) { + if (typeof entry !== 'object' || entry === null) { return fail('TypeError', `Iterator value ${describeValue(entry)} is not an entry object`); } defineOwn( diff --git a/packages/maple/src/builtins/object/statics/group-by.ts b/packages/maple/src/builtins/object/statics/group-by.ts index 9a2907f..7df3054 100644 --- a/packages/maple/src/builtins/object/statics/group-by.ts +++ b/packages/maple/src/builtins/object/statics/group-by.ts @@ -18,7 +18,7 @@ export const OBJECT_STATIC_GROUP_BY: NativeFunction = new NativeFunction( // Converting the key here keeps the host from running its own conversion on a script object return Object.groupBy(iterable, (item, index) => - toPropertyKey(context.call(callback, [item, index])) + toPropertyKey(context.call(undefined, callback, [item, index])) ); } ); diff --git a/packages/maple/src/builtins/object/statics/is-sealed.ts b/packages/maple/src/builtins/object/statics/is-sealed.ts new file mode 100644 index 0000000..c389595 --- /dev/null +++ b/packages/maple/src/builtins/object/statics/is-sealed.ts @@ -0,0 +1,18 @@ +import { isPlainObject } from '../../../support/is-plain-object.ts'; +import { NativeFunction } from '../../../values/native.ts'; +import { SandboxObject } from '../../../values/sandbox-object.ts'; + +/** + * Derived from the properties, as JavaScript does: a primitive counts as sealed, and so does + * anything frozen. + */ +export const OBJECT_STATIC_IS_SEALED: NativeFunction = new NativeFunction( + 'isSealed', + Object.isSealed.length, + (_, [target], { runState }) => { + if (Array.isArray(target) || isPlainObject(target)) { + return Object.isSealed(target); + } + return target instanceof SandboxObject ? target.testIntegrityLevel('sealed', runState) : true; + } +); diff --git a/packages/maple/src/builtins/object/statics/seal.ts b/packages/maple/src/builtins/object/statics/seal.ts new file mode 100644 index 0000000..996de8d --- /dev/null +++ b/packages/maple/src/builtins/object/statics/seal.ts @@ -0,0 +1,20 @@ +import { isPlainObject } from '../../../support/is-plain-object.ts'; +import { NativeFunction } from '../../../values/native.ts'; +import { SandboxObject } from '../../../values/sandbox-object.ts'; + +/** + * Sealing reaches what the script can write to: objects, arrays and Maple's own values. Everything + * else is returned as it is, as JavaScript does for primitives. + */ +export const OBJECT_STATIC_SEAL: NativeFunction = new NativeFunction( + 'seal', + Object.seal.length, + (_, [target], { runState }) => { + if (Array.isArray(target) || isPlainObject(target)) { + Object.seal(target); + } else if (target instanceof SandboxObject) { + target.setIntegrityLevel('sealed', runState); + } + return target; + } +); diff --git a/packages/maple/src/builtins/set/methods/for-each.ts b/packages/maple/src/builtins/set/methods/for-each.ts index 2634933..8328349 100644 --- a/packages/maple/src/builtins/set/methods/for-each.ts +++ b/packages/maple/src/builtins/set/methods/for-each.ts @@ -10,7 +10,7 @@ export const SET_METHOD_FOR_EACH: Method = method( } for (const value of s.set) { - context.call(fn, [value, value, s]); + context.call(undefined, fn, [value, value, s]); } return undefined; diff --git a/packages/maple/src/builtins/string/methods/concat.ts b/packages/maple/src/builtins/string/methods/concat.ts index 368b5c0..470f64b 100644 --- a/packages/maple/src/builtins/string/methods/concat.ts +++ b/packages/maple/src/builtins/string/methods/concat.ts @@ -3,5 +3,5 @@ import { guardHostErrors, method, type Method } from '../../../values/native.ts' export const STRING_METHOD_CONCAT: Method = method( String.prototype.concat.length, - (s, args, { fail }) => guardHostErrors(fail, () => s.concat(...args.map(toString))) + (s, args, { fail }) => guardHostErrors(fail, () => s.concat(...args.map((arg) => toString(arg)))) ); diff --git a/packages/maple/src/builtins/string/methods/replace-all.ts b/packages/maple/src/builtins/string/methods/replace-all.ts index e531da1..b5d72eb 100644 --- a/packages/maple/src/builtins/string/methods/replace-all.ts +++ b/packages/maple/src/builtins/string/methods/replace-all.ts @@ -16,7 +16,7 @@ export const STRING_METHOD_REPLACE_ALL: Method = method( return s.replaceAll(search, toString(replacement)); } return s.replaceAll(search, (...hostArgs: Array): string => - toString(context.call(replacement, hostArgs)) + toString(context.call(undefined, replacement, hostArgs)) ); } ); diff --git a/packages/maple/src/builtins/string/methods/replace.ts b/packages/maple/src/builtins/string/methods/replace.ts index be70c1e..61d0abc 100644 --- a/packages/maple/src/builtins/string/methods/replace.ts +++ b/packages/maple/src/builtins/string/methods/replace.ts @@ -13,7 +13,7 @@ export const STRING_METHOD_REPLACE: Method = method( return s.replace(search, toString(replacement)); } return s.replace(search, (...hostArgs: Array): string => - toString(context.call(replacement, hostArgs)) + toString(context.call(undefined, replacement, hostArgs)) ); } ); diff --git a/packages/maple/src/errors.spec.ts b/packages/maple/src/errors.spec.ts index c8986b0..74c40a1 100644 --- a/packages/maple/src/errors.spec.ts +++ b/packages/maple/src/errors.spec.ts @@ -8,6 +8,11 @@ function run(code: string, data?: unknown): unknown { return maple.run(code, data).value; } +/** Script that leaves `e` as the last of `length` errors, each named by the one before. */ +function errorChain(length: number): string { + return `let e = new Error("m"); for (let i = 1; i < ${length}; i++) { const n = new Error("m"); n.name = e; e = n; }`; +} + describe('errors', () => { describe('throw', () => { it('surfaces an uncaught error object as RUNTIME_ERROR', () => { @@ -282,6 +287,70 @@ describe('errors', () => { }); }); +describe('an error whose name or message leads back to it', () => { + it('fails to convert with the RangeError JavaScript gives, which the script can catch', () => { + const caught = (setup: string, conversion: string): unknown => + run( + `const e = new Error("m"); ${setup} try { ${conversion}; } catch (t) { return [t.name, t.message]; }` + ); + const rangeError = ['RangeError', 'Maximum call stack size exceeded']; + + expect(caught('e.name = e;', 'String(e)')).toEqual(rangeError); + expect(caught('e.message = e;', '"" + e')).toEqual(rangeError); + expect(caught('e.name = e;', '`${e}`')).toEqual(rangeError); + expect(caught('e.name = e;', 'e.toString()')).toEqual(rangeError); + expect(caught('e.name = e;', 'new Error(e)')).toEqual(rangeError); + expect(caught('const f = new Error("f"); e.name = f; f.name = e;', 'String(e)')).toEqual( + rangeError + ); + expect( + run( + 'const e = new Error("m"); e.name = e; try { String(e); } catch {} e.name = "X"; return String(e);' + ) + ).toBe('X: m'); + }); + + it('converts through an array, which cuts the cycle as JavaScript does', () => { + expect(run('const e = new Error("m"); e.name = [e]; return String(e);')).toBe('m: m'); + expect(run('const e = new Error("m"); e.message = [e, 1]; return String(e);')).toBe( + 'Error: Error,1' + ); + expect( + run( + 'const l = [1]; const e = new Error("m"); e.name = l; l.push(e); return [String(l), String(e)];' + ) + ).toEqual(['1,m', '1,m: m']); + }); + + it('converts a chain of errors up to the nesting limit', () => { + expect(run(`${errorChain(1000)} return String(e);`)).toBe(`Error: m${': m'.repeat(999)}`); + expect(run(`${errorChain(1001)} try { String(e); } catch (t) { return t.name; }`)).toBe( + 'RangeError' + ); + }); + + it('is named by its tag in the messages of other errors', () => { + expect( + run( + 'const e = new Error("m"); e.name = e; try { Array.from([1], e); } catch (t) { return t.message; }' + ) + ).toBe('[object Error] is not a function'); + }); + + it('still reaches the host, thrown or returned', () => { + expect( + runAndCatch(maple, 'const e = new Error("m"); e.name = e; throw e;').sandboxError + ).toMatchObject({ + name: 'Error', + message: 'm' + }); + expect(run('const e = new Error("m"); e.name = e; return e;')).toMatchObject({ + name: 'Error', + message: 'm' + }); + }); +}); + describe('error additions', () => { it('give errors a toString method', () => { expect(run('return new TypeError("bad").toString();')).toBe('TypeError: bad'); diff --git a/packages/maple/src/escapes.spec.ts b/packages/maple/src/escapes.spec.ts index 2fb5ac7..811a5ee 100644 --- a/packages/maple/src/escapes.spec.ts +++ b/packages/maple/src/escapes.spec.ts @@ -52,6 +52,12 @@ describe('escape attempts', () => { expect(errorName('host.constructor;')).toBe('TypeError'); expect(errorName('[].map.constructor;')).toBe('TypeError'); expect(errorName('Object.keys.constructor;')).toBe('TypeError'); + expect(errorName('(() => {}).call.constructor;')).toBe('TypeError'); + expect(errorName('(() => {}).bind().constructor;')).toBe('TypeError'); + expect(errorName('host.bind().constructor;')).toBe('TypeError'); + expect(errorName('(() => {}).call.call((() => {}).bind, null).constructor;')).toBe( + 'TypeError' + ); }); it('through __proto__ and prototype, static or computed', () => { @@ -64,6 +70,9 @@ describe('escape attempts', () => { expect(errorName('({}).__proto__ = { polluted: true };')).toBe('TypeError'); expect(errorName('({})["constructor"] = 1;')).toBe('TypeError'); expect(errorName('delete ({}).__proto__;')).toBe('TypeError'); + expect(errorName('(() => 1).bind().prototype;')).toBe('TypeError'); + expect(errorName('(() => 1).bind().__proto__;')).toBe('TypeError'); + expect(errorName('(() => 1).apply.__proto__;')).toBe('TypeError'); }); it('through the host-only globals', () => { @@ -137,6 +146,8 @@ describe('escape attempts', () => { expect(errorCode('new Array(1e9);')).toBe('LIMIT_EXCEEDED'); expect(errorCode('new host();')).toBe('DISALLOWED_SYNTAX'); expect(errorCode('new (Math.max)();')).toBe('DISALLOWED_SYNTAX'); + expect(errorCode('const B = Error.bind(null, "x"); new B();')).toBe('DISALLOWED_SYNTAX'); + expect(errorCode('const H = host.bind(); new H();')).toBe('DISALLOWED_SYNTAX'); }); }); @@ -315,20 +326,44 @@ describe('escape attempts', () => { h: undefined }); expect(run('return [host, [].values()];')).toEqual([undefined, undefined]); + expect(run('return [(() => 1).bind(), host.bind(), Math.max.call];')).toEqual([ + undefined, + undefined, + undefined + ]); const received: Array = []; const spy = new Maple({ globals: { host: (value: unknown): number => received.push(value) } }); - spy.run('host(() => 1); host({ deep: { fn: () => 1 } }); host(host); host(Math.max);'); + spy.run( + 'host(() => 1); host({ deep: { fn: () => 1 } }); host(host); host(Math.max); host(host.bind());' + ); expect(received).toStrictEqual([ undefined, { deep: { fn: undefined } }, undefined, + undefined, undefined ]); }); + it('nor into a host function called through call, apply or bind', () => { + const received: Array = []; + const spy = new Maple({ + globals: { host: (value: unknown): number => received.push(value) } + }); + spy.run( + 'const o = { fn: () => 1, n: 1 }; host.call(null, o); host.apply(null, [o]); host.bind(null, o)();' + ); + expect(received).toStrictEqual([ + { fn: undefined, n: 1 }, + { fn: undefined, n: 1 }, + { fn: undefined, n: 1 } + ]); + expect(new Set(received).size).toBe(3); + }); + it('cannot be inspected', () => { expect(run('return [(() => 1).length, (function f(a) {}).name];')).toEqual([0, 'f']); expect(run('return host.name;')).toBeUndefined(); @@ -337,7 +372,7 @@ describe('escape attempts', () => { '[function]', '[function]' ]); - expect(run('return Math.max.call;')).toBeUndefined(); + expect(run('return [host.bind().name, host.bind().length];')).toEqual([undefined, undefined]); expect(run('return Object.keys(() => 1);')).toEqual([]); expect(run('return Object.keys(host);')).toEqual([]); expect(run('return Object.keys(Math);')).toEqual([]); @@ -507,6 +542,19 @@ describe('escape attempts', () => { expect(runAndCatch(bounded, 'exec("while (true) {}"); return 1;').code).toBe( 'LIMIT_EXCEEDED' ); + expect( + runAndCatch(bounded, 'function f() { try { f.call(); } catch {} } f.apply();').code + ).toBe('LIMIT_EXCEEDED'); + expect( + runAndCatch( + bounded, + 'const ap = (() => 1).apply; const a = [ap, null]; a[1] = a; ap.apply(ap, a);' + ).code + ).toBe('LIMIT_EXCEEDED'); + expect( + runAndCatch(bounded, 'const c = (() => 1).call; let b = c; while (true) { b = b.bind(); }') + .code + ).toBe('LIMIT_EXCEEDED'); }); it('by growing an array or copying a large value in one operation', () => { @@ -527,6 +575,12 @@ describe('escape attempts', () => { expect(runAndCatch(bounded, `const [...rest] = ${huge};`).code).toBe('LIMIT_EXCEEDED'); expect(runAndCatch(bounded, `const { ...rest } = ${huge};`).code).toBe('LIMIT_EXCEEDED'); expect(runAndCatch(bounded, `for (const k in ${huge}) {}`).code).toBe('LIMIT_EXCEEDED'); + expect(runAndCatch(bounded, '(() => 1).apply(null, { length: 4294967295 });').code).toBe( + 'LIMIT_EXCEEDED' + ); + expect(runAndCatch(bounded, `Math.max.apply(null, [...${huge}]);`).code).toBe( + 'LIMIT_EXCEEDED' + ); }); it('bound recursion through nested runs even when the intrinsic swallows the error', () => { diff --git a/packages/maple/src/evaluator/evaluator.ts b/packages/maple/src/evaluator/evaluator.ts index 58f055e..5697fa4 100644 --- a/packages/maple/src/evaluator/evaluator.ts +++ b/packages/maple/src/evaluator/evaluator.ts @@ -1064,10 +1064,7 @@ export class Evaluator { private callContext(node: Node): CallContext { return { ...this.memberContext(node), - call: (callee, args) => { - this.tick(node); - return this.invoke(undefined, callee, args, node, 'callback'); - }, + call: (receiver, callee, args) => this.call(receiver, callee, args, node), stackTrace: () => this.stackTrace(node), invokeHost: (fn, args) => this.invokeHost(fn, args, node), invokeIntrinsic: (fn, args) => this.invokeIntrinsic(fn, args, node) @@ -1120,11 +1117,7 @@ export class Evaluator { handle: { toHost: (value) => guard(() => this.toHostArgument(value, node)), fromHost: (value) => guard(() => this.fromHostResult(value, node, 'intrinsic')), - call: (callee, args) => - guard(() => { - this.tick(node); - return this.invoke(undefined, callee, args, node, 'callback'); - }), + call: (callee, args) => guard(() => this.call(undefined, callee, args, node)), run: (code, data, options = {}) => guard(() => this.runNested(code, data, options, node)) }, revoke: () => { @@ -1133,6 +1126,28 @@ export class Evaluator { }; } + /** + * Calls a function for a built-in or an intrinsic. A closure counts its own level; any other + * callee counts one here, so built-ins calling each other, as `apply` can, stay under `maxDepth`. + */ + private call( + receiver: unknown, + callee: unknown, + args: ReadonlyArray, + node: Node + ): unknown { + this.tick(node); + if (callee instanceof Closure) { + return this.callClosure(callee, args, node); + } + this.enterCall(node); + try { + return this.invoke(receiver, callee, args, node, 'callback'); + } finally { + this.#budget.leave(); + } + } + /** Calls any callable sandbox value; `description` names it in the error when it is not one. */ private invoke( receiver: unknown, @@ -1159,13 +1174,7 @@ export class Evaluator { * returns. */ private runNested(code: string, data: unknown, options: RunOptions, node: Node): MapleRunResult { - if (!this.#budget.enter()) { - this.fail( - MapleErrorCode.LIMIT_EXCEEDED, - `Call depth limit of ${this.#budget.maxDepth} exceeded`, - node - ); - } + this.enterCall(node); this.#frames.push({ name: '', calledFrom: node }); const outer = this.#runState; this.#runState = new RunState(); @@ -1285,8 +1294,8 @@ export class Evaluator { return args; } - /** `node` is where the call happened, which is what a depth error points at. */ - private callClosure(closure: Closure, args: ReadonlyArray, node: Node): unknown { + /** Enters one call level, or ends the run once the depth limit is reached. */ + private enterCall(node: Node): void { if (!this.#budget.enter()) { this.fail( MapleErrorCode.LIMIT_EXCEEDED, @@ -1294,6 +1303,11 @@ export class Evaluator { node ); } + } + + /** `node` is where the call happened, which is what a depth error points at. */ + private callClosure(closure: Closure, args: ReadonlyArray, node: Node): unknown { + this.enterCall(node); const { node: fn } = closure; this.#frames.push({ name: closure.name || '', calledFrom: node }); try { diff --git a/packages/maple/src/evaluator/objects.spec.ts b/packages/maple/src/evaluator/objects.spec.ts index bdcada7..ec600d3 100644 --- a/packages/maple/src/evaluator/objects.spec.ts +++ b/packages/maple/src/evaluator/objects.spec.ts @@ -212,7 +212,7 @@ describe('delete and in', () => { it('rejects deleting from input, nullish values and forbidden keys', () => { expect(sandboxError('delete obj.a;', { obj: { a: 1 } })).toMatchObject({ name: 'TypeError', - message: "Cannot delete property 'a' of a read-only object" + message: "Cannot delete property 'a' of #" }); expect(sandboxError('delete undefined.a;')?.name).toBe('TypeError'); expect(sandboxError('const o = {}; delete o.__proto__;')?.name).toBe('TypeError'); @@ -228,7 +228,7 @@ describe('delete and in', () => { expect(run('return "map" in list;', data)).toBe(true); expect(run('return "x" in (() => 1);')).toBe(false); expect(run('return "toString" in (() => 1);')).toBe(true); - expect(run('return "call" in (() => 1);')).toBe(false); + expect(run('return "call" in (() => 1);')).toBe(true); expect(run('return "__proto__" in obj;', data)).toBe(false); expect(run('return "__proto__" in own;', '{"own": {"__proto__": 1}}')).toBe(true); expect(sandboxError('return "a" in 5;')).toMatchObject({ diff --git a/packages/maple/src/spec-helpers.ts b/packages/maple/src/spec-helpers.ts index 22cd3d5..6feba85 100644 --- a/packages/maple/src/spec-helpers.ts +++ b/packages/maple/src/spec-helpers.ts @@ -21,3 +21,7 @@ export function runAndCatch(maple: Maple, code: string, data?: unknown): MapleEr } throw new Error('expected run to throw'); } + +export function errorMessage(maple: Maple, code: string): string | undefined { + return runAndCatch(maple, code).sandboxError?.message; +} diff --git a/packages/maple/src/values/arguments.ts b/packages/maple/src/values/arguments.ts index 7c8c195..f12dd1d 100644 --- a/packages/maple/src/values/arguments.ts +++ b/packages/maple/src/values/arguments.ts @@ -33,7 +33,7 @@ export function localesArgument(value: unknown): string | Array | undefi if (value === undefined) { return undefined; } - return Array.isArray(value) ? value.map(toString) : toString(value); + return Array.isArray(value) ? value.map((item) => toString(item)) : toString(value); } type IntlOptions = Intl.CollatorOptions & Intl.DateTimeFormatOptions & Intl.NumberFormatOptions; diff --git a/packages/maple/src/values/coercion.spec.ts b/packages/maple/src/values/coercion.spec.ts index ea56795..9957931 100644 --- a/packages/maple/src/values/coercion.spec.ts +++ b/packages/maple/src/values/coercion.spec.ts @@ -61,6 +61,28 @@ describe('coercion', () => { expect(() => toString([nested])).toThrow(UnplacedError); }); + + it('cuts a cycle through an array even where it runs through an error, as the host does', () => { + const error = new SandboxError('Error', 'm'); + error.name = [error]; + expect(toString(error)).toBe('m: m'); + + const list: Array = [1]; + const named = new SandboxError('Error', 'm'); + named.name = list; + list.push(named); + expect([toString(list), toString(named)]).toEqual(['1,m', '1,m: m']); + }); + + it('refuses a cycle between errors with a RangeError for the evaluator to place', () => { + const error = new SandboxError('Error', 'm'); + error.name = error; + + expect(() => toString(error)).toThrow( + expect.objectContaining({ name: 'RangeError', message: 'Maximum call stack size exceeded' }) + ); + expect(() => toString(error)).toThrow(UnplacedError); + }); }); describe('describeValue', () => { @@ -81,6 +103,10 @@ describe('coercion', () => { expect(describeValue(Object.create(null))).toBe('[object Object]'); expect(describeValue(nested)).toBe('[object Array]'); expect(describeValue(error)).toBe('[object Error]'); + + const cyclic = new SandboxError('Error', 'm'); + cyclic.message = cyclic; + expect(describeValue(cyclic)).toBe('[object Error]'); }); }); diff --git a/packages/maple/src/values/coercion.ts b/packages/maple/src/values/coercion.ts index e386119..6fd2864 100644 --- a/packages/maple/src/values/coercion.ts +++ b/packages/maple/src/values/coercion.ts @@ -21,11 +21,61 @@ export const ARGUMENTS_PLACEHOLDER = '[object Arguments]'; /** The ToPrimitive hint; only a Date converts differently for `number` than for the others. */ export type PrimitiveHint = 'default' | 'number' | 'string'; +/** + * What one conversion is in the middle of, handed down to the conversions nested in it: the + * arrays it is joining, so a cycle through one is cut as JavaScript cuts it, and how deep it + * converts errors inside one another. A conversion started from outside makes a new one. + */ +export class Conversion { + readonly #joining = new Set>(); + + #errorDepth = 0; + + /** Joins `array` with `run`, or gives `''` for an array this conversion is already joining. */ + public join(array: ReadonlyArray, run: () => string): string { + if (this.#joining.has(array)) { + return ''; + } + if (this.#joining.size >= MAX_NESTING) { + throw new UnplacedError( + 'RangeError', + `An array nested deeper than ${MAX_NESTING} levels cannot be converted to a string` + ); + } + this.#joining.add(array); + try { + return run(); + } finally { + this.#joining.delete(array); + } + } + + /** + * Converts an error with `run`. Nothing cuts a cycle through an error's `name` or `message`, so + * one runs into the limit and fails as JavaScript fails when it runs out of stack. + */ + public convertError(run: () => T): T { + if (this.#errorDepth >= MAX_NESTING) { + throw new UnplacedError('RangeError', 'Maximum call stack size exceeded'); + } + this.#errorDepth++; + try { + return run(); + } finally { + this.#errorDepth--; + } + } +} + /** * ToPrimitive without `toString` / `valueOf` hooks: a plain object is always `[object Object]`, * an array always its joined elements, so a script cannot influence a conversion. */ -export function toPrimitive(value: unknown, hint: PrimitiveHint = 'default'): Primitive { +export function toPrimitive( + value: unknown, + hint: PrimitiveHint = 'default', + conversion?: Conversion +): Primitive { if ( value === undefined || value === null || @@ -42,10 +92,10 @@ export function toPrimitive(value: unknown, hint: PrimitiveHint = 'default'): Pr throw new Error(`A ${typeof value} reached the sandbox`); } if (value instanceof SandboxObject) { - return value.toPrimitive(hint); + return value.toPrimitive(hint, conversion); } if (Array.isArray(value)) { - return joinArray(value, new Set()); + return joinArray(value, conversion ?? new Conversion()); } if (isArgumentsObject(value)) { return ARGUMENTS_PLACEHOLDER; @@ -56,8 +106,8 @@ export function toPrimitive(value: unknown, hint: PrimitiveHint = 'default'): Pr return OBJECT_PLACEHOLDER; } -export function toString(value: unknown): string { - const primitive = toPrimitive(value, 'string'); +export function toString(value: unknown, conversion?: Conversion): string { + const primitive = toPrimitive(value, 'string', conversion); return typeof primitive === 'string' ? primitive : String(primitive); } @@ -86,6 +136,22 @@ 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. + */ +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 '#'; +} + export function toNumber(value: unknown): number { return Number(toPrimitive(value, 'number')); } @@ -166,31 +232,21 @@ function kindOf(value: unknown): string { /** `Array.prototype.join` with the hook-free element conversion; nested arrays join with commas. */ export function joinValues(array: ReadonlyArray, separator: string): string { - return joinArray(array, new Set(), separator); + return joinArray(array, new Conversion(), separator); } -/** Nullish elements are empty and a cycle is cut with an empty string, as the host does it. */ -function joinArray(array: ReadonlyArray, seen: Set, separator = ','): string { - if (seen.has(array)) { - return ''; - } - if (seen.size >= MAX_NESTING) { - throw new UnplacedError( - 'RangeError', - `An array nested deeper than ${MAX_NESTING} levels cannot be converted to a string` - ); - } - seen.add(array); - const parts: Array = []; - for (const element of array) { - if (element === null || element === undefined) { - parts.push(''); - } else if (Array.isArray(element)) { - parts.push(joinArray(element, seen)); - } else { - parts.push(toString(element)); +/** + * Nullish elements are empty, and an array the conversion is already joining is empty too, as the + * host does it, even when the cycle runs through an error's `name` or `message`. + */ +function joinArray(array: ReadonlyArray, conversion: Conversion, separator = ','): string { + return conversion.join(array, () => { + // By index, since a script can give its array an own `map` that would shadow the host's + const parts: Array = []; + for (let index = 0; index < array.length; index++) { + const element = array[index]; + parts.push(element === null || element === undefined ? '' : toString(element, conversion)); } - } - seen.delete(array); - return parts.join(separator); + return parts.join(separator); + }); } diff --git a/packages/maple/src/builtins/array/utils/guard-integrity-errors.ts b/packages/maple/src/values/guard-integrity-errors.ts similarity index 91% rename from packages/maple/src/builtins/array/utils/guard-integrity-errors.ts rename to packages/maple/src/values/guard-integrity-errors.ts index d1fd000..4ce68a9 100644 --- a/packages/maple/src/builtins/array/utils/guard-integrity-errors.ts +++ b/packages/maple/src/values/guard-integrity-errors.ts @@ -1,4 +1,4 @@ -import type { Fail } from '../../../values/members.ts'; +import type { Fail } from './members.ts'; /** * Runs a host write on an array that is no longer extensible, where the host refuses exactly what diff --git a/packages/maple/src/values/members.ts b/packages/maple/src/values/members.ts index 95e754f..227d6e6 100644 --- a/packages/maple/src/values/members.ts +++ b/packages/maple/src/values/members.ts @@ -1,7 +1,8 @@ import { ARRAY_MAX_LENGTH } from '../builtins/array/array.ts'; import { isPlainObject } from '../support/is-plain-object.ts'; import { isArgumentsObject } from './arguments-object.ts'; -import { toNumber, toString, typeOf } from './coercion.ts'; +import { describeObject, toNumber, toString, typeOf } from './coercion.ts'; +import { guardIntegrityErrors } from './guard-integrity-errors.ts'; import type { OwnProperties } from './own-properties.ts'; import type { RunState } from './run-state.ts'; import { SandboxObject } from './sandbox-object.ts'; @@ -120,7 +121,10 @@ export function setMember( return fail('RangeError', 'Invalid array length'); } charge(Math.max(0, length - target.length)); - target.length = length; + // Shrinking a sealed array deletes elements it may not delete + guardIntegrityErrors(fail, target, () => { + target.length = length; + }); return; } const index = toArrayIndex(key); @@ -174,7 +178,7 @@ export function deleteMember(target: unknown, key: string, context: MemberContex } if (!Reflect.deleteProperty(target, key)) { - return fail('TypeError', `Cannot delete property '${key}' of a read-only object`); + return fail('TypeError', `Cannot delete property '${key}' of ${describeObject(target)}`); } return true; } diff --git a/packages/maple/src/values/native.ts b/packages/maple/src/values/native.ts index 946e622..46636d4 100644 --- a/packages/maple/src/values/native.ts +++ b/packages/maple/src/values/native.ts @@ -13,8 +13,11 @@ import { * covers work proportional to the size of a value, such as copied elements. */ export interface CallContext extends MemberContext { - /** Calls a script function under the budget, for callbacks such as a replace function. */ - call(callee: unknown, args: ReadonlyArray): unknown; + /** + * Calls a function under the budget, for callbacks such as a replace function. `receiver` is + * `undefined` for a callback; `call`, `apply` and bound functions pass theirs. + */ + call(receiver: unknown, callee: unknown, args: ReadonlyArray): unknown; /** The script call stack at this point, formatted for `Error.stack`. */ stackTrace(): string; /** Calls a host function: arguments copied out, result copied in, exceptions made catchable. */ @@ -38,7 +41,7 @@ export type NativeImplementation = ( * function from `globals`. A built-in shows its `name` and `length` to the script; a host * function has an `undefined` length and shows no properties at all, since its name and arity * are the host's business. Built-in and host functions are shared between runs, so what a script - * adds to one is kept by the run. + * adds to one is kept by the run; a bound function belongs to the run that made it. */ export class NativeFunction extends SandboxObject { public readonly name: string; @@ -54,8 +57,13 @@ export class NativeFunction extends SandboxObject { protected readonly fixed: FixedProperties; - constructor(name: string, length: number | undefined, invoke: NativeImplementation) { - super(true); + constructor( + name: string, + length: number | undefined, + invoke: NativeImplementation, + isShared: boolean = true + ) { + super(isShared); this.name = name; this.length = length; this.invoke = invoke; diff --git a/packages/maple/src/values/sandbox-error.ts b/packages/maple/src/values/sandbox-error.ts index 9798326..77e3189 100644 --- a/packages/maple/src/values/sandbox-error.ts +++ b/packages/maple/src/values/sandbox-error.ts @@ -1,5 +1,5 @@ import type { Node } from 'acorn'; -import { type Primitive, toString } from './coercion.ts'; +import { Conversion, 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. */ @@ -63,18 +63,24 @@ export class SandboxError extends SandboxObject { /** * What JavaScript's `Error.prototype.toString` gives: an `undefined` name reads as `Error` and an * `undefined` message as empty, and an empty part leaves out the `: `. Converting an assigned - * value can throw, as it can in JavaScript. + * value can throw, as it can in JavaScript, and so can a `name` or `message` that leads back to + * the error. */ - public override toPrimitive(): Primitive { - const name = this.name === undefined ? 'Error' : toString(this.name); - const message = this.message === undefined ? '' : toString(this.message); - if (name === '') { - return message; - } - if (message === '') { - return name; - } - return `${name}: ${message}`; + public override toPrimitive( + _hint: PrimitiveHint, + 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); + if (name === '') { + return message; + } + if (message === '') { + return name; + } + return `${name}: ${message}`; + }); } /** The fixed part of the error as the host receives it; the boundary adds `properties`. */ diff --git a/packages/maple/src/values/sandbox-object.ts b/packages/maple/src/values/sandbox-object.ts index d90d99f..1ff8f19 100644 --- a/packages/maple/src/values/sandbox-object.ts +++ b/packages/maple/src/values/sandbox-object.ts @@ -1,4 +1,4 @@ -import type { Primitive, PrimitiveHint } from './coercion.ts'; +import { type Conversion, describeObject, type Primitive, type PrimitiveHint } from './coercion.ts'; import type { MemberContext, MethodLookup } from './members.ts'; import { type Attributes, @@ -16,7 +16,7 @@ export interface FixedProperty { get(target: T): unknown; /** Absent for a read-only property. */ set?(target: T, value: unknown): void; - /** Whether `delete` may remove it and `seal` has something to take. */ + /** Its `configurable` attribute in JavaScript, until `seal` or `freeze` turns it off. */ readonly configurable: boolean; } @@ -81,8 +81,11 @@ export abstract class SandboxObject { return this.isShared ? runState.getOwnPropertiesOf(this) : this.properties; } - /** ToPrimitive without hooks, so a script cannot influence a conversion: `[object Map]`. */ - public toPrimitive(_hint: PrimitiveHint): Primitive { + /** + * 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`. + */ + public toPrimitive(_hint: PrimitiveHint, _conversion?: Conversion): Primitive { return `[object ${this.tag}]`; } @@ -130,13 +133,25 @@ export abstract class SandboxObject { } } - /** A fixed property stays: deleting it changes nothing. */ + /** + * A fixed property stays: deleting it changes nothing, unless `seal` or `freeze` locked it, which + * makes the delete fail as in JavaScript. + */ public deleteProperty(key: string, { fail, runState }: MemberContext): boolean { const properties = this.getOwnProperties(runState); - const added = this.fixed.has(key) ? undefined : properties.getAdded(key); - if (added && !added.configurable) { - return fail('TypeError', `Cannot delete property '${key}' of a read-only object`); + const fixed = this.fixed.get(key); + + let locked: boolean; + if (fixed) { + locked = fixed.configurable && !fixedAttributes(key, fixed, properties).configurable; + } else { + locked = properties.getAdded(key)?.configurable === false; } + + if (locked) { + return fail('TypeError', `Cannot delete property '${key}' of ${describeObject(this)}`); + } + properties.deleteAdded(key); return true; }