Skip to content

Commit 4687302

Browse files
committed
feat: Node-style primordials for runtime builtins
Runtime builtins install closures that outlive init and are then reachable from app code, so every intrinsic they use at call time is something the app can replace. internal/primordials.js snapshots exactly the intrinsics the builtins need into a frozen null-prototype namespace, taken on the first RunBuiltin of an isolate (during runtime init) and cached per isolate; builtins now compile with a fourth fixed parameter, `primordials`. Instance methods are uncurried Node-style, so the receiver becomes the first argument. ESLint fails the lint on direct use of the captured statics and constructors. Mirrors NativeScript/ios#415.
1 parent 05dc54b commit 4687302

16 files changed

Lines changed: 517 additions & 55 deletions

eslint.config.mjs

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,37 @@
11
// Lint setup for the runtime's builtin JavaScript
22
// (test-app/runtime/src/main/cpp/js). Each file is compiled by BuiltinLoader
3-
// as a FUNCTION BODY with the fixed parameters `exports`, `module` and
4-
// `binding` (see that directory's README.md), which are declared as globals
5-
// here. no-undef is the typo net for binding-bag destructures and
6-
// native-global usage alike.
3+
// as a FUNCTION BODY with the fixed parameters `exports`, `module`, `binding`
4+
// and `primordials` (see that directory's README.md), which are declared as
5+
// globals here. no-undef is the typo net for binding-bag destructures and
6+
// native-global usage alike; no-restricted-properties keeps the captured
7+
// intrinsics from being read off the live globals again.
78
import globals from 'globals';
89

