From aa7f77aaadc531ce1b5d002ebf455396916688ac Mon Sep 17 00:00:00 2001 From: Raymond Hill Date: Thu, 21 Dec 2023 10:48:01 -0500 Subject: [PATCH] Split scriptlet filtering engine into lo- and hi-level classes The idea is to remove as many dependencies as possible for low-level ScriptletFilteringEngine in order to make it easier to reuse the module outside uBO itself. The high-level derived class takes care of caching and injection of scriptlets into documents, which requires more knowledge about the environment in which scriptlets are to be used. Also improve scriptlet cache usage to minimize overhead of retrieving scriptlets. --- platform/common/vapi-background.js | 22 +- src/js/contentscript.js | 2 - src/js/cosmetic-filtering.js | 6 +- src/js/messaging.js | 17 +- src/js/mrucache.js | 58 ++++ src/js/scriptlet-filtering-core.js | 300 ++++++++++++++++++ src/js/scriptlet-filtering.js | 486 ++++++++--------------------- src/js/utils.js | 42 --- 8 files changed, 505 insertions(+), 428 deletions(-) create mode 100644 src/js/mrucache.js create mode 100644 src/js/scriptlet-filtering-core.js diff --git a/platform/common/vapi-background.js b/platform/common/vapi-background.js index 8e66bb642..0d6fcdd5d 100644 --- a/platform/common/vapi-background.js +++ b/platform/common/vapi-background.js @@ -1175,16 +1175,15 @@ vAPI.messaging = { const shortSecrets = []; let lastShortSecretTime = 0; - // Long secrets are meant to be used multiple times, but for at most a few - // minutes. The realm is one value out of 36^18 = over 10^28 values. - const longSecrets = [ '', '' ]; - let lastLongSecretTimeSlice = 0; + // Long secrets are valid until revoked or uBO restarts. The realm is one + // value out of 36^18 = over 10^28 values. + const longSecrets = new Set(); const guard = details => { const match = reSecret.exec(details.url); if ( match === null ) { return { cancel: true }; } const secret = match[1]; - if ( longSecrets.includes(secret) ) { return; } + if ( longSecrets.has(secret) ) { return; } const pos = shortSecrets.indexOf(secret); if ( pos === -1 ) { return { cancel: true }; } shortSecrets.splice(pos, 1); @@ -1212,14 +1211,13 @@ vAPI.messaging = { shortSecrets.push(secret); return secret; }, - long: ( ) => { - const timeSlice = Date.now() >>> 19; // Changes every ~9 minutes - if ( timeSlice !== lastLongSecretTimeSlice ) { - longSecrets[1] = longSecrets[0]; - longSecrets[0] = `${generateSecret()}${generateSecret()}${generateSecret()}`; - lastLongSecretTimeSlice = timeSlice; + long: previous => { + if ( previous !== undefined ) { + longSecrets.delete(previous); } - return longSecrets[0]; + const secret = `${generateSecret()}${generateSecret()}${generateSecret()}`; + longSecrets.add(secret); + return secret; }, }; } diff --git a/src/js/contentscript.js b/src/js/contentscript.js index fd6126831..8f3a4cfe4 100644 --- a/src/js/contentscript.js +++ b/src/js/contentscript.js @@ -1320,8 +1320,6 @@ vAPI.DOMFilterer = class { vAPI.userStylesheet.apply(); } - // Library of resources is located at: - // https://github.com/gorhill/uBlock/blob/master/assets/ublock/resources.txt if ( scriptletDetails && typeof self.uBO_scriptletsInjected !== 'string' ) { self.uBO_scriptletsInjected = scriptletDetails.filters; if ( scriptletDetails.mainWorld ) { diff --git a/src/js/cosmetic-filtering.js b/src/js/cosmetic-filtering.js index 994708183..f4782bc37 100644 --- a/src/js/cosmetic-filtering.js +++ b/src/js/cosmetic-filtering.js @@ -23,10 +23,10 @@ /******************************************************************************/ -import './utils.js'; import logger from './logger.js'; import µb from './background.js'; +import { MRUCache } from './mrucache.js'; import { StaticExtFilteringHostnameDB } from './static-ext-filtering-db.js'; /******************************************************************************/ @@ -244,13 +244,13 @@ const FilterContainer = function() { canonical: 'highGenericHideSimple', dict: new Set(), str: '', - mru: new µb.MRUCache(16) + mru: new MRUCache(16) }; this.highlyGeneric.complex = { canonical: 'highGenericHideComplex', dict: new Set(), str: '', - mru: new µb.MRUCache(16) + mru: new MRUCache(16) }; // Short-lived: content is valid only during one function call. These diff --git a/src/js/messaging.js b/src/js/messaging.js index 9137f2653..f63d16c0d 100644 --- a/src/js/messaging.js +++ b/src/js/messaging.js @@ -718,12 +718,17 @@ const retrieveContentScriptParameters = async function(sender, request) { if ( logger.enabled || request.needScriptlets ) { const scriptletDetails = scriptletFilteringEngine.injectNow(request); if ( scriptletDetails !== undefined ) { - if ( logger.enabled ) { - scriptletFilteringEngine.logFilters( - tabId, - request.url, - scriptletDetails.filters - ); + if ( logger.enabled && typeof scriptletDetails.filters === 'string' ) { + const fctxt = µb.filteringContext + .duplicate() + .fromTabId(tabId) + .setRealm('extended') + .setType('scriptlet') + .setURL(request.url) + .setDocOriginFromURL(request.url); + for ( const raw of scriptletDetails.filters.split('\n') ) { + fctxt.setFilter({ source: 'extended', raw }).toLogger(); + } } if ( request.needScriptlets ) { response.scriptletDetails = scriptletDetails; diff --git a/src/js/mrucache.js b/src/js/mrucache.js new file mode 100644 index 000000000..9a16047fa --- /dev/null +++ b/src/js/mrucache.js @@ -0,0 +1,58 @@ +/******************************************************************************* + + uBlock Origin - a comprehensive, efficient content blocker + Copyright (C) 2014-present Raymond Hill + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + Home: https://github.com/gorhill/uBlock +*/ + +'use strict'; + +export class MRUCache { + constructor(maxSize) { + this.maxSize = maxSize; + this.array = []; + this.map = new Map(); + this.resetTime = Date.now(); + } + add(key, value) { + const found = this.map.has(key); + this.map.set(key, value); + if ( found ) { return; } + if ( this.array.length === this.maxSize ) { + this.map.delete(this.array.pop()); + } + this.array.unshift(key); + } + remove(key) { + if ( this.map.delete(key) === false ) { return; } + this.array.splice(this.array.indexOf(key), 1); + } + lookup(key) { + const value = this.map.get(key); + if ( value === undefined ) { return; } + if ( this.array[0] === key ) { return value; } + const i = this.array.indexOf(key); + this.array.copyWithin(1, 0, i); + this.array[0] = key; + return value; + } + reset() { + this.array = []; + this.map.clear(); + this.resetTime = Date.now(); + } +} diff --git a/src/js/scriptlet-filtering-core.js b/src/js/scriptlet-filtering-core.js new file mode 100644 index 000000000..125eb87d5 --- /dev/null +++ b/src/js/scriptlet-filtering-core.js @@ -0,0 +1,300 @@ +/******************************************************************************* + + uBlock Origin - a comprehensive, efficient content blocker + Copyright (C) 2017-present Raymond Hill + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + Home: https://github.com/gorhill/uBlock +*/ + +'use strict'; + +/******************************************************************************/ + +import { redirectEngine as reng } from './redirect-engine.js'; +import { StaticExtFilteringHostnameDB } from './static-ext-filtering-db.js'; + +/******************************************************************************/ + +// Increment when internal representation changes +const VERSION = 1; + +const $scriptlets = new Set(); +const $exceptions = new Set(); +const $mainWorldMap = new Map(); +const $isolatedWorldMap = new Map(); + +/******************************************************************************/ + +const normalizeRawFilter = (parser, sourceIsTrusted = false) => { + const args = parser.getScriptletArgs(); + if ( args.length !== 0 ) { + let token = `${args[0]}.js`; + if ( reng.aliases.has(token) ) { + token = reng.aliases.get(token); + } + if ( parser.isException() !== true ) { + if ( sourceIsTrusted !== true ) { + if ( reng.tokenRequiresTrust(token) ) { return; } + } + } + args[0] = token.slice(0, -3); + } + return JSON.stringify(args); +}; + +const lookupScriptlet = (rawToken, mainMap, isolatedMap, debug = false) => { + if ( mainMap.has(rawToken) || isolatedMap.has(rawToken) ) { return; } + const args = JSON.parse(rawToken); + const token = `${args[0]}.js`; + const details = reng.contentFromName(token, 'text/javascript'); + if ( details === undefined ) { return; } + const targetWorldMap = details.world !== 'ISOLATED' ? mainMap : isolatedMap; + const content = patchScriptlet(details.js, args.slice(1)); + const dependencies = details.dependencies || []; + while ( dependencies.length !== 0 ) { + const token = dependencies.shift(); + if ( targetWorldMap.has(token) ) { continue; } + const details = reng.contentFromName(token, 'fn/javascript') || + reng.contentFromName(token, 'text/javascript'); + if ( details === undefined ) { continue; } + targetWorldMap.set(token, details.js); + if ( Array.isArray(details.dependencies) === false ) { continue; } + dependencies.push(...details.dependencies); + } + targetWorldMap.set(rawToken, [ + 'try {', + '// >>>> scriptlet start', + content, + '// <<<< scriptlet end', + '} catch (e) {', + debug ? 'console.error(e);' : '', + '}', + ].join('\n')); +}; + +// Fill-in scriptlet argument placeholders. +const patchScriptlet = (content, arglist) => { + if ( content.startsWith('function') && content.endsWith('}') ) { + content = `(${content})({{args}});`; + } + for ( let i = 0; i < arglist.length; i++ ) { + content = content.replace(`{{${i+1}}}`, arglist[i]); + } + return content.replace('{{args}}', + JSON.stringify(arglist).slice(1,-1).replace(/\$/g, '$$$') + ); +}; + +const decompile = json => { + const args = JSON.parse(json).map(s => s.replace(/,/g, '\\,')); + if ( args.length === 0 ) { return '+js()'; } + return `+js(${args.join(', ')})`; +}; + +/******************************************************************************/ + +export class ScriptletFilteringEngine { + constructor() { + this.acceptedCount = 0; + this.discardedCount = 0; + this.scriptletDB = new StaticExtFilteringHostnameDB(1, VERSION); + this.duplicates = new Set(); + } + + getFilterCount() { + return this.scriptletDB.size; + } + + reset() { + this.scriptletDB.clear(); + this.duplicates.clear(); + this.acceptedCount = 0; + this.discardedCount = 0; + } + + freeze() { + this.duplicates.clear(); + this.scriptletDB.collectGarbage(); + } + + // parser: instance of AstFilterParser from static-filtering-parser.js + // writer: instance of CompiledListWriter from static-filtering-io.js + compile(parser, writer) { + writer.select('SCRIPTLET_FILTERS'); + + // Only exception filters are allowed to be global. + const isException = parser.isException(); + const normalized = normalizeRawFilter(parser, writer.properties.get('trustedSource')); + + // Can fail if there is a mismatch with trust requirement + if ( normalized === undefined ) { return; } + + // Tokenless is meaningful only for exception filters. + if ( normalized === '[]' && isException === false ) { return; } + + if ( parser.hasOptions() === false ) { + if ( isException ) { + writer.push([ 32, '', 1, normalized ]); + } + return; + } + + // https://github.com/gorhill/uBlock/issues/3375 + // Ignore instances of exception filter with negated hostnames, + // because there is no way to create an exception to an exception. + + for ( const { hn, not, bad } of parser.getExtFilterDomainIterator() ) { + if ( bad ) { continue; } + let kind = 0; + if ( isException ) { + if ( not ) { continue; } + kind |= 1; + } else if ( not ) { + kind |= 1; + } + writer.push([ 32, hn, kind, normalized ]); + } + } + + // writer: instance of CompiledListReader from static-filtering-io.js + fromCompiledContent(reader) { + reader.select('SCRIPTLET_FILTERS'); + + while ( reader.next() ) { + this.acceptedCount += 1; + const fingerprint = reader.fingerprint(); + if ( this.duplicates.has(fingerprint) ) { + this.discardedCount += 1; + continue; + } + this.duplicates.add(fingerprint); + const args = reader.args(); + if ( args.length < 4 ) { continue; } + this.scriptletDB.store(args[1], args[2], args[3]); + } + } + + toSelfie() { + return this.scriptletDB.toSelfie(); + } + + fromSelfie(selfie) { + if ( selfie instanceof Object === false ) { return false; } + if ( selfie.version !== VERSION ) { return false; } + this.scriptletDB.fromSelfie(selfie); + return true; + } + + retrieve(request, options = {}) { + if ( this.scriptletDB.size === 0 ) { return; } + + $scriptlets.clear(); + $exceptions.clear(); + + const { hostname } = request; + + this.scriptletDB.retrieve(hostname, [ $scriptlets, $exceptions ]); + const entity = request.entity !== '' + ? `${hostname.slice(0, -request.domain.length)}${request.entity}` + : '*'; + this.scriptletDB.retrieve(entity, [ $scriptlets, $exceptions ], 1); + if ( $scriptlets.size === 0 ) { return; } + + // Wholly disable scriptlet injection? + if ( $exceptions.has('[]') ) { + return { filters: '#@#+js()' }; + } + + for ( const token of $exceptions ) { + if ( $scriptlets.has(token) ) { + $scriptlets.delete(token); + } else { + $exceptions.delete(token); + } + } + + for ( const token of $scriptlets ) { + lookupScriptlet(token, $mainWorldMap, $isolatedWorldMap, options.debug); + } + + const mainWorldCode = []; + for ( const js of $mainWorldMap.values() ) { + mainWorldCode.push(js); + } + + const isolatedWorldCode = []; + for ( const js of $isolatedWorldMap.values() ) { + isolatedWorldCode.push(js); + } + + const scriptletDetails = { + mainWorld: mainWorldCode.join('\n\n'), + isolatedWorld: isolatedWorldCode.join('\n\n'), + filters: [ + ...Array.from($scriptlets).map(s => `##${decompile(s)}`), + ...Array.from($exceptions).map(s => `#@#${decompile(s)}`), + ].join('\n'), + }; + $mainWorldMap.clear(); + $isolatedWorldMap.clear(); + + if ( scriptletDetails.mainWorld === '' ) { + if ( scriptletDetails.isolatedWorld === '' ) { + return { filters: scriptletDetails.filters }; + } + } + + const scriptletGlobals = options.scriptletGlobals || []; + + if ( options.debug ) { + scriptletGlobals.push([ 'canDebug', true ]); + } + + return { + mainWorld: scriptletDetails.mainWorld === '' ? '' : [ + '(function() {', + '// >>>> start of private namespace', + '', + options.debugScriptlets ? 'debugger;' : ';', + '', + // For use by scriptlets to share local data among themselves + `const scriptletGlobals = new Map(${JSON.stringify(scriptletGlobals, null, 2)});`, + '', + scriptletDetails.mainWorld, + '', + '// <<<< end of private namespace', + '})();', + ].join('\n'), + isolatedWorld: scriptletDetails.isolatedWorld === '' ? '' : [ + 'function() {', + '// >>>> start of private namespace', + '', + options.debugScriptlets ? 'debugger;' : ';', + '', + // For use by scriptlets to share local data among themselves + `const scriptletGlobals = new Map(${JSON.stringify(scriptletGlobals, null, 2)});`, + '', + scriptletDetails.isolatedWorld, + '', + '// <<<< end of private namespace', + '}', + ].join('\n'), + filters: scriptletDetails.filters, + }; + } +} + +/******************************************************************************/ diff --git a/src/js/scriptlet-filtering.js b/src/js/scriptlet-filtering.js index 69a06e44b..1e10db256 100644 --- a/src/js/scriptlet-filtering.js +++ b/src/js/scriptlet-filtering.js @@ -29,7 +29,8 @@ import µb from './background.js'; import { onBroadcast } from './broadcast.js'; import { redirectEngine as reng } from './redirect-engine.js'; import { sessionFirewall } from './filtering-engines.js'; -import { StaticExtFilteringHostnameDB } from './static-ext-filtering-db.js'; +import { MRUCache } from './mrucache.js'; +import { ScriptletFilteringEngine } from './scriptlet-filtering-core.js'; import { domainFromHostname, @@ -39,31 +40,6 @@ import { /******************************************************************************/ -// Increment when internal representation changes -const VERSION = 1; - -const duplicates = new Set(); -const scriptletCache = new µb.MRUCache(32); - -const scriptletDB = new StaticExtFilteringHostnameDB(1, VERSION); - -let acceptedCount = 0; -let discardedCount = 0; - -let isDevBuild; - -const scriptletFilteringEngine = { - get acceptedCount() { - return acceptedCount; - }, - get discardedCount() { - return discardedCount; - }, - getFilterCount() { - return scriptletDB.size; - }, -}; - const contentScriptRegisterer = new (class { constructor() { this.hostnameToDetails = new Map(); @@ -132,27 +108,7 @@ const contentScriptRegisterer = new (class { } })(); -// Purpose of `contentscriptCode` below is too programmatically inject -// content script code which only purpose is to inject scriptlets. This -// essentially does the same as what uBO's declarative content script does, -// except that this allows to inject the scriptlets earlier than it is -// possible through the declarative content script. -// -// Declaratively: -// 1. Browser injects generic content script => -// 2. Content script queries scriptlets => -// 3. Main process sends scriptlets => -// 4. Content script injects scriptlets -// -// Programmatically: -// 1. uBO injects specific scriptlets-aware content script => -// 2. Content script injects scriptlets -// -// However currently this programmatic injection works well only on -// Chromium-based browsers, it does not work properly with Firefox. More -// investigations is needed to find out why this fails with Firefox. -// Consequently, the programmatic-injection code path is taken only with -// Chromium-based browsers. +/******************************************************************************/ const mainWorldInjector = (( ) => { const parts = [ @@ -221,331 +177,135 @@ const isolatedWorldInjector = (( ) => { }; })(); -const normalizeRawFilter = function(parser, sourceIsTrusted = false) { - const args = parser.getScriptletArgs(); - if ( args.length !== 0 ) { - let token = `${args[0]}.js`; - if ( reng.aliases.has(token) ) { - token = reng.aliases.get(token); - } - if ( parser.isException() !== true ) { - if ( sourceIsTrusted !== true ) { - if ( reng.tokenRequiresTrust(token) ) { return; } - } - } - args[0] = token.slice(0, -3); - } - return JSON.stringify(args); -}; - -const lookupScriptlet = function(rawToken, mainMap, isolatedMap) { - if ( mainMap.has(rawToken) || isolatedMap.has(rawToken) ) { return; } - const args = JSON.parse(rawToken); - const token = `${args[0]}.js`; - const details = reng.contentFromName(token, 'text/javascript'); - if ( details === undefined ) { return; } - const targetWorldMap = details.world !== 'ISOLATED' ? mainMap : isolatedMap; - const content = patchScriptlet(details.js, args.slice(1)); - const dependencies = details.dependencies || []; - while ( dependencies.length !== 0 ) { - const token = dependencies.shift(); - if ( targetWorldMap.has(token) ) { continue; } - const details = reng.contentFromName(token, 'fn/javascript') || - reng.contentFromName(token, 'text/javascript'); - if ( details === undefined ) { continue; } - targetWorldMap.set(token, details.js); - if ( Array.isArray(details.dependencies) === false ) { continue; } - dependencies.push(...details.dependencies); - } - targetWorldMap.set(rawToken, [ - 'try {', - '// >>>> scriptlet start', - content, - '// <<<< scriptlet end', - '} catch (e) {', - isDevBuild ? 'console.error(e);' : '', - '}', - ].join('\n')); -}; - -// Fill-in scriptlet argument placeholders. -const patchScriptlet = function(content, arglist) { - if ( content.startsWith('function') && content.endsWith('}') ) { - content = `(${content})({{args}});`; - } - for ( let i = 0; i < arglist.length; i++ ) { - content = content.replace(`{{${i+1}}}`, arglist[i]); - } - return content.replace('{{args}}', - JSON.stringify(arglist).slice(1,-1).replace(/\$/g, '$$$') - ); -}; - -const decompile = function(json) { - const args = JSON.parse(json).map(s => s.replace(/,/g, '\\,')); - if ( args.length === 0 ) { return '+js()'; } - return `+js(${args.join(', ')})`; -}; - /******************************************************************************/ -scriptletFilteringEngine.logFilters = function(tabId, url, filters) { - if ( typeof filters !== 'string' ) { return; } - const fctxt = µb.filteringContext - .duplicate() - .fromTabId(tabId) - .setRealm('extended') - .setType('scriptlet') - .setURL(url) - .setDocOriginFromURL(url); - for ( const filter of filters.split('\n') ) { - fctxt.setFilter({ source: 'extended', raw: filter }).toLogger(); - } -}; - -scriptletFilteringEngine.reset = function() { - scriptletDB.clear(); - duplicates.clear(); - contentScriptRegisterer.reset(); - scriptletCache.reset(); - acceptedCount = 0; - discardedCount = 0; -}; - -scriptletFilteringEngine.freeze = function() { - duplicates.clear(); - scriptletDB.collectGarbage(); - scriptletCache.reset(); -}; - -scriptletFilteringEngine.compile = function(parser, writer) { - writer.select('SCRIPTLET_FILTERS'); - - // Only exception filters are allowed to be global. - const isException = parser.isException(); - const normalized = normalizeRawFilter(parser, writer.properties.get('trustedSource')); - - // Can fail if there is a mismatch with trust requirement - if ( normalized === undefined ) { return; } - - // Tokenless is meaningful only for exception filters. - if ( normalized === '[]' && isException === false ) { return; } - - if ( parser.hasOptions() === false ) { - if ( isException ) { - writer.push([ 32, '', 1, normalized ]); - } - return; - } - - // https://github.com/gorhill/uBlock/issues/3375 - // Ignore instances of exception filter with negated hostnames, - // because there is no way to create an exception to an exception. - - for ( const { hn, not, bad } of parser.getExtFilterDomainIterator() ) { - if ( bad ) { continue; } - let kind = 0; - if ( isException ) { - if ( not ) { continue; } - kind |= 1; - } else if ( not ) { - kind |= 1; - } - writer.push([ 32, hn, kind, normalized ]); - } -}; - -scriptletFilteringEngine.fromCompiledContent = function(reader) { - reader.select('SCRIPTLET_FILTERS'); - - while ( reader.next() ) { - acceptedCount += 1; - const fingerprint = reader.fingerprint(); - if ( duplicates.has(fingerprint) ) { - discardedCount += 1; - continue; - } - duplicates.add(fingerprint); - const args = reader.args(); - if ( args.length < 4 ) { continue; } - scriptletDB.store(args[1], args[2], args[3]); - } -}; - -const $scriptlets = new Set(); -const $exceptions = new Set(); -const $mainWorldMap = new Map(); -const $isolatedWorldMap = new Map(); - -scriptletFilteringEngine.retrieve = function(request) { - if ( scriptletDB.size === 0 ) { return; } - - const hostname = request.hostname; - - // https://github.com/gorhill/uBlock/issues/2835 - // Do not inject scriptlets if the site is under an `allow` rule. - if ( - µb.userSettings.advancedUserEnabled && - sessionFirewall.evaluateCellZY(hostname, hostname, '*') === 2 - ) { - return; - } - - if ( scriptletCache.resetTime < reng.modifyTime ) { - scriptletCache.reset(); - } - - let cacheDetails = scriptletCache.lookup(hostname); - if ( cacheDetails === undefined ) { - $scriptlets.clear(); - $exceptions.clear(); - - scriptletDB.retrieve(hostname, [ $scriptlets, $exceptions ]); - const entity = request.entity !== '' - ? `${hostname.slice(0, -request.domain.length)}${request.entity}` - : '*'; - scriptletDB.retrieve(entity, [ $scriptlets, $exceptions ], 1); - if ( $scriptlets.size === 0 ) { return; } - - // Wholly disable scriptlet injection? - if ( $exceptions.has('[]') ) { - return { filters: '#@#+js()' }; - } - - for ( const token of $exceptions ) { - if ( $scriptlets.has(token) ) { - $scriptlets.delete(token); - } else { - $exceptions.delete(token); - } - } - for ( const token of $scriptlets ) { - lookupScriptlet(token, $mainWorldMap, $isolatedWorldMap); - } - const mainWorldCode = []; - for ( const js of $mainWorldMap.values() ) { - mainWorldCode.push(js); - } - const isolatedWorldCode = []; - for ( const js of $isolatedWorldMap.values() ) { - isolatedWorldCode.push(js); - } - cacheDetails = { - mainWorld: mainWorldCode.join('\n\n'), - isolatedWorld: isolatedWorldCode.join('\n\n'), - filters: [ - ...Array.from($scriptlets).map(s => `##${decompile(s)}`), - ...Array.from($exceptions).map(s => `#@#${decompile(s)}`), - ].join('\n'), - }; - scriptletCache.add(hostname, cacheDetails); - $mainWorldMap.clear(); - $isolatedWorldMap.clear(); - } - - if ( cacheDetails.mainWorld === '' && cacheDetails.isolatedWorld === '' ) { - return { filters: cacheDetails.filters }; - } - - const scriptletGlobals = [ - [ 'warOrigin', vAPI.getURL('/web_accessible_resources') ], - [ 'warSecret', vAPI.warSecret.long() ], - ]; - - if ( isDevBuild === undefined ) { - isDevBuild = vAPI.webextFlavor.soup.has('devbuild'); - } - if ( isDevBuild || µb.hiddenSettings.filterAuthorMode ) { - scriptletGlobals.push([ 'canDebug', true ]); - } - - return { - mainWorld: cacheDetails.mainWorld === '' ? '' : [ - '(function() {', - '// >>>> start of private namespace', - '', - µb.hiddenSettings.debugScriptlets ? 'debugger;' : ';', - '', - // For use by scriptlets to share local data among themselves - `const scriptletGlobals = new Map(${JSON.stringify(scriptletGlobals, null, 2)});`, - '', - cacheDetails.mainWorld, - '', - '// <<<< end of private namespace', - '})();', - ].join('\n'), - isolatedWorld: cacheDetails.isolatedWorld === '' ? '' : [ - 'function() {', - '// >>>> start of private namespace', - '', - µb.hiddenSettings.debugScriptlets ? 'debugger;' : ';', - '', - // For use by scriptlets to share local data among themselves - `const scriptletGlobals = new Map(${JSON.stringify(scriptletGlobals, null, 2)});`, - '', - cacheDetails.isolatedWorld, - '', - '// <<<< end of private namespace', - '}', - ].join('\n'), - filters: cacheDetails.filters, - }; -}; - -scriptletFilteringEngine.injectNow = function(details) { - if ( typeof details.frameId !== 'number' ) { return; } - const request = { - tabId: details.tabId, - frameId: details.frameId, - url: details.url, - hostname: hostnameFromURI(details.url), - domain: undefined, - entity: undefined - }; - request.domain = domainFromHostname(request.hostname); - request.entity = entityFromDomain(request.domain); - const scriptletDetails = this.retrieve(request); - if ( scriptletDetails === undefined ) { - contentScriptRegisterer.unregister(request.hostname); - return; - } - const contentScript = []; - if ( µb.hiddenSettings.debugScriptletInjector ) { - contentScript.push('debugger'); - } - const { mainWorld = '', isolatedWorld = '', filters } = scriptletDetails; - if ( mainWorld !== '' ) { - contentScript.push(mainWorldInjector.assemble(request.hostname, mainWorld, filters)); - } - if ( isolatedWorld !== '' ) { - contentScript.push(isolatedWorldInjector.assemble(request.hostname, isolatedWorld)); - } - const code = contentScript.join('\n\n'); - const isAlreadyInjected = contentScriptRegisterer.register(request.hostname, code); - if ( isAlreadyInjected !== true ) { - vAPI.tabs.executeScript(details.tabId, { - code, - frameId: details.frameId, - matchAboutBlank: true, - runAt: 'document_start', +export class ScriptletFilteringEngineEx extends ScriptletFilteringEngine { + constructor() { + super(); + this.warOrigin = vAPI.getURL('/web_accessible_resources'); + this.warSecret = undefined; + this.scriptletCache = new MRUCache(32); + this.isDevBuild = undefined; + onBroadcast(msg => { + if ( msg.what !== 'hiddenSettingsChanged' ) { return; } + this.scriptletCache.reset(); + this.isDevBuild = undefined; }); } - return scriptletDetails; -}; -scriptletFilteringEngine.toSelfie = function() { - return scriptletDB.toSelfie(); -}; + reset() { + super.reset(); + this.warSecret = vAPI.warSecret.long(this.warSecret); + this.scriptletCache.reset(); + contentScriptRegisterer.reset(); + } -scriptletFilteringEngine.fromSelfie = function(selfie) { - if ( selfie instanceof Object === false ) { return false; } - if ( selfie.version !== VERSION ) { return false; } - scriptletDB.fromSelfie(selfie); - return true; -}; + freeze() { + super.freeze(); + this.warSecret = vAPI.warSecret.long(this.warSecret); + this.scriptletCache.reset(); + contentScriptRegisterer.reset(); + } + + retrieve(request) { + const { hostname } = request; + + // https://github.com/gorhill/uBlock/issues/2835 + // Do not inject scriptlets if the site is under an `allow` rule. + if ( µb.userSettings.advancedUserEnabled ) { + if ( sessionFirewall.evaluateCellZY(hostname, hostname, '*') === 2 ) { + return; + } + } + + if ( this.scriptletCache.resetTime < reng.modifyTime ) { + this.warSecret = vAPI.warSecret.long(this.warSecret); + this.scriptletCache.reset(); + } + + let scriptletDetails = this.scriptletCache.lookup(hostname); + if ( scriptletDetails !== undefined ) { + return scriptletDetails || undefined; + } + + if ( this.isDevBuild === undefined ) { + this.isDevBuild = vAPI.webextFlavor.soup.has('devbuild') || + µb.hiddenSettings.filterAuthorMode; + } + + if ( this.warSecret === undefined ) { + this.warSecret = vAPI.warSecret.long(); + } + + const options = { + scriptletGlobals: [ + [ 'warOrigin', this.warOrigin ], + [ 'warSecret', this.warSecret ], + ], + debug: this.isDevBuild, + debugScriptlets: µb.hiddenSettings.debugScriptlets, + }; + + scriptletDetails = super.retrieve(request, options); + + this.scriptletCache.add(hostname, scriptletDetails || null); + + return scriptletDetails; + } + + injectNow(details) { + if ( typeof details.frameId !== 'number' ) { return; } + + const request = { + tabId: details.tabId, + frameId: details.frameId, + url: details.url, + hostname: hostnameFromURI(details.url), + domain: undefined, + entity: undefined + }; + + request.domain = domainFromHostname(request.hostname); + request.entity = entityFromDomain(request.domain); + + const scriptletDetails = this.retrieve(request); + if ( scriptletDetails === undefined ) { + contentScriptRegisterer.unregister(request.hostname); + return; + } + + const contentScript = []; + if ( µb.hiddenSettings.debugScriptletInjector ) { + contentScript.push('debugger'); + } + const { mainWorld = '', isolatedWorld = '', filters } = scriptletDetails; + if ( mainWorld !== '' ) { + contentScript.push(mainWorldInjector.assemble(request.hostname, mainWorld, filters)); + } + if ( isolatedWorld !== '' ) { + contentScript.push(isolatedWorldInjector.assemble(request.hostname, isolatedWorld)); + } + + const code = contentScript.join('\n\n'); + + const isAlreadyInjected = contentScriptRegisterer.register(request.hostname, code); + if ( isAlreadyInjected !== true ) { + vAPI.tabs.executeScript(details.tabId, { + code, + frameId: details.frameId, + matchAboutBlank: true, + runAt: 'document_start', + }); + } + + return scriptletDetails; + } +} /******************************************************************************/ +const scriptletFilteringEngine = new ScriptletFilteringEngineEx(); + export default scriptletFilteringEngine; /******************************************************************************/ diff --git a/src/js/utils.js b/src/js/utils.js index e43ce73a0..e48e963e1 100644 --- a/src/js/utils.js +++ b/src/js/utils.js @@ -84,48 +84,6 @@ import µb from './background.js'; /******************************************************************************/ -µb.MRUCache = class { - constructor(size) { - this.size = size; - this.array = []; - this.map = new Map(); - this.resetTime = Date.now(); - } - add(key, value) { - const found = this.map.has(key); - this.map.set(key, value); - if ( !found ) { - if ( this.array.length === this.size ) { - this.map.delete(this.array.pop()); - } - this.array.unshift(key); - } - } - remove(key) { - if ( this.map.has(key) ) { - this.array.splice(this.array.indexOf(key), 1); - } - } - lookup(key) { - const value = this.map.get(key); - if ( value !== undefined && this.array[0] !== key ) { - let i = this.array.indexOf(key); - do { - this.array[i] = this.array[i-1]; - } while ( --i ); - this.array[0] = key; - } - return value; - } - reset() { - this.array = []; - this.map.clear(); - this.resetTime = Date.now(); - } -}; - -/******************************************************************************/ - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions µb.escapeRegex = function(s) {