diff --git a/NativeScript/runtime/BuiltinLoader.cpp b/NativeScript/runtime/BuiltinLoader.cpp index d004db84..10913f55 100644 --- a/NativeScript/runtime/BuiltinLoader.cpp +++ b/NativeScript/runtime/BuiltinLoader.cpp @@ -3,6 +3,7 @@ #include #include +#include "Caches.h" #include "Helpers.h" using namespace v8; @@ -17,12 +18,14 @@ std::vector builtinCache[static_cast(BuiltinId::kCount)]; // Every builtin is compiled as a function body receiving these fixed // parameters, mirroring Node's module wrapper: a file exports through -// `module.exports`/`exports`, and natives arrive as properties of the -// `binding` bag (Node's internalBinding idiom) for each file to destructure. +// `module.exports`/`exports`, natives arrive as properties of the `binding` +// bag (Node's internalBinding idiom) and intrinsics as properties of +// `primordials`; each file destructures what it needs. constexpr const char* kExportsParamName = "exports"; constexpr const char* kModuleParamName = "module"; constexpr const char* kBindingParamName = "binding"; -constexpr int kParamCount = 3; +constexpr const char* kPrimordialsParamName = "primordials"; +constexpr int kParamCount = 4; MaybeLocal CompileBuiltin(Local context, BuiltinId id) { Isolate* isolate = v8::Isolate::GetCurrent(); @@ -49,9 +52,11 @@ MaybeLocal CompileBuiltin(Local context, BuiltinId id) { ); Local sourceText = tns::ToV8String( isolate, builtin.source, static_cast(builtin.length)); - Local params[] = {tns::ToV8String(isolate, kExportsParamName), - tns::ToV8String(isolate, kModuleParamName), - tns::ToV8String(isolate, kBindingParamName)}; + Local params[] = { + tns::ToV8String(isolate, kExportsParamName), + tns::ToV8String(isolate, kModuleParamName), + tns::ToV8String(isolate, kBindingParamName), + tns::ToV8String(isolate, kPrimordialsParamName)}; Local fn; if (!blob.empty()) { @@ -91,11 +96,8 @@ MaybeLocal CompileBuiltin(Local context, BuiltinId id) { return fn; } -} // namespace - -MaybeLocal BuiltinLoader::RunBuiltin(Local context, - BuiltinId id, - Local binding) { +MaybeLocal CallBuiltin(Local context, BuiltinId id, + Local binding, Local primordials) { Isolate* isolate = v8::Isolate::GetCurrent(); Local fn; @@ -112,7 +114,8 @@ MaybeLocal BuiltinLoader::RunBuiltin(Local context, Local args[] = { exportsObj, moduleObj, - binding.IsEmpty() ? v8::Undefined(isolate).As() : binding}; + binding.IsEmpty() ? v8::Undefined(isolate).As() : binding, + primordials}; if (fn->Call(context, v8::Undefined(isolate), kParamCount, args).IsEmpty()) { return MaybeLocal(); } @@ -120,4 +123,42 @@ MaybeLocal BuiltinLoader::RunBuiltin(Local context, return moduleObj->Get(context, exportsKey); } +// Snapshot of the intrinsics, taken the first time any builtin runs in this +// isolate — during runtime init, before user code can replace a global. Later +// builtins (smart-stringify compiles lazily, on the first object logged) get +// the same pristine snapshot. +MaybeLocal GetPrimordials(Local context) { + Isolate* isolate = v8::Isolate::GetCurrent(); + std::shared_ptr cache = Caches::Get(isolate); + if (cache->Primordials != nullptr) { + return cache->Primordials->Get(isolate); + } + + Local result; + if (!CallBuiltin(context, BuiltinId::kPrimordials, Local(), + v8::Undefined(isolate)) + .ToLocal(&result) || + !result->IsObject()) { + return MaybeLocal(); + } + + Local primordials = result.As(); + cache->Primordials = + std::make_unique>(isolate, primordials); + return primordials; +} + +} // namespace + +MaybeLocal BuiltinLoader::RunBuiltin(Local context, + BuiltinId id, + Local binding) { + Local primordials; + if (!GetPrimordials(context).ToLocal(&primordials)) { + return MaybeLocal(); + } + + return CallBuiltin(context, id, binding, primordials); +} + } // namespace tns diff --git a/NativeScript/runtime/BuiltinLoader.h b/NativeScript/runtime/BuiltinLoader.h index 453c3fa7..56fb7505 100644 --- a/NativeScript/runtime/BuiltinLoader.h +++ b/NativeScript/runtime/BuiltinLoader.h @@ -9,10 +9,13 @@ namespace tns { class BuiltinLoader { public: // Compiles the builtin identified by id as a function body with the fixed - // parameters `exports`, `module` and `binding` (Node's module wrapper plus - // its internalBinding idiom), calls it with the given bag of natives (or - // undefined when omitted), and returns the resulting `module.exports`. - // Scripts carry an "internal/.js" origin so runtime + // parameters `exports`, `module`, `binding` (Node's module wrapper plus its + // internalBinding idiom) and `primordials`, calls it with the given bag of + // natives (or undefined when omitted) plus this isolate's frozen intrinsics + // snapshot, and returns the resulting `module.exports`. The snapshot is + // produced by the kPrimordials builtin on first use and cached per isolate, + // so it is taken before any user code can replace a global. Scripts carry + // an "internal/.js" origin so runtime // frames are identifiable in stack traces. Compilation goes through a // process-wide bytecode cache: the first run in the process compiles // eagerly and populates the cache, later isolates (workers) consume it diff --git a/NativeScript/runtime/Caches.h b/NativeScript/runtime/Caches.h index 40391fcf..25852103 100644 --- a/NativeScript/runtime/Caches.h +++ b/NativeScript/runtime/Caches.h @@ -163,6 +163,12 @@ class Caches { std::unique_ptr> UnmanagedTypeCtorFunc = std::unique_ptr>(nullptr); + // Frozen intrinsics snapshot returned by internal/primordials.js, passed to + // every builtin as its second fixed parameter (BuiltinLoader::RunBuiltin). + // Per isolate, so workers snapshot their own realm's intrinsics. + std::unique_ptr> Primordials = + std::unique_ptr>(nullptr); + // Internal EventTarget instance backing the global, returned by the generic // event-primitives bootstrap IIFE (Events::Init). Holds the real listener // store, so native layers dispatch through it without going through diff --git a/NativeScript/runtime/js/README.md b/NativeScript/runtime/js/README.md index 0df07307..da14bb60 100644 --- a/NativeScript/runtime/js/README.md +++ b/NativeScript/runtime/js/README.md @@ -9,16 +9,19 @@ at runtime `BuiltinLoader::RunBuiltin` compiles and executes them with an ## Contract (Node's module wrapper + internalBinding idiom) Every file is compiled as a **function body** via `v8::ScriptCompiler::CompileFunction` -with the fixed parameters `exports`, `module` and `binding`: +with the fixed parameters `exports`, `module`, `binding` and `primordials`: ```js const { someNative, anotherNative } = binding; +const { ArrayPrototypeSlice, ObjectCreate } = primordials; module.exports = somethingTheCallSiteNeeds; ``` - `binding` is a plain object of natives built by the C++ call site; a file that needs nothing from C++ simply doesn't mention it. +- `primordials` is the frozen intrinsics snapshot built by `primordials.js` + (see below), the same object for every builtin in an isolate. - **`module.exports` is the export channel** — whatever it holds when the file finishes is what `RunBuiltin` hands back to C++ (used for factory functions and init results). Both CommonJS styles work: replace the whole export with @@ -28,20 +31,52 @@ module.exports = somethingTheCallSiteNeeds; every tool that isn't reading this repo's ESLint config (editors' TS server, prettier, review bots) rejects the file as invalid JavaScript. - Strict mode is per-file: start the file with `"use strict";` to opt in. -- Destructure `binding` once, at the top of the file, so the file's native - dependencies are visible and greppable. +- Destructure `binding` and `primordials` once, at the top of the file, so the + file's dependencies are visible and greppable. ## Rules - Run at isolate init, before any user code: capture any global you rely on (e.g. `globalThis.Event`) eagerly so later monkey-patching can't break you. - (Planned: a frozen `primordials` namespace as a second fixed parameter, - Node-style, to make this systematic for closures that outlive init.) + For intrinsics that is what `primordials` is; for everything else + (`URLSearchParams`, …) capture it into a file-level `const`. - No `import`/`export` — these are classic function bodies, not modules. - ESLint (`eslint.config.mjs` at the repo root, run by lint-staged) declares - `exports`, `module`, `binding` and the reachable native globals; `no-undef` - is the typo net. If a builtin starts using a new native global, add it there. + `exports`, `module`, `binding`, `primordials` and the reachable native + globals; `no-undef` is the typo net. If a builtin starts using a new native + global, add it there. `no-restricted-properties` fails the lint on direct use + of the captured statics (`JSON.stringify`, `Object.defineProperty`, …). + Uncurried instance methods can't be matched that way, so `list.slice()` + instead of `ArrayPrototypeSlice(list)` is caught by review, not by the + linter. - File names are kebab-case; the name determines the `BuiltinId` enum value (`promise-proxy.js` → `kPromiseProxy`) and the script origin. New files must also be added to `tools/js2c-inputs.xcfilelist` — the build fails with an explicit message if that list drifts out of sync (`js2c.mjs --filelist`). + +## primordials + +`primordials.js` runs first in every isolate — lazily, on the first +`RunBuiltin` call, which happens during runtime init — and its frozen, +null-prototype export is cached per isolate (`Caches::Primordials`) and +handed to every other builtin. Builtins that compile late (`smart-stringify` +is compiled on the first object logged) therefore still see intrinsics as they +were before user code ran. + +Naming follows Node: statics keep their path (`JSONStringify`, +`ObjectDefineProperty`), instance methods are **uncurried** so the receiver +becomes the first argument: + +```js +ArrayPrototypeSlice(list, 1) // not list.slice(1) +FunctionPrototypeCall(cb, this, event) // not cb.call(this, event) +``` + +Uncurrying is `Function.prototype.bind.bind(Function.prototype.call)`, which on +the jitless configuration the runtime ships is both faster than a captured +`fn.call(...)` and immune to a replaced `Function.prototype.call`. + +Add only what a builtin actually needs; this is not a mirror of Node's list. +Plain constructor calls made once at init time (`new Map()` while +bootstrapping) may stay direct — the rule targets code in closures that +outlive init. diff --git a/NativeScript/runtime/js/blob-url.js b/NativeScript/runtime/js/blob-url.js index 6f414f85..f6ce429b 100644 --- a/NativeScript/runtime/js/blob-url.js +++ b/NativeScript/runtime/js/blob-url.js @@ -1,10 +1,25 @@ +const { + Map, + MapPrototypeDelete, + MapPrototypeGet, + MapPrototypeSet, + ObjectDefineProperty, + StringPrototypeToLowerCase, +} = primordials; + +// The searchParams accessor below outlives init, so the constructor it reaches +// for is captured now rather than looked up on the global at call time. +const URLSearchParamsCtor = URLSearchParams; + const BLOB_STORE = new Map(); URL.createObjectURL = function (object, options = null) { try { + // Blob/File come from the app layer, not the runtime, so these stay + // live lookups; the catch below covers them not existing yet. if (object instanceof Blob || object instanceof File) { - const id = NSUUID.UUID().UUIDString.toLowerCase(); + const id = StringPrototypeToLowerCase(NSUUID.UUID().UUIDString); const ret = `blob:nativescript/${id}`; - BLOB_STORE.set(ret, { + MapPrototypeSet(BLOB_STORE, ret, { blob: object, type: object?.type, ext: options?.ext, @@ -17,18 +32,18 @@ URL.createObjectURL = function (object, options = null) { return null; }; URL.revokeObjectURL = function (url) { - BLOB_STORE.delete(url); + MapPrototypeDelete(BLOB_STORE, url); }; const InternalAccessor = class {}; InternalAccessor.getData = function (url) { - return BLOB_STORE.get(url); + return MapPrototypeGet(BLOB_STORE, url); }; URL.InternalAccessor = InternalAccessor; -Object.defineProperty(URL.prototype, 'searchParams', { +ObjectDefineProperty(URL.prototype, 'searchParams', { get() { if (this._searchParams == null) { - this._searchParams = new URLSearchParams(this.search); - Object.defineProperty(this._searchParams, '_url', { + this._searchParams = new URLSearchParamsCtor(this.search); + ObjectDefineProperty(this._searchParams, '_url', { enumerable: false, writable: false, value: this, diff --git a/NativeScript/runtime/js/class-extends.js b/NativeScript/runtime/js/class-extends.js index 24e1f411..3a80b33e 100644 --- a/NativeScript/runtime/js/class-extends.js +++ b/NativeScript/runtime/js/class-extends.js @@ -1,11 +1,12 @@ - function __extends(d, b) { + const { ObjectCreate, ObjectPrototypeHasOwnProperty } = primordials; + function __extends(d, b) { for (var p in b) { - if (b.hasOwnProperty(p)) { + if (ObjectPrototypeHasOwnProperty(b, p)) { d[p] = b[p]; } } function __() { this.constructor = d; } - d.prototype = b === null ? Object.create(b) : + d.prototype = b === null ? ObjectCreate(b) : (__.prototype = b.prototype, new __()); - } + } module.exports = __extends; diff --git a/NativeScript/runtime/js/error-events.js b/NativeScript/runtime/js/error-events.js index b530e0f2..2d0ce1a9 100644 --- a/NativeScript/runtime/js/error-events.js +++ b/NativeScript/runtime/js/error-events.js @@ -1,28 +1,29 @@ "use strict"; const { globalTarget, nativeReportFatal } = binding; +const { FunctionPrototypeCall, ObjectCreate, String, TypeError } = primordials; var g = globalThis; var Event = g.Event; function ErrorEvent(type, opts) { opts = opts || {}; - Event.call(this, type, opts); + FunctionPrototypeCall(Event, this, type, opts); this.message = opts.message !== undefined ? String(opts.message) : ""; this.filename = opts.filename !== undefined ? String(opts.filename) : ""; this.lineno = opts.lineno !== undefined ? (opts.lineno | 0) : 0; this.colno = opts.colno !== undefined ? (opts.colno | 0) : 0; this.error = opts.error !== undefined ? opts.error : null; } -ErrorEvent.prototype = Object.create(Event.prototype); +ErrorEvent.prototype = ObjectCreate(Event.prototype); ErrorEvent.prototype.constructor = ErrorEvent; function PromiseRejectionEvent(type, opts) { opts = opts || {}; - Event.call(this, type, opts); + FunctionPrototypeCall(Event, this, type, opts); this.promise = opts.promise; this.reason = opts.reason; } -PromiseRejectionEvent.prototype = Object.create(Event.prototype); +PromiseRejectionEvent.prototype = ObjectCreate(Event.prototype); PromiseRejectionEvent.prototype.constructor = PromiseRejectionEvent; // A listener that throws must not stop other listeners: route the thrown diff --git a/NativeScript/runtime/js/events.js b/NativeScript/runtime/js/events.js index 612f3570..28b57fcf 100644 --- a/NativeScript/runtime/js/events.js +++ b/NativeScript/runtime/js/events.js @@ -1,4 +1,13 @@ "use strict"; +const { + ArrayPrototypeIndexOf, + ArrayPrototypePush, + ArrayPrototypeSlice, + ArrayPrototypeSplice, + FunctionPrototypeCall, + ObjectCreate, + String, +} = primordials; var g = globalThis; function Event(type, opts) { @@ -29,7 +38,7 @@ Event.prototype.stopImmediatePropagation = function () { // user code runs); until then a thrown listener is swallowed. var reportListenerError = function (e) {}; -function EventTargetImpl() { this._listeners = Object.create(null); } +function EventTargetImpl() { this._listeners = ObjectCreate(null); } EventTargetImpl.prototype.addEventListener = function (type, callback, options) { if (callback === null || callback === undefined) { return; } type = String(type); @@ -45,7 +54,7 @@ EventTargetImpl.prototype.addEventListener = function (type, callback, options) for (var i = 0; i < list.length; i++) { if (list[i].callback === callback && list[i].capture === capture) { return; } } - list.push({ callback: callback, once: once, capture: capture }); + ArrayPrototypePush(list, { callback: callback, once: once, capture: capture }); }; EventTargetImpl.prototype.removeEventListener = function (type, callback, options) { type = String(type); @@ -59,7 +68,7 @@ EventTargetImpl.prototype.removeEventListener = function (type, callback, option if (!list) { return; } for (var i = 0; i < list.length; i++) { if (list[i].callback === callback && list[i].capture === capture) { - list.splice(i, 1); + ArrayPrototypeSplice(list, i, 1); return; } } @@ -71,16 +80,16 @@ EventTargetImpl.prototype.dispatchEvent = function (event) { if (list) { // Snapshot so listeners added during dispatch are not invoked and // registration order is preserved. - var snapshot = list.slice(); + var snapshot = ArrayPrototypeSlice(list); for (var i = 0; i < snapshot.length; i++) { var entry = snapshot[i]; - var idx = list.indexOf(entry); + var idx = ArrayPrototypeIndexOf(list, entry); if (idx === -1) { continue; } // removed since snapshot - if (entry.once) { list.splice(idx, 1); } + if (entry.once) { ArrayPrototypeSplice(list, idx, 1); } var cb = entry.callback; try { if (typeof cb === "function") { - cb.call(this, event); + FunctionPrototypeCall(cb, this, event); } else if (cb && typeof cb.handleEvent === "function") { cb.handleEvent(event); } @@ -116,7 +125,7 @@ g.dispatchEvent = function (event) { return globalTarget.dispatchEvent(event); }; -function EventTarget() { EventTargetImpl.call(this); } +function EventTarget() { FunctionPrototypeCall(EventTargetImpl, this); } EventTarget.prototype.addEventListener = EventTargetImpl.prototype.addEventListener; EventTarget.prototype.removeEventListener = EventTargetImpl.prototype.removeEventListener; EventTarget.prototype.dispatchEvent = EventTargetImpl.prototype.dispatchEvent; diff --git a/NativeScript/runtime/js/inline-functions.js b/NativeScript/runtime/js/inline-functions.js index 72a99b62..ea98b3d8 100644 --- a/NativeScript/runtime/js/inline-functions.js +++ b/NativeScript/runtime/js/inline-functions.js @@ -1,4 +1,13 @@ -Object.assign(global, { +const { + ArrayPrototypeConcat, + FunctionPrototypeApply, + ObjectAssign, + ObjectDefineProperty, + ObjectGetOwnPropertyDescriptor, + ObjectKeys, +} = primordials; + +ObjectAssign(global, { CGPointMake(x, y) { return new CGPoint({ x, y }); }, @@ -16,20 +25,26 @@ Object.assign(global, { }, __decorate(decorators, target, key, desc) { - var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; + var c = arguments.length, r = c < 3 ? target : desc === null ? desc = ObjectGetOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; - return c > 3 && r && Object.defineProperty(target, key, r), r; + return c > 3 && r && ObjectDefineProperty(target, key, r), r; }, __param(paramIndex, decorator) { return function (target, key) { decorator(target, key, paramIndex); } }, ObjCClass() { - var protocols = Array.from(arguments); + // Index loop, not ArrayFrom/spread: this runs at class-definition + // time, when user code could already have tampered the array + // iterator protocol. + var protocols = []; + for (var pi = 0; pi < arguments.length; pi++) { + protocols[pi] = arguments[pi]; + } return function (target) { if (protocols.length > 0) { - target.ObjCProtocols = (target.ObjCProtocols && target.ObjCProtocols instanceof Array ? target.ObjCProtocols.concat(protocols) : protocols); + target.ObjCProtocols = (target.ObjCProtocols && target.ObjCProtocols instanceof Array ? ArrayPrototypeConcat(target.ObjCProtocols, protocols) : protocols); } } }, @@ -52,20 +67,23 @@ Object.assign(global, { delete target.constructor.ObjCExposedMethods[propertyKey]; target[name] = function () { - return this[propertyKey].apply(this, arguments); + return FunctionPrototypeApply(this[propertyKey], this, arguments); } } } }, ObjC() { - var args = Array.from(arguments); + var args = []; + for (var ai = 0; ai < arguments.length; ai++) { + args[ai] = arguments[ai]; + } return function (target, propertyKey, descriptor) { if (propertyKey === undefined) { - return ObjCClass.apply(this, args)(target); + return FunctionPrototypeApply(ObjCClass, this, args)(target); } - ObjCMethod.apply(this, args)(target, propertyKey, descriptor); + FunctionPrototypeApply(ObjCMethod, this, args)(target, propertyKey, descriptor); }; }, ObjCParam(type) { @@ -86,13 +104,17 @@ Object.assign(global, { }); -Object.defineProperty(global, "__tsEnum", { +ObjectDefineProperty(global, "__tsEnum", { writable: false, enumerable: false, configurable: false, value: function(obj) { var result = {}; - for (var key of Object.keys(obj)) { + // Index loop, not for...of: enum globals evaluate lazily on first + // access, after user code could have tampered the array iterator. + var keys = ObjectKeys(obj); + for (var i = 0; i < keys.length; i++) { + var key = keys[i]; result[key] = obj[key]; result[obj[key]] = key; } diff --git a/NativeScript/runtime/js/primordials.js b/NativeScript/runtime/js/primordials.js new file mode 100644 index 00000000..56d8ca71 --- /dev/null +++ b/NativeScript/runtime/js/primordials.js @@ -0,0 +1,61 @@ +"use strict"; + +// Snapshot of the intrinsics the other builtins depend on, taken before any +// user code can reach the globals. Runs first and is handed to every other +// builtin as the second fixed parameter. +// +// Instance methods are exposed "uncurried" (Node's idiom): the receiver +// becomes the first argument, so `ArrayPrototypeSlice(list, 0)` reads the +// captured Array.prototype.slice directly instead of a property of whatever +// `list.slice` resolves to at call time. + +const FunctionPrototypeCall = Function.prototype.call; +const FunctionPrototypeBind = Function.prototype.bind; +const FunctionPrototypeApply = Function.prototype.apply; + +// bind() with `this` pinned to call(): uncurryThis(fn) === fn.call.bind(fn), +// but without reading `fn.call`. +const uncurryThis = FunctionPrototypeBind.bind(FunctionPrototypeCall); + +// Named `intrinsics` rather than `primordials`: the latter is this file's own +// (unused) parameter. +const intrinsics = { + // Constructors and well-known symbols. + Error, + Map, + Proxy, + String, + TypeError, + SymbolHasInstance: Symbol.hasInstance, + + // Statics. + JSONStringify: JSON.stringify, + ObjectAssign: Object.assign, + ObjectCreate: Object.create, + ObjectDefineProperty: Object.defineProperty, + ObjectFreeze: Object.freeze, + ObjectGetOwnPropertyDescriptor: Object.getOwnPropertyDescriptor, + ObjectKeys: Object.keys, + ObjectSetPrototypeOf: Object.setPrototypeOf, + ReflectConstruct: Reflect.construct, + + // Instance methods, uncurried. + ArrayPrototypeConcat: uncurryThis(Array.prototype.concat), + ArrayPrototypeIndexOf: uncurryThis(Array.prototype.indexOf), + ArrayPrototypePush: uncurryThis(Array.prototype.push), + ArrayPrototypeSlice: uncurryThis(Array.prototype.slice), + ArrayPrototypeSplice: uncurryThis(Array.prototype.splice), + FunctionPrototypeApply: uncurryThis(FunctionPrototypeApply), + FunctionPrototypeBind: uncurryThis(FunctionPrototypeBind), + FunctionPrototypeCall: uncurryThis(FunctionPrototypeCall), + FunctionPrototypeToString: uncurryThis(Function.prototype.toString), + MapPrototypeDelete: uncurryThis(Map.prototype.delete), + MapPrototypeGet: uncurryThis(Map.prototype.get), + MapPrototypeSet: uncurryThis(Map.prototype.set), + ObjectPrototypeHasOwnProperty: uncurryThis(Object.prototype.hasOwnProperty), + StringPrototypeIndexOf: uncurryThis(String.prototype.indexOf), + StringPrototypeToLowerCase: uncurryThis(String.prototype.toLowerCase), +}; + +Object.setPrototypeOf(intrinsics, null); +module.exports = Object.freeze(intrinsics); diff --git a/NativeScript/runtime/js/promise-proxy.js b/NativeScript/runtime/js/promise-proxy.js index 091d127c..b2f5d2db 100644 --- a/NativeScript/runtime/js/promise-proxy.js +++ b/NativeScript/runtime/js/promise-proxy.js @@ -3,6 +3,7 @@ // thread settles on whichever thread resolves it, because the background // run loop may be dormant and marshaling a resolution to it would hang. const { isRuntimeRunloop } = binding; +const { FunctionPrototypeBind, Proxy } = primordials; global.Promise = new Proxy(global.Promise, { construct: function(target, args) { @@ -23,7 +24,7 @@ global.Promise = new Proxy(global.Promise, { if (isFulfilled()) { return; } - const resolveCall = resolve.bind(this, value); + const resolveCall = FunctionPrototypeBind(resolve, this, value); if (!originIsRuntimeLoop || runloop === CFRunLoopGetCurrent()) { markFulfilled(); resolveCall(); @@ -36,7 +37,7 @@ global.Promise = new Proxy(global.Promise, { if (isFulfilled()) { return; } - const rejectCall = reject.bind(this, reason); + const rejectCall = FunctionPrototypeBind(reject, this, reason); if (!originIsRuntimeLoop || runloop === CFRunLoopGetCurrent()) { markFulfilled(); rejectCall(); @@ -52,14 +53,14 @@ global.Promise = new Proxy(global.Promise, { get: function(target, name) { let orig = target[name]; if (name === "then" || name === "catch" || name === "finally") { - return orig.bind(target); + return FunctionPrototypeBind(orig, target); } return typeof orig === 'function' ? function(x) { if (!originIsRuntimeLoop || runloop === CFRunLoopGetCurrent()) { - orig.bind(target, x)(); + FunctionPrototypeBind(orig, target, x)(); return target; } - CFRunLoopPerformBlock(runloop, kCFRunLoopDefaultMode, orig.bind(target, x)); + CFRunLoopPerformBlock(runloop, kCFRunLoopDefaultMode, FunctionPrototypeBind(orig, target, x)); CFRunLoopWakeUp(runloop); return target; } : orig; diff --git a/NativeScript/runtime/js/smart-stringify.js b/NativeScript/runtime/js/smart-stringify.js index b7b06098..5824f9d1 100644 --- a/NativeScript/runtime/js/smart-stringify.js +++ b/NativeScript/runtime/js/smart-stringify.js @@ -1,17 +1,19 @@ +const { ArrayPrototypeIndexOf, ArrayPrototypePush, JSONStringify } = primordials; + function smartStringify(object) { const seen = []; var replacer = function (key, value) { if (value != null && typeof value == "object") { - if (seen.indexOf(value) >= 0) { + if (ArrayPrototypeIndexOf(seen, value) >= 0) { if (key) { return "[Circular]"; } return; } - seen.push(value); + ArrayPrototypePush(seen, value); } return value; }; - return JSON.stringify(object, replacer, 2); + return JSONStringify(object, replacer, 2); } module.exports = smartStringify; diff --git a/NativeScript/runtime/js/ts-helpers.js b/NativeScript/runtime/js/ts-helpers.js index bd3cbc8d..6942849c 100644 --- a/NativeScript/runtime/js/ts-helpers.js +++ b/NativeScript/runtime/js/ts-helpers.js @@ -1,6 +1,20 @@ +const { + ArrayPrototypeConcat, + ArrayPrototypeSlice, + Error, + FunctionPrototypeToString, + ObjectCreate, + ObjectDefineProperty, + ObjectPrototypeHasOwnProperty, + ObjectSetPrototypeOf, + ReflectConstruct, + StringPrototypeIndexOf, + SymbolHasInstance, +} = primordials; + var __originalExtends = global.__extends; var __extends = (Child, Parent) => { - var extendingNativeClass = !!Parent.extend && (Parent.extend.toString().indexOf("[native code]") > -1); + var extendingNativeClass = !!Parent.extend && (StringPrototypeIndexOf(FunctionPrototypeToString(Parent.extend), "[native code]") > -1); if (!extendingNativeClass) { __extends_ts(Child, Parent); return; @@ -19,7 +33,7 @@ var __extends = (Child, Parent) => { protocols: child.ObjCProtocols || [], exposedMethods: child.ObjCExposedMethods || {} }); - child[Symbol.hasInstance] = function (instance) { + child[SymbolHasInstance] = function (instance) { return instance instanceof this.__extended; } } @@ -30,7 +44,7 @@ var __extends = (Child, Parent) => { var Extended = extend(thiz); thiz.__container__ = true; if (arguments.length > 1) { - thiz.__proto__ = new (Function.prototype.bind.apply(Extended, [null].concat(Array.prototype.slice.call(arguments, 1)))); + thiz.__proto__ = ReflectConstruct(Extended, ArrayPrototypeSlice(arguments, 1)); } else { thiz.__proto__ = new Extended() } @@ -41,7 +55,10 @@ var __extends = (Child, Parent) => { var Extended = extend(thiz); thiz.__container__ = true; if (args && args.length > 0) { - thiz.__proto__ = new (Function.prototype.bind.apply(Extended, [null].concat(args))); + // concat, not spread: a non-Array `args` (the arguments object + // TypeScript's `_super.apply(this, arguments)` passes) is forwarded + // as a single argument. + thiz.__proto__ = ReflectConstruct(Extended, ArrayPrototypeConcat([], args)); } else { thiz.__proto__ = new Extended(); } @@ -73,7 +90,7 @@ var __extends_ns = function (child, parent) { }; var extendStaticFunctions = - Object.setPrototypeOf + ObjectSetPrototypeOf || (hasInternalProtoProperty() && function (child, parent) { child.__proto__ = parent; }) || assignPropertiesFromParentToChild; @@ -83,7 +100,7 @@ function hasInternalProtoProperty() { function assignPropertiesFromParentToChild(parent, child) { for (var property in parent) { - if (parent.hasOwnProperty(property)) { + if (ObjectPrototypeHasOwnProperty(parent, property)) { child[property] = parent[property]; } } @@ -95,11 +112,11 @@ function assignPrototypeFromParentToChild(parent, child) { } if (parent === null) { - child.prototype = Object.create(null); + child.prototype = ObjectCreate(null); } else { __.prototype = parent.prototype; child.prototype = new __(); } } -Object.defineProperty(global, "__extends", { value: __extends, writable: false }); +ObjectDefineProperty(global, "__extends", { value: __extends, writable: false }); diff --git a/TestRunner/app/tests/PrimordialsTests.js b/TestRunner/app/tests/PrimordialsTests.js new file mode 100644 index 00000000..b531918c --- /dev/null +++ b/TestRunner/app/tests/PrimordialsTests.js @@ -0,0 +1,209 @@ +describe("primordials", function () { + const boom = function () { + throw new Error("intrinsic tampered"); + }; + + // Tampering with the intrinsics breaks Jasmine and most of the runtime as + // well, so the tampered window stays synchronous and assertion-free: + // results go into locals, the originals come back in a finally, and only + // then do the expectations run. Nothing inside the window may use an array + // method or `.call` either — plain indexing and direct calls only. + function withTampered(patches, body) { + const originals = []; + for (let i = 0; i < patches.length; i++) { + originals[i] = patches[i][0][patches[i][1]]; + } + try { + for (let i = 0; i < patches.length; i++) { + patches[i][0][patches[i][1]] = boom; + } + return body(); + } finally { + for (let i = 0; i < patches.length; i++) { + patches[i][0][patches[i][1]] = originals[i]; + } + } + } + + const arrayAndCall = [ + [Array.prototype, "slice"], + [Array.prototype, "indexOf"], + [Array.prototype, "push"], + [Array.prototype, "splice"], + [Function.prototype, "call"], + ]; + + it("the tampering used by this suite is actually observable", function () { + const outcome = withTampered(arrayAndCall, function () { + try { + [1, 2].slice(0); + return "no throw"; + } catch (e) { + return e.message; + } + }); + + expect(outcome).toBe("intrinsic tampered"); + expect([1, 2].slice(0).length).toBe(2); + }); + + it("global dispatchEvent delivers to every listener while intrinsics are tampered", function () { + const seen = []; + const first = function (e) { seen[seen.length] = "first:" + e.type; }; + const second = { handleEvent: function (e) { seen[seen.length] = "second:" + e.type; } }; + const event = new Event("primordials-dispatch"); + + global.addEventListener("primordials-dispatch", first); + global.addEventListener("primordials-dispatch", second); + + let dispatchResult; + try { + dispatchResult = withTampered(arrayAndCall, function () { + return global.dispatchEvent(event); + }); + } finally { + global.removeEventListener("primordials-dispatch", first); + global.removeEventListener("primordials-dispatch", second); + } + + expect(dispatchResult).toBe(true); + expect(seen.join(",")).toBe("first:primordials-dispatch,second:primordials-dispatch"); + }); + + it("addEventListener/removeEventListener and once work while intrinsics are tampered", function () { + const calls = []; + const persistent = function () { calls[calls.length] = "persistent"; }; + const onceOnly = function () { calls[calls.length] = "once"; }; + + try { + withTampered(arrayAndCall, function () { + global.addEventListener("primordials-registration", persistent); + global.addEventListener("primordials-registration", onceOnly, { once: true }); + global.dispatchEvent(new Event("primordials-registration")); + global.dispatchEvent(new Event("primordials-registration")); + global.removeEventListener("primordials-registration", persistent); + global.dispatchEvent(new Event("primordials-registration")); + }); + } finally { + global.removeEventListener("primordials-registration", persistent); + global.removeEventListener("primordials-registration", onceOnly); + } + + expect(calls.join(",")).toBe("persistent,once,persistent"); + }); + + it("reportError still reaches an error listener while intrinsics are tampered", function () { + let received = null; + // preventDefault keeps the unhandled tail (which aborts the process) + // out of the picture. + const onError = function (e) { + received = e; + e.preventDefault(); + }; + const error = new Error("primordials-report"); + + global.addEventListener("error", onError); + try { + withTampered(arrayAndCall, function () { + global.reportError(error); + }); + } finally { + global.removeEventListener("error", onError); + } + + expect(received).not.toBeNull(); + expect(received.type).toBe("error"); + expect(received.error).toBe(error); + expect(received.message).toBe("primordials-report"); + }); + + it("console.log of a circular object neither throws nor crashes with JSON.stringify tampered", function () { + // The smart-stringify builtin both calls JSON.stringify and tracks + // already-visited objects with Array.prototype.indexOf/push. Its output + // is not reachable from JS and JsonStringifyObject swallows a throwing + // stringify, so this only pins down that the tampered path stays + // non-fatal; the primordial routing itself is covered by review. + const circular = { name: "primordials" }; + circular.self = circular; + + let threw = null; + try { + withTampered([ + [JSON, "stringify"], + [Array.prototype, "indexOf"], + [Array.prototype, "push"], + ], function () { + console.log(circular); + }); + } catch (e) { + threw = e; + } + + expect(threw).toBeNull(); + }); + + it("a native super called with arguments works while intrinsics are tampered", function () { + // ts-helpers routes `_super.call(this, x)` over a native base through + // Array.prototype.slice and Reflect.construct. + const PrimordialsSubclass = (function (_super) { + global.__extends(PrimordialsSubclass, _super); + function PrimordialsSubclass(x) { + return _super.call(this, x) || this; + } + return PrimordialsSubclass; + }(TNSCInterface)); + + // The first construction is what registers the Objective-C class, and + // that has to happen with the intrinsics intact. + new PrimordialsSubclass(1); + + TNSClearOutput(); + let threw = null; + try { + withTampered([ + [Array.prototype, "slice"], + [Array.prototype, "concat"], + [Function.prototype, "bind"], + [Reflect, "construct"], + ], function () { + return new PrimordialsSubclass(7); + }); + } catch (e) { + threw = e; + } + + expect(threw).toBeNull(); + expect(TNSGetOutput()).toBe("initWithPrimitive:7 called"); + TNSClearOutput(); + }); + + it("__extends and __tsEnum work while Object intrinsics are tampered", function () { + function Base() { } + Base.prototype.hello = function () { return "hello"; }; + Base.staticMember = 1; + function Derived() { } + + let enumerated = null; + let threw = null; + try { + withTampered([ + [Object, "create"], + [Object, "keys"], + [Object, "setPrototypeOf"], + [Object.prototype, "hasOwnProperty"], + [Function.prototype, "call"], + ], function () { + global.__extends(Derived, Base); + enumerated = global.__tsEnum({ First: 0, Second: 1 }); + }); + } catch (e) { + threw = e; + } + + expect(threw).toBeNull(); + expect(new Derived().hello()).toBe("hello"); + expect(Derived.staticMember).toBe(1); + expect(enumerated.First).toBe(0); + expect(enumerated[0]).toBe("First"); + }); +}); diff --git a/TestRunner/app/tests/index.js b/TestRunner/app/tests/index.js index 9871a181..7832d422 100644 --- a/TestRunner/app/tests/index.js +++ b/TestRunner/app/tests/index.js @@ -160,6 +160,9 @@ require("./ErrorEventsTests"); // interop.escapeException + JS<->native boundary hardening (Phase 3) require("./EscapeExceptionTests"); +// Runtime builtins keep working when app code replaces the intrinsics they use +require("./PrimordialsTests"); + // Tests common for all runtimes (git submodule of NativeScript/common-runtime-tests-app). require("../shared/index").runAllTests(); diff --git a/eslint.config.mjs b/eslint.config.mjs index 4b648a26..1b5dc930 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,11 +1,51 @@ // Lint setup for the runtime's builtin JavaScript (NativeScript/runtime/js). // Each file is compiled by BuiltinLoader as a FUNCTION BODY with the fixed -// parameters `exports`, `module` and `binding` (see +// parameters `exports`, `module`, `binding` and `primordials` (see // NativeScript/runtime/js/README.md), which are declared as globals here. // no-undef is the typo net for binding-bag destructures and native-global -// usage alike. +// usage alike; no-restricted-properties keeps the captured intrinsics from +// being read off the live globals again. import globals from 'globals'; +// Statics that primordials.js captures, mapped to their replacement. Instance +// methods (Array.prototype.slice and friends) cannot be matched by +// no-restricted-properties on the receiver, so uncurried use of those stays a +// review rule. +const capturedStatics = [ + ['JSON', 'stringify', 'JSONStringify'], + ['Object', 'assign', 'ObjectAssign'], + ['Object', 'create', 'ObjectCreate'], + ['Object', 'defineProperty', 'ObjectDefineProperty'], + ['Object', 'freeze', 'ObjectFreeze'], + ['Object', 'getOwnPropertyDescriptor', 'ObjectGetOwnPropertyDescriptor'], + ['Object', 'keys', 'ObjectKeys'], + ['Object', 'setPrototypeOf', 'ObjectSetPrototypeOf'], + ['Reflect', 'construct', 'ReflectConstruct'], + // No ReflectApply primordial: apply goes through the uncurried + // Function.prototype.apply, which is one property read cheaper. + ['Reflect', 'apply', 'FunctionPrototypeApply'], +]; + +// Captured constructors and namespaces. A destructure from `primordials` +// shadows the global, so these only fire on the unguarded reference. +// `Array` and `Reflect` are deliberately absent: builtins still use them bare +// (`instanceof Array`, the live `Reflect.decorate` probe that reflect-metadata +// fills in after init). Array.from has no primordial: copying `arguments` goes +// through an index loop instead, because Array.from depends on the tamperable +// array iterator protocol. +const restrictedGlobals = ['Error', 'Map', 'Proxy', 'String', 'TypeError'].map((name) => ({ + name, + message: `Destructure ${name} from primordials — builtins must not read intrinsics off globals user code can replace.`, +})); + +const restrictedProperties = capturedStatics.map( + ([object, property, primordial]) => ({ + object, + property, + message: `Use the ${primordial} primordial instead of ${object}.${property} — builtins must not read intrinsics off globals user code can replace.`, + }), +); + export default [ { files: ['NativeScript/runtime/js/**/*.js'], @@ -17,6 +57,7 @@ export default [ exports: 'readonly', module: 'readonly', binding: 'readonly', + primordials: 'readonly', global: 'readonly', console: 'readonly', URL: 'readonly', @@ -44,6 +85,16 @@ export default [ rules: { 'no-undef': 'error', 'no-unused-vars': ['error', { args: 'none', caughtErrors: 'none' }], + 'no-restricted-properties': ['error', ...restrictedProperties], + 'no-restricted-globals': ['error', ...restrictedGlobals], + }, + }, + { + // The file that does the capturing. + files: ['NativeScript/runtime/js/primordials.js'], + rules: { + 'no-restricted-properties': 'off', + 'no-restricted-globals': 'off', }, }, ]; diff --git a/tools/js2c-inputs.xcfilelist b/tools/js2c-inputs.xcfilelist index 3d31d102..617605da 100644 --- a/tools/js2c-inputs.xcfilelist +++ b/tools/js2c-inputs.xcfilelist @@ -4,6 +4,7 @@ $(SRCROOT)/NativeScript/runtime/js/class-extends.js $(SRCROOT)/NativeScript/runtime/js/error-events.js $(SRCROOT)/NativeScript/runtime/js/events.js $(SRCROOT)/NativeScript/runtime/js/inline-functions.js +$(SRCROOT)/NativeScript/runtime/js/primordials.js $(SRCROOT)/NativeScript/runtime/js/promise-proxy.js $(SRCROOT)/NativeScript/runtime/js/require-factory.js $(SRCROOT)/NativeScript/runtime/js/smart-stringify.js