/******************************************************************************* uBlock Origin - a browser extension to block requests. Copyright (C) 2022-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 */ /* jshint esversion:11 */ 'use strict'; /******************************************************************************/ import { browser, dnr, i18n } from './ext.js'; import { fetchJSON } from './fetch.js'; import { ubolLog } from './utils.js'; /******************************************************************************/ const RULE_REALM_SIZE = 1000000; const REGEXES_REALM_START = 1000000; const REGEXES_REALM_END = REGEXES_REALM_START + RULE_REALM_SIZE; const REMOVEPARAMS_REALM_START = REGEXES_REALM_END; const REMOVEPARAMS_REALM_END = REMOVEPARAMS_REALM_START + RULE_REALM_SIZE; const REDIRECT_REALM_START = REMOVEPARAMS_REALM_END; const REDIRECT_REALM_END = REDIRECT_REALM_START + RULE_REALM_SIZE; const MODIFYHEADERS_REALM_START = REDIRECT_REALM_END; const MODIFYHEADERS_REALM_END = MODIFYHEADERS_REALM_START + RULE_REALM_SIZE; const TRUSTED_DIRECTIVE_BASE_RULE_ID = 8000000; /******************************************************************************/ function getRulesetDetails() { if ( getRulesetDetails.rulesetDetailsPromise !== undefined ) { return getRulesetDetails.rulesetDetailsPromise; } getRulesetDetails.rulesetDetailsPromise = fetchJSON('/rulesets/ruleset-details').then(entries => { const rulesMap = new Map( entries.map(entry => [ entry.id, entry ]) ); return rulesMap; }); return getRulesetDetails.rulesetDetailsPromise; } /******************************************************************************/ function getDynamicRules() { if ( getDynamicRules.dynamicRuleMapPromise !== undefined ) { return getDynamicRules.dynamicRuleMapPromise; } getDynamicRules.dynamicRuleMapPromise = dnr.getDynamicRules().then(rules => { const rulesMap = new Map(rules.map(rule => [ rule.id, rule ])); ubolLog(`Dynamic rule count: ${rulesMap.size}`); ubolLog(`Available dynamic rule count: ${dnr.MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES - rulesMap.size}`); return rulesMap; }); return getDynamicRules.dynamicRuleMapPromise; } /******************************************************************************/ async function pruneInvalidRegexRules(realm, rulesIn) { // Avoid testing already tested regexes const dynamicRules = await dnr.getDynamicRules(); const validRegexSet = new Set( dynamicRules.filter(rule => rule.condition?.regexFilter && true || false ).map(rule => rule.condition.regexFilter ) ); // Validate regex-based rules const toCheck = []; const rejectedRegexRules = []; for ( const rule of rulesIn ) { if ( rule.condition?.regexFilter === undefined ) { toCheck.push(true); continue; } const { regexFilter: regex, isUrlFilterCaseSensitive: isCaseSensitive } = rule.condition; if ( validRegexSet.has(regex) ) { toCheck.push(true); continue; } toCheck.push( dnr.isRegexSupported({ regex, isCaseSensitive }).then(result => { if ( result.isSupported ) { return true; } rejectedRegexRules.push(`\t${regex} ${result.reason}`); return false; }) ); } // Collate results const isValid = await Promise.all(toCheck); if ( rejectedRegexRules.length !== 0 ) { ubolLog( `${realm} realm: rejected regexes:\n`, rejectedRegexRules.join('\n') ); } return rulesIn.filter((v, i) => isValid[i]); } /******************************************************************************/ async function updateRegexRules() { const rulesetDetails = await getEnabledRulesetsDetails(); // Fetch regexes for all enabled rulesets const toFetch = []; for ( const details of rulesetDetails ) { if ( details.rules.regex === 0 ) { continue; } toFetch.push(fetchJSON(`/rulesets/regex/${details.id}`)); } const regexRulesets = await Promise.all(toFetch); // Collate all regexes rules const allRules = []; let regexRuleId = REGEXES_REALM_START; for ( const rules of regexRulesets ) { if ( Array.isArray(rules) === false ) { continue; } for ( const rule of rules ) { rule.id = regexRuleId++; allRules.push(rule); } } const validatedRules = await pruneInvalidRegexRules('regexes', allRules); // Add validated regex rules to dynamic ruleset without affecting rules // outside regex rules realm. const dynamicRuleMap = await getDynamicRules(); const newRuleMap = new Map(validatedRules.map(rule => [ rule.id, rule ])); const addRules = []; const removeRuleIds = []; for ( const oldRule of dynamicRuleMap.values() ) { if ( oldRule.id < REGEXES_REALM_START ) { continue; } if ( oldRule.id >= REGEXES_REALM_END ) { continue; } const newRule = newRuleMap.get(oldRule.id); if ( newRule === undefined ) { removeRuleIds.push(oldRule.id); dynamicRuleMap.delete(oldRule.id); } else if ( JSON.stringify(oldRule) !== JSON.stringify(newRule) ) { removeRuleIds.push(oldRule.id); addRules.push(newRule); dynamicRuleMap.set(oldRule.id, newRule); } } for ( const newRule of newRuleMap.values() ) { if ( dynamicRuleMap.has(newRule.id) ) { continue; } addRules.push(newRule); dynamicRuleMap.set(newRule.id, newRule); } if ( addRules.length === 0 && removeRuleIds.length === 0 ) { return; } if ( removeRuleIds.length !== 0 ) { ubolLog(`Remove ${removeRuleIds.length} DNR regex rules`); } if ( addRules.length !== 0 ) { ubolLog(`Add ${addRules.length} DNR regex rules`); } return dnr.updateDynamicRules({ addRules, removeRuleIds }).catch(reason => { console.error(`updateRegexRules() / ${reason}`); }); } /******************************************************************************/ async function updateRemoveparamRules() { const [ hasOmnipotence, rulesetDetails, dynamicRuleMap, ] = await Promise.all([ browser.permissions.contains({ origins: [ '' ] }), getEnabledRulesetsDetails(), getDynamicRules(), ]); // Fetch removeparam rules for all enabled rulesets const toFetch = []; for ( const details of rulesetDetails ) { if ( details.rules.removeparam === 0 ) { continue; } toFetch.push(fetchJSON(`/rulesets/removeparam/${details.id}`)); } const removeparamRulesets = await Promise.all(toFetch); // Removeparam rules can only be enforced with omnipotence const allRules = []; if ( hasOmnipotence ) { let removeparamRuleId = REMOVEPARAMS_REALM_START; for ( const rules of removeparamRulesets ) { if ( Array.isArray(rules) === false ) { continue; } for ( const rule of rules ) { rule.id = removeparamRuleId++; allRules.push(rule); } } } const validatedRules = await pruneInvalidRegexRules('removeparam', allRules); // Add removeparam rules to dynamic ruleset without affecting rules // outside removeparam rules realm. const newRuleMap = new Map(validatedRules.map(rule => [ rule.id, rule ])); const addRules = []; const removeRuleIds = []; for ( const oldRule of dynamicRuleMap.values() ) { if ( oldRule.id < REMOVEPARAMS_REALM_START ) { continue; } if ( oldRule.id >= REMOVEPARAMS_REALM_END ) { continue; } const newRule = newRuleMap.get(oldRule.id); if ( newRule === undefined ) { removeRuleIds.push(oldRule.id); dynamicRuleMap.delete(oldRule.id); } else if ( JSON.stringify(oldRule) !== JSON.stringify(newRule) ) { removeRuleIds.push(oldRule.id); addRules.push(newRule); dynamicRuleMap.set(oldRule.id, newRule); } } for ( const newRule of newRuleMap.values() ) { if ( dynamicRuleMap.has(newRule.id) ) { continue; } addRules.push(newRule); dynamicRuleMap.set(newRule.id, newRule); } if ( addRules.length === 0 && removeRuleIds.length === 0 ) { return; } if ( removeRuleIds.length !== 0 ) { ubolLog(`Remove ${removeRuleIds.length} DNR removeparam rules`); } if ( addRules.length !== 0 ) { ubolLog(`Add ${addRules.length} DNR removeparam rules`); } return dnr.updateDynamicRules({ addRules, removeRuleIds }).catch(reason => { console.error(`updateRemoveparamRules() / ${reason}`); }); } /******************************************************************************/ async function updateRedirectRules() { const [ hasOmnipotence, rulesetDetails, dynamicRuleMap, ] = await Promise.all([ browser.permissions.contains({ origins: [ '' ] }), getEnabledRulesetsDetails(), getDynamicRules(), ]); // Fetch redirect rules for all enabled rulesets const toFetch = []; for ( const details of rulesetDetails ) { if ( details.rules.redirect === 0 ) { continue; } toFetch.push(fetchJSON(`/rulesets/redirect/${details.id}`)); } const redirectRulesets = await Promise.all(toFetch); // Redirect rules can only be enforced with omnipotence const allRules = []; if ( hasOmnipotence ) { let redirectRuleId = REDIRECT_REALM_START; for ( const rules of redirectRulesets ) { if ( Array.isArray(rules) === false ) { continue; } for ( const rule of rules ) { rule.id = redirectRuleId++; allRules.push(rule); } } } const validatedRules = await pruneInvalidRegexRules('redirect', allRules); // Add redirect rules to dynamic ruleset without affecting rules // outside redirect rules realm. const newRuleMap = new Map(validatedRules.map(rule => [ rule.id, rule ])); const addRules = []; const removeRuleIds = []; for ( const oldRule of dynamicRuleMap.values() ) { if ( oldRule.id < REDIRECT_REALM_START ) { continue; } if ( oldRule.id >= REDIRECT_REALM_END ) { continue; } const newRule = newRuleMap.get(oldRule.id); if ( newRule === undefined ) { removeRuleIds.push(oldRule.id); dynamicRuleMap.delete(oldRule.id); } else if ( JSON.stringify(oldRule) !== JSON.stringify(newRule) ) { removeRuleIds.push(oldRule.id); addRules.push(newRule); dynamicRuleMap.set(oldRule.id, newRule); } } for ( const newRule of newRuleMap.values() ) { if ( dynamicRuleMap.has(newRule.id) ) { continue; } addRules.push(newRule); dynamicRuleMap.set(newRule.id, newRule); } if ( addRules.length === 0 && removeRuleIds.length === 0 ) { return; } if ( removeRuleIds.length !== 0 ) { ubolLog(`Remove ${removeRuleIds.length} DNR redirect rules`); } if ( addRules.length !== 0 ) { ubolLog(`Add ${addRules.length} DNR redirect rules`); } return dnr.updateDynamicRules({ addRules, removeRuleIds }).catch(reason => { console.error(`updateRedirectRules() / ${reason}`); }); } /******************************************************************************/ async function updateModifyHeadersRules() { const [ hasOmnipotence, rulesetDetails, dynamicRuleMap, ] = await Promise.all([ browser.permissions.contains({ origins: [ '' ] }), getEnabledRulesetsDetails(), getDynamicRules(), ]); // Fetch modifyHeaders rules for all enabled rulesets const toFetch = []; for ( const details of rulesetDetails ) { if ( details.rules.modifyHeaders === 0 ) { continue; } toFetch.push(fetchJSON(`/rulesets/modify-headers/${details.id}`)); } const rulesets = await Promise.all(toFetch); // Redirect rules can only be enforced with omnipotence const allRules = []; if ( hasOmnipotence ) { let ruleId = MODIFYHEADERS_REALM_START; for ( const rules of rulesets ) { if ( Array.isArray(rules) === false ) { continue; } for ( const rule of rules ) { rule.id = ruleId++; allRules.push(rule); } } } const validatedRules = await pruneInvalidRegexRules('modify-headers', allRules); // Add modifyHeaders rules to dynamic ruleset without affecting rules // outside modifyHeaders realm. const newRuleMap = new Map(validatedRules.map(rule => [ rule.id, rule ])); const addRules = []; const removeRuleIds = []; for ( const oldRule of dynamicRuleMap.values() ) { if ( oldRule.id < MODIFYHEADERS_REALM_START ) { continue; } if ( oldRule.id >= MODIFYHEADERS_REALM_END ) { continue; } const newRule = newRuleMap.get(oldRule.id); if ( newRule === undefined ) { removeRuleIds.push(oldRule.id); dynamicRuleMap.delete(oldRule.id); } else if ( JSON.stringify(oldRule) !== JSON.stringify(newRule) ) { removeRuleIds.push(oldRule.id); addRules.push(newRule); dynamicRuleMap.set(oldRule.id, newRule); } } for ( const newRule of newRuleMap.values() ) { if ( dynamicRuleMap.has(newRule.id) ) { continue; } addRules.push(newRule); dynamicRuleMap.set(newRule.id, newRule); } if ( addRules.length === 0 && removeRuleIds.length === 0 ) { return; } if ( removeRuleIds.length !== 0 ) { ubolLog(`Remove ${removeRuleIds.length} DNR modifyHeaders rules`); } if ( addRules.length !== 0 ) { ubolLog(`Add ${addRules.length} DNR modifyHeaders rules`); } return dnr.updateDynamicRules({ addRules, removeRuleIds }).catch(reason => { console.error(`updateModifyHeadersRules() / ${reason}`); }); } /******************************************************************************/ // TODO: group all omnipotence-related rules into one realm. async function updateDynamicRules() { return Promise.all([ updateRegexRules(), updateRemoveparamRules(), updateRedirectRules(), updateModifyHeadersRules(), ]); } /******************************************************************************/ async function defaultRulesetsFromLanguage() { const out = [ 'default', 'cname-trackers' ]; const dropCountry = lang => { const pos = lang.indexOf('-'); if ( pos === -1 ) { return lang; } return lang.slice(0, pos); }; const langSet = new Set(); for ( const lang of navigator.languages.map(dropCountry) ) { langSet.add(lang); } langSet.add(dropCountry(i18n.getUILanguage())); const reTargetLang = new RegExp( `\\b(${Array.from(langSet).join('|')})\\b` ); const rulesetDetails = await getRulesetDetails(); for ( const [ id, details ] of rulesetDetails ) { if ( typeof details.lang !== 'string' ) { continue; } if ( reTargetLang.test(details.lang) === false ) { continue; } out.push(id); } return out; } /******************************************************************************/ async function enableRulesets(ids) { const afterIds = new Set(ids); const beforeIds = new Set(await dnr.getEnabledRulesets()); const enableRulesetSet = new Set(); const disableRulesetSet = new Set(); for ( const id of afterIds ) { if ( beforeIds.has(id) ) { continue; } enableRulesetSet.add(id); } for ( const id of beforeIds ) { if ( afterIds.has(id) ) { continue; } disableRulesetSet.add(id); } if ( enableRulesetSet.size === 0 && disableRulesetSet.size === 0 ) { return; } // Be sure the rulesets to enable/disable do exist in the current version, // otherwise the API throws. const rulesetDetails = await getRulesetDetails(); for ( const id of enableRulesetSet ) { if ( rulesetDetails.has(id) ) { continue; } enableRulesetSet.delete(id); } for ( const id of disableRulesetSet ) { if ( rulesetDetails.has(id) ) { continue; } disableRulesetSet.delete(id); } const enableRulesetIds = Array.from(enableRulesetSet); const disableRulesetIds = Array.from(disableRulesetSet); if ( enableRulesetIds.length !== 0 ) { ubolLog(`Enable rulesets: ${enableRulesetIds}`); } if ( disableRulesetIds.length !== 0 ) { ubolLog(`Disable ruleset: ${disableRulesetIds}`); } await dnr.updateEnabledRulesets({ enableRulesetIds, disableRulesetIds }); return updateDynamicRules(); } /******************************************************************************/ async function getEnabledRulesetsDetails() { const [ ids, rulesetDetails, ] = await Promise.all([ dnr.getEnabledRulesets(), getRulesetDetails(), ]); const out = []; for ( const id of ids ) { const ruleset = rulesetDetails.get(id); if ( ruleset === undefined ) { continue; } out.push(ruleset); } return out; } /******************************************************************************/ export { TRUSTED_DIRECTIVE_BASE_RULE_ID, getRulesetDetails, getDynamicRules, enableRulesets, defaultRulesetsFromLanguage, getEnabledRulesetsDetails, updateDynamicRules, };