Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 20 additions & 47 deletions build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,10 @@ const { builtinScriptlets: scriptlets } = await import(
`https://raw.githubusercontent.com/gorhill/uBlock/${tagName}/src/js/resources/scriptlets.js`
);

const index = new Map();
for (const scriptlet of scriptlets) {
index.set(scriptlet.name, scriptlet);
for (const name of scriptlet.aliases || []) {
index.set(name, scriptlet);
}
}

// uBO runs all scriptlets of a page in one scope, so they share one safeSelf() cache. Our scriptlets are separate functions, so they share it on globalThis.
// Else each hook captures the earlier hooks as natives, and calls grow as 2^n. The uBO version is in the key, so other versions do not share it.
const safeSelf = index.get('safe-self.fn').fn;
const shareSafeSelf = `try {
const key = Symbol.for('safeSelf.${tagName}');
safeSelf.safe = globalThis[key];
if ( safeSelf.safe === undefined ) {
Object.defineProperty(globalThis, key, { value: safeSelf() });
}
} catch {
}
`;
// Every function once, by name; run() declares them all in one scope, so the scriptlets of an
// injection share one safeSelf() cache without touching globalThis, as in uBO.
const functions = new Map(scriptlets.map((scriptlet) => [scriptlet.fn.name, scriptlet.fn.toString()]));
const entries = scriptlets.filter((scriptlet) => scriptlet.name.endsWith('.js'));

console.log(`
/*******************************************************************************
Expand All @@ -54,38 +38,27 @@ console.log(`

*/
const scriptlets = {};

${scriptlets
.filter(scriptlet => scriptlet.name.endsWith('.js'))
.map((scriptlet) => {
const allDependencies = new Set();

const addDeps = (aScriptlet) => {
for (const dep of aScriptlet.dependencies || []) {
allDependencies.add(dep);
const bScriptlet = index.get(dep);
addDeps(bScriptlet);
}
};

addDeps(scriptlet);

const deps = [...allDependencies].reverse().map((dep) => index.get(dep).fn);

return `
${entries
.map(
(scriptlet) => `
scriptlets['${scriptlet.name}'] = {
aliases: ${JSON.stringify(scriptlet.aliases || [])},
${scriptlet.world ? `world: '${scriptlet.world}',` : '' }
${scriptlet.world ? `world: '${scriptlet.world}',` : ''}
requiresTrust: ${scriptlet.requiresTrust || false},
func: function (scriptletGlobals = {}, ...args) {
${deps.map((dep) => dep.toString()).join('\n')}
${scriptlet.fn.toString()};
${deps.includes(safeSelf) ? shareSafeSelf : ''}${scriptlet.fn.name}(...args);
},
fn: '${scriptlet.fn.name}',
};
`;
})
`,
)
.join('\n')}

// The calls come last, so the class dependencies, which are not hoisted, are declared by then.
export function run(scriptletGlobals, calls) {
${[...functions.values()].join('\n')}
const table = { ${entries.map((scriptlet) => scriptlet.fn.name).join(', ')} };
for (const [name, ...args] of calls) {
try { table[name](...args); } catch {}
}
}

export default scriptlets;
`);
3 changes: 2 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import SCRIPTLETS from './ubo.js';
import SCRIPTLETS, { run } from './ubo.js';

const scriptlets = {};

Expand All @@ -9,4 +9,5 @@ for (const [name, scriptlet] of Object.entries(SCRIPTLETS)) {
}
}

export { run };
export default scriptlets;
41 changes: 23 additions & 18 deletions test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { test, suite } from "node:test";
import assert from "node:assert";
import vm from "node:vm";
import scriptlets from "./index.js";
import scriptlets, { run } from "./index.js";

const runSource = run.toString();

test("default export is an object", () => {
assert(typeof scriptlets === "object");
Expand All @@ -11,8 +13,8 @@ test("each scriptlet has basic properties", () => {
for (const [name, scriptlet] of Object.entries(scriptlets)) {
assert(name.length > 0, `${name} - name is too short`);
assert(
scriptlet.func instanceof Function,
`${name} - func is not have a Function`
runSource.includes(`function ${scriptlet.fn}(`),
`${name} - fn does not name a function of run()`
);
assert(
scriptlet.aliases instanceof Array,
Expand All @@ -28,14 +30,16 @@ suite("uBO", () => {
});
});

suite("safeSelf() cache", () => {
suite("run()", () => {
// Each hook clones the argument with safe.JSON_parse(safe.JSON_stringify(obj))
const func = scriptlets["trusted-edit-inbound-object.js"].func;
const scriptlet = scriptlets["trusted-edit-inbound-object.js"];
const HOOKS = 6;
// Same code as the extension makes for each scriptlet; the first argument is scriptletGlobals
const hooks = Array.from({ length: HOOKS }, (_, i) =>
`(${func})(...${JSON.stringify([{}, "JSON.stringify", "0", `[?.hook${i}]+={"edited${i}":true}`])});`
);
const calls = Array.from({ length: HOOKS }, (_, i) => [
scriptlet.fn,
"JSON.stringify",
"0",
`[?.hook${i}]+={"edited${i}":true}`,
]);

// A fresh realm is the page; counters wrap the native JSON methods before the scriptlets run
function createPage() {
Expand All @@ -52,11 +56,14 @@ suite("safeSelf() cache", () => {
return page;
}

test("is shared in a realm, so stacked hooks stay linear", () => {
test("declares safeSelf() once", () => {
assert.strictEqual(runSource.match(/^function safeSelf\(/gm).length, 1);
});

test("shares one safeSelf() cache, so stacked hooks stay linear", () => {
const page = createPage();
for (const hook of hooks) {
vm.runInContext(hook, page);
}
// Same call as chrome.scripting.executeScript() makes: the function source with JSON arguments
vm.runInContext(`(${runSource})(...${JSON.stringify([{}, calls])});`, page);

const result = vm.runInContext(
"calls.parse = 0; calls.stringify = 0; JSON.stringify({ hook0: true, hook5: true });",
Expand All @@ -67,15 +74,13 @@ suite("safeSelf() cache", () => {
assert.deepStrictEqual({ ...page.calls }, { parse: HOOKS, stringify: HOOKS + 1 });
});

test("is read-only and not enumerable", () => {
test("leaves no global behind", () => {
const page = createPage();
vm.runInContext(hooks.join("\n"), page);
vm.runInContext(`(${runSource})(...${JSON.stringify([{}, calls])});`, page);

const keys = vm
.runInContext("Object.getOwnPropertySymbols(globalThis)", page)
.filter((key) => key.description.startsWith("safeSelf."));
assert.strictEqual(keys.length, 1);
const { value, ...flags } = Object.getOwnPropertyDescriptor(page, keys[0]);
assert.deepStrictEqual(flags, { writable: false, enumerable: false, configurable: false });
assert.strictEqual(keys.length, 0);
});
});
Loading
Loading