Skip to content

Commit 98b63ba

Browse files
authored
feat: Node-style primordials for runtime builtins (#1990)
1 parent b5edc78 commit 98b63ba

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)