10+
// Statics that primordials.js captures, mapped to their replacement. Instance
11+
// methods (Array.prototype.slice and friends) cannot be matched by
12+
// no-restricted-properties on the receiver, so uncurried use of those stays a
13+
// review rule.
14+
const capturedStatics = [
15+
['Array', 'isArray', 'ArrayIsArray'],
16+
['JSON', 'stringify', 'JSONStringify'],
17+
['Object', 'create', 'ObjectCreate'],
18+
['Object', 'defineProperty', 'ObjectDefineProperty'],
19+
['Object', 'keys', 'ObjectKeys'],
20+
];
21+
22+
// Captured constructors. A destructure from `primordials` shadows the global,
23+
// so these only fire on the unguarded reference.
24+
const restrictedGlobals = ['Date', 'Map', 'Proxy', 'String', 'TypeError'].map((name) => ({
25+
name,
26+
message: `Destructure ${name} from primordials — builtins must not read intrinsics off globals user code can replace.`,
27+
}));
28+
29+
const restrictedProperties = capturedStatics.map(([object, property, primordial]) => ({
30+
object,
31+
property,
32+
message: `Use the ${primordial} primordial instead of ${object}.${property} — builtins must not read intrinsics off globals user code can replace.`,
33+
}));
34+
935
export default [
1036
{
1137
files: ['test-app/runtime/src/main/cpp/js/**/*.js'],
@@ -17,6 +43,7 @@ export default [
1743
exports: 'readonly',
1844
module: 'readonly',
1945
binding: 'readonly',
46+
primordials: 'readonly',
2047
global: 'readonly',
2148
console: 'readonly',
2249
URL: 'readonly',
@@ -33,6 +60,16 @@ export default [
3360
rules: {
3461
'no-undef': 'error',
3562
'no-unused-vars': ['error', { args: 'none', caughtErrors: 'none' }],
63+
'no-restricted-properties': ['error', ...restrictedProperties],
64+
'no-restricted-globals': ['error', ...restrictedGlobals],
65+
},
66+
},
67+
{
68+
// The file that does the capturing.
69+
files: ['test-app/runtime/src/main/cpp/js/primordials.js'],
70+
rules: {
71+
'no-restricted-properties': 'off',
72+
'no-restricted-globals': 'off',
3673
},
3774
},
3875
];

test-app/app/src/main/assets/app/mainpage.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ require('./tests/testErrorEvents');
7878
require('./tests/testUnhandledRejections');
7979
require('./tests/testEscapeException');
8080
require('./tests/testUncaughtErrorPolicy');
81+
// Runtime builtins keep working when app code replaces the intrinsics they use
82+
require('./tests/testPrimordials');
8183
require("./tests/testConcurrentAccess");
8284

8385
require("./tests/testESModules.mjs");
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
describe("primordials", function () {
2+
const boom = function () {
3+
throw new Error("intrinsic tampered");
4+
};
5+
6+
// Tampering with the intrinsics breaks Jasmine and most of the runtime as
7+
// well, so the tampered window stays synchronous and assertion-free:
8+
// results go into locals, the originals come back in a finally, and only
9+
// then do the expectations run. Nothing inside the window may use an array
10+
// method or `.call` either — plain indexing and direct calls only.
11+
function withTampered(patches, body) {
12+
const originals = [];
13+
for (let i = 0; i < patches.length; i++) {
14+
originals[i] = patches[i][0][patches[i][1]];
15+
}
16+
try {
17+
for (let i = 0; i < patches.length; i++) {
18+
patches[i][0][patches[i][1]] = boom;
19+
}
20+
return body();
21+
} finally {
22+
for (let i = 0; i < patches.length; i++) {
23+
patches[i][0][patches[i][1]] = originals[i];
24+
}
25+
}
26+
}
27+
28+
const arrayAndCall = [
29+
[Array.prototype, "slice"],
30+
[Array.prototype, "indexOf"],
31+
[Array.prototype, "push"],
32+
[Array.prototype, "splice"],
33+
[Function.prototype, "call"],
34+
];
35+
36+
it("the tampering used by this suite is actually observable", function () {
37+
const outcome = withTampered(arrayAndCall, function () {
38+
try {
39+
[1, 2].slice(0);
40+
return "no throw";
41+
} catch (e) {
42+
return e.message;
43+
}
44+
});
45+
46+
expect(outcome).toBe("intrinsic tampered");
47+
expect([1, 2].slice(0).length).toBe(2);
48+
});
49+
50+
it("global dispatchEvent delivers to every listener while intrinsics are tampered", function () {
51+
const seen = [];
52+
const first = function (e) { seen[seen.length] = "first:" + e.type; };
53+
const second = { handleEvent: function (e) { seen[seen.length] = "second:" + e.type; } };
54+
const event = new Event("primordials-dispatch");
55+
56+
global.addEventListener("primordials-dispatch", first);
57+
global.addEventListener("primordials-dispatch", second);
58+
59+
let dispatchResult;
60+
try {
61+
dispatchResult = withTampered(arrayAndCall, function () {
62+
return global.dispatchEvent(event);
63+
});
64+
} finally {
65+
global.removeEventListener("primordials-dispatch", first);
66+
global.removeEventListener("primordials-dispatch", second);
67+
}
68+
69+
expect(dispatchResult).toBe(true);
70+
expect(seen.join(",")).toBe("first:primordials-dispatch,second:primordials-dispatch");
71+
});
72+
73+
it("addEventListener/removeEventListener and once work while intrinsics are tampered", function () {
74+
const calls = [];
75+
const persistent = function () { calls[calls.length] = "persistent"; };
76+
const onceOnly = function () { calls[calls.length] = "once"; };
77+
78+
try {
79+
withTampered(arrayAndCall, function () {
80+
global.addEventListener("primordials-registration", persistent);
81+
global.addEventListener("primordials-registration", onceOnly, { once: true });
82+
global.dispatchEvent(new Event("primordials-registration"));
83+
global.dispatchEvent(new Event("primordials-registration"));
84+
global.removeEventListener("primordials-registration", persistent);
85+
global.dispatchEvent(new Event("primordials-registration"));
86+
});
87+
} finally {
88+
global.removeEventListener("primordials-registration", persistent);
89+
global.removeEventListener("primordials-registration", onceOnly);
90+
}
91+
92+
expect(calls.join(",")).toBe("persistent,once,persistent");
93+
});
94+
95+
it("reportError still reaches an error listener while intrinsics are tampered", function () {
96+
let received = null;
97+
// preventDefault keeps the unhandled tail (which aborts the process)
98+
// out of the picture.
99+
const onError = function (e) {
100+
received = e;
101+
e.preventDefault();
102+
};
103+
const error = new Error("primordials-report");
104+
105+
global.addEventListener("error", onError);
106+
try {
107+
withTampered(arrayAndCall, function () {
108+
global.reportError(error);
109+
});
110+
} finally {
111+
global.removeEventListener("error", onError);
112+
}
113+
114+
expect(received).not.toBeNull();
115+
expect(received.type).toBe("error");
116+
expect(received.error).toBe(error);
117+
expect(received.message).toBe("primordials-report");
118+
});
119+
120+
it("console.log of a circular object neither throws nor crashes with JSON.stringify tampered", function () {
121+
// The smart-stringify builtin both calls JSON.stringify and tracks
122+
// already-visited objects with Array.prototype.indexOf/push. Its output
123+
// is not reachable from JS and JsonStringifyObject swallows a throwing
124+
// stringify, so this only pins down that the tampered path stays
125+
// non-fatal; the primordial routing itself is covered by review.
126+
const circular = { name: "primordials" };
127+
circular.self = circular;
128+
129+
let threw = null;
130+
try {
131+
withTampered([
132+
[JSON, "stringify"],
133+
[Array.prototype, "indexOf"],
134+
[Array.prototype, "push"],
135+
], function () {
136+
console.log(circular);
137+
});
138+
} catch (e) {
139+
threw = e;
140+
}
141+
142+
expect(threw).toBeNull();
143+
});
144+
145+
it("the searchParams accessor works while Object.defineProperty is tampered", function () {
146+
const url = new URL("https://example.com/path?a=1");
147+
148+
let readBack = null;
149+
let searchAfterAppend = null;
150+
let threw = null;
151+
try {
152+
withTampered([[Object, "defineProperty"]], function () {
153+
const params = url.searchParams;
154+
readBack = params.get("a");
155+
params.append("b", "2");
156+
searchAfterAppend = url.search;
157+
});
158+
} catch (e) {
159+
threw = e;
160+
}
161+
162+
expect(threw).toBeNull();
163+
expect(readBack).toBe("1");
164+
expect(searchAfterAppend).toBe("?a=1&b=2");
165+
});
166+
167+
it("revokeObjectURL and InternalAccessor.getData work while Map methods are tampered", function () {
168+
let data;
169+
let threw = null;
170+
try {
171+
withTampered([
172+
[Map.prototype, "get"],
173+
[Map.prototype, "set"],
174+
[Map.prototype, "delete"],
175+
], function () {
176+
URL.revokeObjectURL("blob:nativescript/primordials-missing");
177+
data = URL.InternalAccessor.getData("blob:nativescript/primordials-missing");
178+
});
179+
} catch (e) {
180+
threw = e;
181+
}
182+
183+
expect(threw).toBeNull();
184+
expect(data).toBeUndefined();
185+
});
186+
187+
it("org.json.JSONObject.from works while the intrinsics json-helper uses are tampered", function () {
188+
const source = {
189+
text: "primordials",
190+
when: new Date(1570696661136),
191+
list: [1, 2],
192+
};
193+
194+
let converted = null;
195+
let threw = null;
196+
try {
197+
withTampered([
198+
[Array, "isArray"],
199+
[Array.prototype, "forEach"],
200+
[Object, "keys"],
201+
[Date.prototype, "toJSON"],
202+
], function () {
203+
converted = org.json.JSONObject.from(source);
204+
});
205+
} catch (e) {
206+
threw = e;
207+
}
208+
209+
expect(threw).toBeNull();
210+
expect(converted instanceof org.json.JSONObject).toBe(true);
211+
expect(converted.getString("text")).toBe("primordials");
212+
expect(converted.getString("when")).toBe("2019-10-10T08:37:41.136Z");
213+
expect(converted.getJSONArray("list").length()).toBe(2);
214+
});
215+
});

test-app/runtime/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ set(RUNTIME_BUILTIN_JS
6969
${RUNTIME_BUILTIN_JS_DIR}/events.js
7070
${RUNTIME_BUILTIN_JS_DIR}/json-helper.js
7171
${RUNTIME_BUILTIN_JS_DIR}/message-loop-timer.js
72+
${RUNTIME_BUILTIN_JS_DIR}/primordials.js
7273
${RUNTIME_BUILTIN_JS_DIR}/require-factory.js
7374
${RUNTIME_BUILTIN_JS_DIR}/smart-stringify.js
7475
${RUNTIME_BUILTIN_JS_DIR}/weak-ref.js

0 commit comments

Comments
 (0)