From 1c9f00c57dc23496083599feaaeb55eae06c7434 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 30 Jul 2026 11:06:07 -0300 Subject: [PATCH] feat: add Node-style primordials to runtime builtins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime's builtins install globals and leave closures behind that keep running for the lifetime of the app: event dispatch, the Promise proxy traps, console's smart-stringify, __extends. Those closures reached for intrinsics (Array.prototype.slice, JSON.stringify, Object.create, ...) through the live globals, so app code replacing one could break or observe runtime internals. primordials.js captures the intrinsics the other builtins need into a frozen, null-prototype namespace and BuiltinLoader passes it to every builtin as a second fixed parameter next to `binding`. It is built lazily on the first RunBuiltin of an isolate — during runtime init, before user code — and cached in Caches, so workers snapshot their own realm and late-compiling builtins still see pristine intrinsics. Instance methods are uncurried Node-style, `uncurryThis(fn)` being Function.prototype.bind.bind(Function.prototype.call): ArrayPrototypeSlice(list, 1) rather than list.slice(1). Benchmarked under jitless (which is how the runtime always runs on device) uncurried calls cost +5-12% per op over a raw method call but beat the captured-and-.call() alternative, so they are used uniformly. ESLint gains no-restricted-properties for the captured statics and no-restricted-globals for the captured constructors, each pointing at the replacement; uncurried instance-method use stays a review rule since the receiver cannot be matched. --- NativeScript/runtime/BuiltinLoader.cpp | 65 ++++-- NativeScript/runtime/BuiltinLoader.h | 11 +- NativeScript/runtime/Caches.h | 6 + NativeScript/runtime/js/README.md | 49 ++++- NativeScript/runtime/js/blob-url.js | 29 ++- NativeScript/runtime/js/class-extends.js | 9 +- NativeScript/runtime/js/error-events.js | 9 +- NativeScript/runtime/js/events.js | 25 ++- NativeScript/runtime/js/inline-functions.js | 44 +++-- NativeScript/runtime/js/primordials.js | 61 ++++++ NativeScript/runtime/js/promise-proxy.js | 11 +- NativeScript/runtime/js/smart-stringify.js | 8 +- NativeScript/runtime/js/ts-helpers.js | 33 +++- TestRunner/app/tests/PrimordialsTests.js | 209 ++++++++++++++++++++ TestRunner/app/tests/index.js | 3 + eslint.config.mjs | 55 +++++- tools/js2c-inputs.xcfilelist | 1 + 17 files changed, 553 insertions(+), 75 deletions(-) create mode 100644 NativeScript/runtime/js/primordials.js create mode 100644 TestRunner/app/tests/PrimordialsTests.js 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 8fa28559..f51b211a 100644 --- a/TestRunner/app/tests/index.js +++ b/TestRunner/app/tests/index.js @@ -102,6 +102,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