diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index c6fcbab03..eefacc876 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -1295,3 +1295,158 @@ function nearestKey(t) { return thisProperty.nearestKey(t); } if ('value' in Object.getOwnPropertyDescriptor(globalThis, k)) Object.defineProperty(globalThis, k, { writable: false, configurable: false }); }); })(); + +// --------------------------------------------------------------------------------------------- +// One runtime serves every expression on a thread, and user code runs as a script at global +// scope, so `linear = …` or a top-level `var clamp` would otherwise rebind a prelude name for +// every later expression, and for every later project in the session (#2133). The block above +// closes the `__` globals, the model constructors and the prelude's own prototypes; what is left +// is the rest of the global surface: the prelude's own helpers (`linear`, `clamp`, `wiggle`), the +// JavaScript built-ins that an expression and the prelude both call, and the prototypes those +// hang off. A non-writable, non-configurable binding makes such a write a silent no-op in sloppy +// mode, and `var` leaves an existing global property alone. A name the user introduces is not +// frozen, so an expression that declares its own functions still works. +(function () { + var g = globalThis; + // Every intrinsic this block needs, read once before the first prototype is rewritten. The + // block turns `Array.prototype.concat` and `forEach` into accessors as it goes, so reaching for + // them through a prototype part-way down the list would be reaching for something it has just + // changed under itself: the loops below index arrays directly for the same reason. + var describe = Object.getOwnPropertyDescriptor, define = Object.defineProperty; + var getNames = Object.getOwnPropertyNames, getSyms = Object.getOwnPropertySymbols; + var freeze = Object.freeze, isFrozen = Object.isFrozen, isExtensible = Object.isExtensible; + var protoOf = Object.getPrototypeOf; + + var lock = function (n) { + var d = describe(g, n); + if (!d || !d.writable) return; + define(g, n, { value: d.value, writable: false, enumerable: d.enumerable, configurable: false }); + }; + + // `name` and `message` on an error's prototype are the two names the repair below has to leave + // alone. The host renders a thrown error by reading both straight off the object, with no + // context to call a getter with, and it has no way to run one safely either: the time budget is + // checked between polls of the evaluation, and formatting happens after that returns, so a + // getter of the expression's own would run outside it — bounded by the loop and recursion limits + // at some hundreds of millions of iterations, which is a hang. Left as data properties they are + // frozen rather than repaired, so `e.name = 'MyError'` on an expression's own error is dropped + // in silence. That is the cost; an unbounded getter in the failure path is not worth paying to + // avoid it. + var HOST_READ = ['name', 'message']; + var hostRead = []; + var errorNames = ['Error', 'EvalError', 'RangeError', 'ReferenceError', 'SyntaxError', 'TypeError', + 'URIError', 'AggregateError']; + for (var e = 0; e < errorNames.length; e++) { + var C = g[errorNames[e]]; + if (typeof C === 'function') { + var cd = describe(C, 'prototype'); + if (cd && 'value' in cd) hostRead.push(cd.value); + } + } + var readByHost = function (p, n) { + for (var i = 0; i < HOST_READ.length; i++) { + if (n !== HOST_READ[i]) continue; + for (var j = 0; j < hostRead.length; j++) if (hostRead[j] === p) return true; + } + return false; + }; + + // Freezing a prototype costs an expression the right to shadow an inherited name on its own + // object: once the inherited data property is non-writable, `o.toString = …` is dropped in + // sloppy mode rather than creating an own property — the "override mistake". That is ordinary + // expression code: `counts[w]` with `w` of "constructor" or "valueOf" uses an object as a + // dictionary, and `a.map = …` gives one array its own version of a method. So every name that + // was shadowable before the freeze becomes an accessor whose setter writes an own property on + // the instance, which leaves the shadowing that worked before this block working after it. + var tolerate = function (p, n) { + var d = describe(p, n); + // An accessor has no value to stand in for, and a name already non-writable or + // non-configurable could not be shadowed before this ran either: no regression to repair. + if (!d || !d.configurable || !('value' in d) || !d.writable) return; + var v = d.value; + define(p, n, { + get: function () { return v; }, + set: function (nv) { + // This setter stands in for the data property it replaced, so it answers a write the way + // that property would: an accessor is handed the receiver and nothing else, while a data + // property has the receiver's own property consulted first + // (OrdinarySetWithOwnDescriptor). Doing that here keeps the repair invisible — an own + // writable property is updated in place and keeps its other attributes, an own + // non-writable one drops the write rather than being overwritten, and a target that + // cannot take a new property is left alone instead of being handed to `defineProperty`, + // which would throw. That last case covers a frozen prototype, an object the expression + // froze itself, and `null` or `undefined`. + if (this === p || this === null || this === undefined) return; + var own = describe(this, n); + if (own) { + if (own.writable) define(this, n, { value: nv }); + return; + } + if (!isExtensible(this)) return; + define(this, n, { value: nv, writable: true, enumerable: true, configurable: true }); + }, + enumerable: d.enumerable, + configurable: false, + }); + }; + + var freezeProto = function (p) { + if (p === null || (typeof p !== 'object' && typeof p !== 'function') || isFrozen(p)) return; + var names = getNames(p); + for (var i = 0; i < names.length; i++) if (!readByHost(p, names[i])) tolerate(p, names[i]); + // Symbol keys go the same way: `a[Symbol.iterator] = …` on an expression's own array is an + // ordinary way to make something iterable, and left frozen it is dropped in silence while + // `for…of` quietly walks the array's own elements instead. + var syms = getSyms(p); + for (var j = 0; j < syms.length; j++) tolerate(p, syms[j]); + freeze(p); + }; + + // Freezing a function also makes its own `prototype` property non-writable, so `Comp.prototype = + // {}` is a no-op; freezing that prototype object closes the methods hanging off it. + var shut = function (o) { + var d = describe(o, 'prototype'); + if (d && 'value' in d) freezeProto(d.value); + freeze(o); + }; + + var globals = getNames(g); + for (var k = 0; k < globals.length; k++) { + var n = globals[k]; + var d = describe(g, n); + if (!d || !('value' in d) || typeof d.value !== 'function') continue; + // The function object as well as the binding: the prelude calls `Array.isArray` and + // `Object.setPrototypeOf` while it answers an expression, so a replacement for one of those + // would not merely shadow a name, it would change what a later expression computes. + shut(d.value); + lock(n); + } + // `Math.sin = …` goes the same way, and the prelude leans on both of these itself. They are the + // `prototype` of no function, so the loop above never reaches them, and they are the prototype + // of no object either: nothing inherits `sin` or `parse`, so there is no shadowing for the + // accessor repair to keep, and a plain freeze closes them. That matters on the hot path, where + // a repaired name costs an accessor call on every read and a per-frame expression reads + // `Math.*` more than anything else. The bindings are locked as well, so `Math = {}` cannot take + // them away from the next expression either. + freeze(Math); + freeze(JSON); + lock('Math'); + lock('JSON'); + // The iterator intrinsics are the `prototype` of no global function either, and `for…of`, + // destructuring and spread all run through them: replacing `next` on the array iterator left + // every later expression on the thread unable to iterate at all. + var walk = function (o) { + for (var p = o; p !== null && p !== undefined; p = protoOf(p)) freezeProto(p); + }; + var probes = [function () { return [][Symbol.iterator](); }, + function () { return ''[Symbol.iterator](); }, + function () { return new Map()[Symbol.iterator](); }, + function () { return new Set()[Symbol.iterator](); }, + function () { return (function* () {})(); }, + function () { return 'a'.matchAll(/a/g); }, + function () { return Int8Array.prototype; }]; + for (var m = 0; m < probes.length; m++) { + // Each one is a probe for an intrinsic this engine may not have at all. + try { walk(protoOf(probes[m]())); } catch (e) { /* not present */ } + } +})(); diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index fd5de44fa..48f656e52 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -193,6 +193,315 @@ fn functions_and_let_are_reusable_across_evaluations() { assert_close!(f.num(e, 2.0), 9.0); } +#[test] +fn an_expression_cannot_rebind_a_prelude_name_for_the_next_one() { + let f = fx(); + // #2133: one runtime serves every expression on a thread and user code runs at global scope, so + // a write to a prelude name used to outlive the expression that made it and poison every later + // one — in this session's other projects too. + assert_close!(f.num("linear = function () { return 777 };\n50", 0.0), 50.0); + assert_close!(f.num("linear(time, 0, 1, 0, 100)", 0.0), 0.0); + assert_close!(f.num("var clamp = 5;\n10", 0.0), 10.0); + assert_close!(f.num("clamp(50, 0, 100)", 0.0), 50.0); + assert_close!(f.num("Math.sin = function () { return 9 };\n1", 0.0), 1.0); + assert_close!(f.num("Math.sin(Math.PI / 2) * 100", 0.0), 100.0); + assert_close!(f.num("Object.prototype.leak = 9;\n2", 0.0), 2.0); + assert_close!(f.num("({}).leak === undefined ? 1 : 0", 0.0), 1.0); + // The runtime is one thread-local singleton, so a second project on this thread meets the same + // objects: the isolation has to hold between projects, not only between the evaluations of one. + let g = fx(); + assert_close!(g.num("linear(time, 0, 1, 0, 100)", 0.0), 0.0); + assert_close!(g.num("clamp(50, 0, 100)", 0.0), 50.0); + assert_close!(g.num("Math.sin(Math.PI / 2) * 100", 0.0), 100.0); + assert_close!(g.num("({}).leak === undefined ? 1 : 0", 0.0), 1.0); +} + +/// The four ways a script can reach a global binding, against a prelude *function* rather than one +/// of the unqualified accessors: assignment, `var`, `delete` and `defineProperty`. The lock is +/// `writable: false, configurable: false`, and it is the second half of that pair that answers the +/// last two — a `delete` of a non-configurable property is false, and a `defineProperty` over one +/// is a `TypeError`. Asked for by a reviewer on #2176. +#[test] +fn a_prelude_function_survives_every_way_a_script_can_reach_its_binding() { + let f = fx(); + // Assignment and `var`: silent, and the next expression still has the original. + assert_close!(f.num("clamp = function () { return 777 };\n1", 0.0), 1.0); + assert_close!(f.num("var clamp = 5;\n2", 0.0), 2.0); + assert_close!(f.num("clamp(50, 0, 100)", 0.0), 50.0); + // `delete` of a non-configurable binding answers false in sloppy mode rather than throwing. + assert_close!(f.num("delete clamp ? 1 : 0", 0.0), 0.0); + assert_close!(f.num("clamp(50, 0, 100)", 0.0), 50.0); + assert_close!(f.num("typeof clamp === 'function' ? 1 : 0", 0.0), 1.0); + // `defineProperty` over one is a `TypeError`, so this is the one route the user does see. The + // expression fails; what it was reaching for is unchanged for the next one. + assert!(f.err("Object.defineProperty(globalThis, 'clamp', { value: 5 })").contains("TypeError")); + assert_close!(f.num("clamp(50, 0, 100)", 0.0), 50.0); + // Reconfiguring it to writable is refused the same way, so the lock cannot be reopened. + assert!(f.err("Object.defineProperty(globalThis, 'clamp', { writable: true })").contains("TypeError")); + assert_close!(f.num("clamp = 7;\n3", 0.0), 3.0); + assert_close!(f.num("clamp(50, 0, 100)", 0.0), 50.0); + // A second project on the same thread-local runtime meets the same bindings. + let g = fx(); + assert_close!(g.num("clamp(50, 0, 100)", 0.0), 50.0); + assert_close!(g.num("typeof clamp === 'function' ? 1 : 0", 0.0), 1.0); +} + +/// Which mode a write is silent in decides what the user sees, and the answer is that an expression +/// cannot reach strict mode at all: `Runtime::script` wraps the text in `try{…}catch(__e){throw __e}`, +/// so a `"use strict"` the user writes is an expression statement inside a block rather than a +/// directive prologue, and has no effect. A failed write is therefore always the silent kind. +/// Asked for by a reviewer on #2176. +#[test] +fn a_use_strict_in_an_expression_is_inert_so_a_refused_write_stays_silent() { + let f = fx(); + assert_close!(f.num("\"use strict\";\nlinear = function () { return 777 };\n50", 0.0), 50.0); + assert_close!(f.num("linear(time, 0, 1, 0, 100)", 0.0), 0.0); + // The directive is inert rather than honoured: in strict mode an assignment to an undeclared + // name is a ReferenceError, and here it quietly creates a global instead. + assert_close!(f.num("\"use strict\";\nundeclared_global = 5;\nundeclared_global", 0.0), 5.0); + // Which is also why the refused write above cannot be made to report itself by the user: the + // only mode an expression runs in is the one where it is dropped. + assert_close!(f.num("\"use strict\";\ntypeof linear === 'function' ? 1 : 0", 0.0), 1.0); +} + +/// The same leak, reached through the objects instead of the names: a prelude constructor keeps its +/// methods on its `prototype`, the prelude itself calls `Array.isArray` and `Object.setPrototypeOf` +/// while it answers an expression, and `thisProject` is reachable surface that holds no function at +/// all (#2133). +#[test] +fn an_expression_cannot_tamper_with_a_prelude_object_for_the_next_one() { + let f = fx(); + // An unqualified name is an accessor, so it takes a `defineProperty` or a `delete` rather than + // an assignment; the setter an assignment goes through writes per-evaluation state. + assert_close!(f.num("try { Object.defineProperty(globalThis, 'width', { value: 777 }) } catch (e) {}\n1", 0.0), 1.0); + assert_close!(f.num("delete width;\n2", 0.0), 2.0); + assert_close!(f.num("width", 0.0), 20.0); + assert_close!(f.num("Comp.prototype = {};\n3", 0.0), 3.0); + assert_close!(f.num("Comp.prototype.leak = 9;\n4", 0.0), 4.0); + assert_close!(f.num("thisComp.width", 0.0), 200.0); + // `add` reads its arguments through `Array.isArray`, and reaching an effect builds a group with + // `Object.setPrototypeOf`, so a replacement for either would change the answers below. + assert_close!(f.num("Array.isArray = function () { return false };\n5", 0.0), 5.0); + assert_close!(f.num("Object.setPrototypeOf = function (o) { return o };\n6", 0.0), 6.0); + assert_close!(f.num("add([1, 2], [3, 4])[0]", 0.0), 4.0); + assert_close!(f.num("effect(\"Slider Control\")(\"Slider\")", 0.0), 42.0); + assert_close!(f.num("thisProject = 5;\nthisProject.leak = 9;\n7", 0.0), 7.0); + assert_close!(f.num("typeof thisProject === 'object' && thisProject.leak === undefined ? 1 : 0", 0.0), 1.0); +} + +/// `Math` and `JSON` are namespaces rather than prototypes: nothing inherits from them, so the +/// prelude freezes them plainly instead of turning every method into a shadowable accessor, which +/// would put a getter call on every `Math.sin` of a per-frame expression for a shadowing no object +/// can do. They still cannot be rewritten, through a member or through the binding (#2176). +#[test] +fn math_and_json_are_frozen_and_cannot_be_replaced_for_the_next_expression() { + let f = fx(); + assert_close!(f.num("Object.isFrozen(Math) && Object.isFrozen(JSON) ? 1 : 0", 0.0), 1.0); + // Plain data properties, not the accessor repair: the hot path reads the native method directly. + assert_close!(f.num("'value' in Object.getOwnPropertyDescriptor(Math, 'sin') ? 1 : 0", 0.0), 1.0); + assert_close!(f.num("'value' in Object.getOwnPropertyDescriptor(JSON, 'stringify') ? 1 : 0", 0.0), 1.0); + // A member write is dropped in silence, and the next expression still has the original. + assert_close!(f.num("Math.sin = function () { return 9 }; Math.max = function () { return -1 };\n1", 0.0), 1.0); + assert_close!(f.num("Math.sin(Math.PI / 2) * 100 + Math.max(3, 7)", 0.0), 107.0); + assert_close!(f.num("JSON.stringify = function () { return 'x' }; JSON.parse = null;\n2", 0.0), 2.0); + assert_close!(f.num("JSON.stringify(JSON.parse('[1,2]')) === '[1,2]' ? 1 : 0", 0.0), 1.0); + assert_close!(f.num("Math.leak = 5; JSON.leak = 6;\n3", 0.0), 3.0); + assert_close!(f.num("Math.leak === undefined && JSON.leak === undefined ? 1 : 0", 0.0), 1.0); + assert_close!(f.num("delete Math.floor ? 1 : 0", 0.0), 0.0); + assert!(f.err("Object.defineProperty(Math, 'floor', { value: 5 })").contains("TypeError")); + // The bindings are locked too: assignment, `var` and `delete` leave both in place. + assert_close!(f.num("Math = { sin: function () { return 9 } }; JSON = null;\n4", 0.0), 4.0); + assert_close!(f.num("var Math = 5; var JSON = 6;\n5", 0.0), 5.0); + assert_close!(f.num("delete Math || delete JSON ? 1 : 0", 0.0), 0.0); + assert_close!(f.num("Math.floor(2.7) + JSON.parse('3')", 0.0), 5.0); + // A second project on the same thread-local runtime meets the same objects. + let g = fx(); + assert_close!(g.num("Math.sin(Math.PI / 2) * 100", 0.0), 100.0); + assert_close!(g.num("JSON.stringify([1]) === '[1]' ? 1 : 0", 0.0), 1.0); +} + +/// Closing the prototypes must not cost an expression its own objects: once an inherited data +/// property is non-writable, an assignment meaning to shadow it on an instance is dropped in sloppy +/// mode rather than creating an own property, and using an object as a dictionary runs into exactly +/// those names (#2133). +#[test] +fn an_expression_can_shadow_an_inherited_name_on_its_own_object() { + let f = fx(); + assert_close!(f.num("var o = {}; o.toString = function () { return 'x' }; o.toString() === 'x' ? 1 : 0", 0.0), 1.0); + assert_close!(f.num("var d = {}; d.constructor = 3; d.constructor", 0.0), 3.0); + assert_close!(f.num("var c = {}; c['valueOf'] = 2; c['valueOf']", 0.0), 2.0); + // The shared prototype itself still cannot be written through, which is the point of freezing. + assert_close!(f.num("Object.prototype.toString = function () { return 'z' };\n1", 0.0), 1.0); + assert_close!(f.num("({}).toString() === 'z' ? 0 : 1", 0.0), 1.0); +} + +/// The names an expression shadows are not only `Object.prototype`'s: giving one array its own +/// `map` or one function its own `call` is ordinary JavaScript, and a prototype frozen without the +/// repair takes it away. The two halves fail differently, which is why both are checked here — +/// `map`, `forEach` and `filter` throw, while `push` and `join` silently run the native method and +/// return its answer, so an expression using them keeps working and quietly computes something +/// else (#2133). +#[test] +fn an_expression_can_shadow_an_inherited_method_on_its_own_array_or_function() { + let f = fx(); + assert_close!(f.num("var a = []; a.map = function () { return 42 }; a.map()", 0.0), 42.0); + assert_close!(f.num("var a = [1, 2]; a.forEach = function () { return 5 }; a.forEach()", 0.0), 5.0); + assert_close!(f.num("var a = [1, 2]; a.filter = function () { return 6 }; a.filter()", 0.0), 6.0); + // `push` returned the native length and `join` the native string before the repair. + assert_close!(f.num("var a = [1]; a.push = function () { return 99 }; a.push(2)", 0.0), 99.0); + assert_close!(f.num("var a = [1]; a.push = function () { return 99 }; a.push(2); a.length", 0.0), 1.0); + assert_close!(f.num("var a = [1]; a.join = function () { return 'J' }; a.join() === 'J' ? 1 : 0", 0.0), 1.0); + assert_close!(f.num("var fn = function () {}; fn.call = function () { return 7 }; fn.call()", 0.0), 7.0); + assert_close!(f.num("var fn = function () {}; fn.apply = function () { return 8 }; fn.apply()", 0.0), 8.0); + // A symbol key on a prototype the loop reaches goes the same way. + assert_close!(f.num("var a = [1]; a[Symbol.toPrimitive] = function () { return 11 }; +a", 0.0), 11.0); + // Reading an unshadowed method still answers with the native one, through the repair. + assert_close!(f.num("[1, 2, 3].map(function (x) { return x * 2 })[2]", 0.0), 6.0); + assert_close!(f.num("[1, 2, 3].filter(function (x) { return x > 1 }).length", 0.0), 2.0); + assert_close!(f.num("[3, 1, 2].sort()[0]", 0.0), 1.0); + assert_close!(f.num("[1, 2].concat([3]).length", 0.0), 3.0); + // The shared prototype itself still cannot be written through, which is the point of freezing. + assert_close!(f.num("Array.prototype.map = function () { return 0 };\n1", 0.0), 1.0); + assert_close!(f.num("[1].map(function (x) { return x + 1 })[0]", 0.0), 2.0); + assert_close!(f.num("Function.prototype.call = function () { return 0 };\n1", 0.0), 1.0); + assert_close!(f.num("(function () { return 4 }).call(null)", 0.0), 4.0); +} + +/// `name` and `message` on an error's prototype are the one exception to the repair, because the +/// host reads both off a thrown object to render it and has no context to call a getter with, nor +/// anywhere safe to call one: formatting happens after the evaluation's time budget stops being +/// checked. What matters is that the message still names the error and the line, and that the +/// assignment the exception costs is dropped in silence rather than failing the expression (#2133). +#[test] +fn an_error_still_renders_with_its_name_and_the_users_line() { + let f = fx(); + // The write the exception costs: a no-op, not a throw, and the next statement still runs. + assert_close!(f.num("var e = new Error('x'); e.name = 'MyError'; 1", 0.0), 1.0); + assert_close!(f.num("var e = new Error('x'); e.name === 'Error' ? 1 : 0", 0.0), 1.0); + // An own `message` is the instance's, so it is untouched by any of this. + assert_close!(f.num("new Error('x').message === 'x' ? 1 : 0", 0.0), 1.0); + // The prototype itself still cannot be rebound for the next expression, which is the point. + assert_close!(f.num("Error.prototype.name = 'Hacked';\n1", 0.0), 1.0); + assert_close!(f.num("new Error('x').name === 'Error' ? 1 : 0", 0.0), 1.0); + // A getter the expression installs on its own error is reached through its own property, so it + // never goes near the host's read of the prototype. + assert_close!(f.num("var e = new Error('x'); Object.defineProperty(e, 'name', { get: function () { return 'Own' } }); e.name === 'Own' ? 1 : 0", 0.0), 1.0); +} + +/// The repair must not turn a dropped assignment into a failed expression: in sloppy mode a write +/// to a frozen target is ignored, and the setter runs for every receiver that inherits the name, +/// including the other frozen prototypes and an object the expression froze itself. +#[test] +fn assigning_a_repaired_name_on_a_frozen_object_is_dropped_not_an_error() { + let f = fx(); + // `hasOwnProperty` is `Object.prototype`'s, so this runs the repaired setter with a frozen + // `Array.prototype` as the receiver. + assert_close!(f.num("Array.prototype.hasOwnProperty = function () { return true };\n1", 0.0), 1.0); + assert_close!(f.num("[].hasOwnProperty('length') ? 1 : 0", 0.0), 1.0); + // The prelude freezes these, and each inherits the repaired names. + assert_close!(f.num("Math.toString = function () { return 'm' };\n2", 0.0), 2.0); + assert_close!(f.num("JSON.valueOf = 3;\n3", 0.0), 3.0); + assert_close!(f.num("thisProject.toLocaleString = 4;\n4", 0.0), 4.0); + // An object the expression froze itself behaves as it does outside the prelude. + assert_close!(f.num("var o = {}; Object.freeze(o); o.toString = function () { return 'x' };\n5", 0.0), 5.0); + assert_close!(f.num("var s = {}; Object.seal(s); s.constructor = 6;\n6", 0.0), 6.0); + // Still a no-op where it has to be, and still working where it has to work. + assert_close!(f.num("({}).toString() === '[object Object]' ? 1 : 0", 0.0), 1.0); + assert_close!(f.num("var k = {}; k.toString = function () { return 'y' }; k.toString() === 'y' ? 1 : 0", 0.0), 1.0); + // A primitive target boxes into a throwaway wrapper, and `null` is not extensible, which is why + // the receiver check alone is enough. + assert_close!(f.num("'abc'.toString = function () { return 'q' };\n7", 0.0), 7.0); + assert_close!(f.num("(5).constructor = 8;\n8", 0.0), 8.0); + assert_close!(f.num("Object.isExtensible(null) === false && Object.isExtensible(undefined) === false ? 1 : 0", 0.0), 1.0); +} + +/// The repaired name is an accessor standing in for a data property, and an accessor is handed the +/// receiver while a data property has the receiver's own property consulted first, so the setter +/// has to do that itself or the substitution shows through. An ordinary assignment finds the own +/// property and never reaches the setter, so these call it with the receiver directly, which is +/// what `Reflect.set(Object.prototype, name, v, o)` would do; `Reflect` is not available to +/// expressions. Every expected value is what that `Reflect.set` answers in node, where the name +/// is still the data property this one stands in for. +#[test] +fn the_repair_answers_a_write_as_the_data_property_it_replaced_would() { + let f = fx(); + let set = "var put = Object.getOwnPropertyDescriptor(Object.prototype, 'toString').set;"; + // An own writable property is updated in place, even when the receiver takes no new ones. + assert_close!(f.num(&format!("{set} var o = {{ toString: 1 }}; Object.preventExtensions(o); put.call(o, 2); o.toString"), 0.0), 2.0); + assert_close!(f.num(&format!("{set} var o = {{ toString: 1 }}; put.call(o, 2); o.toString"), 0.0), 2.0); + // ... and keeps the attributes it had, rather than being redefined as a fresh enumerable one. + assert_close!( + f.num( + &format!( + "{set} var o = {{}}; Object.defineProperty(o, 'toString', {{ value: 1, writable: true, enumerable: false, configurable: true }}); + put.call(o, 2); Object.getOwnPropertyDescriptor(o, 'toString').enumerable ? 1 : 0" + ), + 0.0 + ), + 0.0 + ); + // An own non-writable property drops the write instead of being overwritten. + assert_close!( + f.num( + &format!( + "{set} var o = {{}}; Object.defineProperty(o, 'toString', {{ value: 1, writable: false, enumerable: false, configurable: true }}); + put.call(o, 2); o.toString" + ), + 0.0 + ), + 1.0 + ); + // Writing through the prototype itself changes nothing for anyone. + assert_close!(f.num(&format!("{set} put.call(Object.prototype, 2); ({{}}).toString === undefined ? 0 : 1"), 0.0), 1.0); + // An ordinary assignment finds the own property first and never reaches the setter at all. + assert_close!(f.num("var o = { toString: 1 }; o.toString = 2; o.toString", 0.0), 2.0); +} + +/// `for…of`, destructuring and spread all go through the iterator intrinsics, and those are the +/// `prototype` of no global function, so freezing what the globals reach never touched them (#2133). +#[test] +fn an_expression_cannot_poison_iteration_for_the_next_one() { + let f = fx(); + assert_close!(f.num("Object.getPrototypeOf([][Symbol.iterator]()).next = function () { return { done: true } };\n1", 0.0), 1.0); + assert_close!(f.num("var s = 0; for (var x of [1, 2, 3]) s += x; s", 0.0), 6.0); + assert_close!(f.num("var [a, b] = [4, 5]; a + b", 0.0), 9.0); + assert_close!(f.num("Math.max(...[1, 7, 3])", 0.0), 7.0); +} + +/// Shadowing an inherited name on an instance has to keep working for symbol keys too: making an +/// object iterable by assigning `Symbol.iterator` is ordinary JavaScript, and a frozen +/// `Array.prototype` drops that write in silence, so `for…of` walks the array's own elements and +/// quietly returns a different number rather than failing (#2133). +#[test] +fn an_expression_can_give_its_own_array_an_iterator() { + let f = fx(); + assert_close!( + f.num( + "var a = [1, 2]; + a[Symbol.iterator] = function () { var d = false; return { next: function () { if (d) return { done: true }; d = true; return { value: 9, done: false } } } }; + var s = 0; for (var x of a) s += x; s", + 0.0 + ), + 9.0 + ); + assert_close!(f.num("var a = [1, 2]; a[Symbol.iterator] = 1; a[Symbol.iterator] === 1 ? 1 : 0", 0.0), 1.0); + // `Object.prototype` carries no symbol keys, so a plain object was never affected. + assert_close!(f.num("var o = {}; o[Symbol.iterator] = 1; o[Symbol.iterator] === 1 ? 1 : 0", 0.0), 1.0); + // An inherited name that is already non-writable is left alone, as it is outside the prelude: + // `Date.prototype[Symbol.toPrimitive]` drops the write on `main` and in node as well. + assert_close!(f.num("var d = new Date(0); d[Symbol.toPrimitive] = 1; d[Symbol.toPrimitive] === 1 ? 1 : 0", 0.0), 0.0); + // Repairing the key must not reopen it: the shared prototype still cannot be written through. + assert_close!( + f.num( + "Array.prototype[Symbol.iterator] = function () { return { next: function () { return { done: true } } } }; +1", + 0.0 + ), + 1.0 + ); + assert_close!(f.num("var s = 0; for (var x of [1, 2, 3]) s += x; s", 0.0), 6.0); +} + #[test] fn comments_are_ignored() { let f = fx(); @@ -1672,3 +1981,47 @@ fn an_expression_cant_change_the_prelude_for_the_next() { } mod property_group; + +/// Timing harness for the prelude's cost on the evaluation path. Ignored: it is a measurement, not +/// an assertion. `cargo test -p effectcraft-expr --release --lib bench_evaluation -- --ignored +/// --nocapture`. +#[test] +#[ignore = "timing measurement, not a test"] +fn bench_evaluation() { + let f = fx(); + // Each case runs on a property whose dimension its answer fits: a scalar handed to `position` + // is rejected after the script has already run, so the case would be timing an error path. + // Ordinary expressions first, then three that read an inherited name in a loop: the repair + // turns each of those reads into an accessor call, and `Array.prototype.map` is the one an + // expression is most likely to reach for in quantity. + let cases: [(&str, &str, &str, u32); 10] = [ + ("array maths", POS, "value + [10, 0]", 2500), + ("scalar maths", ROT, "time * 100 + 5", 3000), + ("prelude call", ROT, "linear(time, 0, 1, 0, 100)", 2500), + ("cross-layer read", ROT, "thisComp.layer(\"B\").transform.position[0]", 200), + ("effect read", ROT, "effect(\"Slider Control\")(\"Slider\")", 150), + ("wiggle", POS, "wiggle(2, 10)", 800), + ("dictionary", ROT, "var c = {}; var k = 'constructor'; c[k] = 1; c[k] + value", 2500), + ("toString x100", ROT, "var o = {}; var n = 0; for (var i = 0; i < 100; i++) { if (o.toString) n++; } n", 250), + ("hasOwnProperty x100", ROT, "var o = {}; var n = 0; for (var i = 0; i < 100; i++) { if (o.hasOwnProperty) n++; } n", 250), + ("array method x100", ROT, "var a = [1]; var n = 0; for (var i = 0; i < 100; i++) { if (a.map) n++; } n", 250), + ]; + for (label, path, text, iters) in cases { + let mut best = u128::MAX; + // Three passes, keeping the fastest: a slower pass is scheduling noise, not the prelude. + for _ in 0..3 { + // The warm-up checks as it goes, at every time the measured loop uses: that loop drops + // its answer by design, so a case that had started failing would be timed on its error + // path and still print a number. + for i in 0..50 { + assert!(f.ev(f.a, path, text, f64::from(i % 10) * 0.1).is_ok(), "{label}"); + } + let start = std::time::Instant::now(); + for i in 0..iters { + let _ = f.ev(f.a, path, text, f64::from(i % 10) * 0.1); + } + best = best.min(start.elapsed().as_nanos() / u128::from(iters)); + } + println!("BENCH {best:>9} ns/eval {label} (min of 3 x {iters})"); + } +}