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) {