/******************************************************************************* uBlock Origin - a comprehensive, efficient content blocker 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 */ import * as makeScriptlet from './make-scriptlets.js'; import * as sfp from './js/static-filtering-parser.js'; import { createHash, randomBytes, } from 'crypto'; import { dnrRulesetFromRawLists, mergeRules, } from './js/static-dnr-filtering.js'; import { execSync } from 'node:child_process'; import fs from 'fs/promises'; import path from 'path'; import process from 'process'; import redirectResourcesMap from './js/redirect-resources.js'; import { safeReplace } from './safe-replace.js'; /******************************************************************************/ const commandLineArgs = (( ) => { const args = new Map(); let name, value; for ( const arg of process.argv.slice(2) ) { const pos = arg.indexOf('='); if ( pos === -1 ) { name = arg; value = ''; } else { name = arg.slice(0, pos); value = arg.slice(pos+1); } args.set(name, value); } return args; })(); const platform = commandLineArgs.get('platform') || 'chromium'; const outputDir = commandLineArgs.get('output') || '.'; const cacheDir = `${outputDir}/../mv3-data`; const rulesetDir = `${outputDir}/rulesets`; const scriptletDir = `${rulesetDir}/scripting`; const env = [ platform, 'native_css_has', 'mv3', 'ublock', 'ubol', 'user_stylesheet', ]; if ( platform === 'edge' ) { env.push('chromium'); } /******************************************************************************/ const jsonSetMapReplacer = (k, v) => { if ( v instanceof Set || v instanceof Map ) { if ( v.size === 0 ) { return; } return Array.from(v); } return v; }; const uidint32 = (s) => { const h = createHash('sha256').update(s).digest('hex').slice(0,8); return parseInt(h,16) & 0x7FFFFFFF; }; /******************************************************************************/ const consoleLog = console.log; const stdOutput = []; const log = (text, silent = true) => { stdOutput.push(text); if ( silent === false ) { consoleLog(text); } }; console.log = log; const logProgress = text => { process?.stdout?.clearLine?.(); process?.stdout?.cursorTo?.(0); process?.stdout?.write?.(text.length > 120 ? `${text.slice(0, 119)}… ` : `${text} `); }; /******************************************************************************/ async function fetchText(url, cacheDir) { logProgress(`Reading locally cached ${url}`); const fname = url .replace(/^https?:\/\//, '') .replace(/\//g, '_');(url); const content = await fs.readFile( `${cacheDir}/${fname}`, { encoding: 'utf8' } ).catch(( ) => { }); if ( content !== undefined ) { log(`\tFetched local ${url}`); return { url, content }; } logProgress(`Fetching remote ${url}`); log(`\tFetching remote ${url}`); const response = await fetch(url).catch(( ) => { }); if ( response === undefined ) { return { url, error: `Fetching failed: ${url}` }; } let text; if ( response.ok ) { text = await response.text().catch(( ) => { }); } else { text = await fallbackFetchText(url).catch(( ) => { }); } if ( text === undefined ) { return { url, error: `Fetching text content failed: ${url}` }; } writeFile(`${cacheDir}/${fname}`, text); return { url, content: text }; } async function fallbackFetchText(url) { const match = /^https:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/master\/([^?]+)/.exec(url); if ( match === null ) { return; } logProgress(`\tGitHub CLI-fetching remote ${url}`); // https://docs.github.com/en/rest/repos/contents const content = execSync(`gh api \ -H "Accept: application/vnd.github.raw+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ /repos/${match[1]}/${match[2]}/contents/${match[3]} \ `, { encoding: 'utf8' }); return content; } /******************************************************************************/ const writeFile = async (fname, data) => { const dir = path.dirname(fname); await fs.mkdir(dir, { recursive: true }); const promise = fs.writeFile(fname, data); writeOps.push(promise); return promise; }; const copyFile = async (from, to) => { const dir = path.dirname(to); await fs.mkdir(dir, { recursive: true }); const promise = fs.copyFile(from, to); writeOps.push(promise); return promise; }; const writeOps = []; /******************************************************************************/ const ruleResources = []; const rulesetDetails = []; const scriptletStats = new Map(); const genericDetails = new Map(); const requiredRedirectResources = new Set(); let networkBad = new Set(); // This will be used to sign our inserted `!#trusted on` directives const secret = createHash('sha256').update(randomBytes(16)).digest('hex').slice(0,16); log(`Secret: ${secret}`, false); /******************************************************************************/ const restrSeparator = '(?:[^%.0-9a-z_-]|$)'; const rePatternFromUrlFilter = s => { let anchor = 0b000; if ( s.startsWith('||') ) { anchor = 0b100; s = s.slice(2); } else if ( s.startsWith('|') ) { anchor = 0b010; s = s.slice(1); } if ( s.endsWith('|') ) { anchor |= 0b001; s = s.slice(0, -1); } let reStr = s.replace(rePatternFromUrlFilter.rePlainChars, '\\$&') .replace(rePatternFromUrlFilter.reSeparators, restrSeparator) .replace(rePatternFromUrlFilter.reDanglingAsterisks, '') .replace(rePatternFromUrlFilter.reAsterisks, '\\S*?'); if ( anchor & 0b100 ) { reStr = ( reStr.startsWith('\\.') ? rePatternFromUrlFilter.restrHostnameAnchor2 : rePatternFromUrlFilter.restrHostnameAnchor1 ) + reStr; } else if ( anchor & 0b010 ) { reStr = '^' + reStr; } if ( anchor & 0b001 ) { reStr += '$'; } return reStr; }; rePatternFromUrlFilter.rePlainChars = /[.+?${}()|[\]\\]/g; rePatternFromUrlFilter.reSeparators = /\^/g; rePatternFromUrlFilter.reDanglingAsterisks = /^\*+|\*+$/g; rePatternFromUrlFilter.reAsterisks = /\*+/g; rePatternFromUrlFilter.restrHostnameAnchor1 = '^[a-z-]+://(?:[^/?#]+\\.)?'; rePatternFromUrlFilter.restrHostnameAnchor2 = '^[a-z-]+://(?:[^/?#]+)?'; /******************************************************************************/ async function fetchList(assetDetails) { // Remember fetched URLs const fetchedURLs = new Set(); // Fetch list and expand `!#include` directives let parts = assetDetails.urls.map(url => ({ url })); while ( parts.every(v => typeof v === 'string') === false ) { const newParts = []; for ( const part of parts ) { if ( typeof part === 'string' ) { newParts.push(part); continue; } if ( fetchedURLs.has(part.url) ) { newParts.push(''); continue; } fetchedURLs.add(part.url); if ( assetDetails.trusted || part.url.startsWith('https://ublockorigin.github.io/uAssets/filters/') ) { newParts.push(`!#trusted on ${secret}`); } newParts.push( fetchText(part.url, cacheDir).then(details => { const { url, error } = details; if ( error !== undefined ) { return details; } const content = details.content.trim(); if ( /* content === '' || */ /^<.*>$/.test(content) ) { return { url, error: `Bad content: ${url}` }; } return { url, content }; }) ); newParts.push(`!#trusted off ${secret}`); } if ( parts.some(v => typeof v === 'object' && v.error) ) { return; } parts = await Promise.all(newParts); parts = sfp.utils.preparser.expandIncludes(parts, env); } const text = parts.join('\n'); if ( text === '' ) { log('No filterset found', false); } return text; } /******************************************************************************/ const isUnsupported = rule => rule._error !== undefined; const isRegex = rule => rule.condition !== undefined && rule.condition.regexFilter !== undefined; const isRedirect = rule => { if ( isUnsupported(rule) ) { return false; } if ( rule.action.type !== 'redirect' ) { return false; } if ( rule.action.redirect?.extensionPath !== undefined ) { return true; } if ( rule.action.redirect?.transform?.path !== undefined ) { return true; } if ( rule.action.redirect?.regexSubstitution !== undefined ) { return true; } return false; }; const isModifyHeaders = rule => isUnsupported(rule) === false && rule.action.type === 'modifyHeaders'; const isRemoveparam = rule => isUnsupported(rule) === false && rule.action.type === 'redirect' && rule.action.redirect.transform !== undefined; const isSafe = rule => isUnsupported(rule) === false && rule.action !== undefined && ( rule.action.type === 'block' || rule.action.type === 'allow' || rule.action.type === 'allowAllRequests' ); const isURLSkip = rule => isUnsupported(rule) === false && rule.action !== undefined && rule.action.type === 'urlskip'; /******************************************************************************/ async function patchRuleset(ruleset) { return import(`./${platform}/patch-ruleset.js`).then(module => { return module.patchRuleset(ruleset) }).catch(( ) => { return ruleset; }); } /******************************************************************************/ // Two distinct hostnames: // www.example.com // example.com // Can be reduced to a single one: // example.com // Since if example.com matches, then www.example.com (or any other subdomain // of example.com) will always match. function pruneHostnameArray(hostnames) { const rootMap = new Map(); for ( const hostname of hostnames ) { const labels = hostname.split('.'); let currentMap = rootMap; let i = labels.length; while ( i-- ) { const label = labels[i]; let nextMap = currentMap.get(label); if ( nextMap === null ) { break; } if ( nextMap === undefined ) { if ( i === 0 ) { currentMap.set(label, (nextMap = null)); } else { currentMap.set(label, (nextMap = new Map())); } } else if ( i === 0 ) { currentMap.set(label, null); } currentMap = nextMap; } } const assemble = (currentMap, currentHostname, out) => { for ( const [ label, nextMap ] of currentMap ) { const nextHostname = currentHostname === '' ? label : `${label}.${currentHostname}`; if ( nextMap === null ) { out.push(nextHostname); } else { assemble(nextMap, nextHostname, out); } } return out; }; return assemble(rootMap, '', []); } /******************************************************************************* * * For large rulesets, one rule per line for compromise between size and * readability. This also means that the number of lines in resulting file * representative of the number of rules in the ruleset. * * */ function toJSONRuleset(ruleset) { const nodupProps = [ 'domains', 'excludedDomains', 'requestDomains', 'excludedRequestDomains', 'initiatorDomains', 'excludedInitiatorDomains' ]; for ( const { condition } of ruleset ) { if ( condition === undefined ) { continue; } for ( const prop of nodupProps ) { if ( condition[prop] === undefined ) { continue; } condition[prop] = Array.from(new Set(condition[prop])); } } const sortProps = [ 'requestDomains', 'initiatorDomains', 'domains' ]; ruleset.sort((a, b) => { let aLen = 0, bLen = 0; for ( const prop of sortProps ) { aLen += a.condition[prop]?.length ?? 0; bLen += b.condition[prop]?.length ?? 0; } return bLen - aLen; }); const replacer = (k, v) => { if ( k.startsWith('_') ) { return; } if ( Array.isArray(v) ) { return v.sort(); } if ( v instanceof Object ) { const sorted = {}; for ( const kk of Object.keys(v).sort() ) { sorted[kk] = v[kk]; } return sorted; } return v; }; const indent = ruleset.length > 10 ? undefined : 1; const out = []; let id = 1; for ( const rule of ruleset ) { rule.id = id++; out.push(JSON.stringify(rule, replacer, indent)); } return `[\n${out.join(',\n')}\n]\n`; } /******************************************************************************/ function toStrictBlockRule(rule, out) { if ( rule.action.type !== 'block' ) { return; } const { condition } = rule; if ( condition === undefined ) { return; } if ( condition.domainType ) { return; } if ( condition.excludedResourceTypes ) { return; } if ( condition.requestMethods ) { return; } if ( condition.excludedRequestMethods ) { return; } if ( condition.responseHeaders ) { return; } if ( condition.excludedResponseHeaders ) { return; } if ( condition.initiatorDomains ) { return; } if ( condition.excludedInitiatorDomains ) { return; } const { resourceTypes } = condition; if ( resourceTypes === undefined ) { if ( condition.requestDomains === undefined ) { return; } } else if ( resourceTypes.includes('main_frame') === false ) { return; } let regexFilter; if ( condition.urlFilter ) { regexFilter = rePatternFromUrlFilter(condition.urlFilter); } else if ( condition.regexFilter ) { regexFilter = condition.regexFilter; } else { regexFilter = '^https?://.*'; } if ( regexFilter.startsWith('^') === false ) { regexFilter = `^.*${regexFilter}`; } if ( regexFilter.endsWith('$') === false && regexFilter.endsWith('.*') === false && regexFilter.endsWith('.+') === false ) { regexFilter = `${regexFilter}.*`; } const strictBlockRule = out.get(regexFilter) || { action: { type: 'redirect', redirect: { regexSubstitution: `/strictblock.html#\\0`, }, }, condition: { regexFilter, resourceTypes: [ 'main_frame' ], }, priority: 29, }; if ( condition.requestDomains ) { strictBlockRule.condition.requestDomains ??= []; strictBlockRule.condition.requestDomains.push(...condition.requestDomains); } if ( condition.excludedRequestDomains ) { strictBlockRule.condition.excludedRequestDomains ??= []; strictBlockRule.condition.excludedRequestDomains.push(...condition.excludedRequestDomains); } out.set(regexFilter, strictBlockRule); } toStrictBlockRule.ruleId = 1; /******************************************************************************/ async function processNetworkFilters(assetDetails, network) { const { ruleset: rules } = network; log(`Input filter count: ${network.filterCount}`); log(`\tAccepted filter count: ${network.acceptedFilterCount}`); log(`\tRejected filter count: ${network.rejectedFilterCount}`); log(`Output rule count: ${rules.length}`); // Minimize requestDomains arrays for ( const rule of rules ) { const condition = rule.condition; if ( condition === undefined ) { continue; } const requestDomains = condition.requestDomains; if ( requestDomains === undefined ) { continue; } const beforeCount = requestDomains.length; condition.requestDomains = pruneHostnameArray(requestDomains); const afterCount = condition.requestDomains.length; if ( afterCount !== beforeCount ) { log(`\tPruning requestDomains: from ${beforeCount} to ${afterCount}`); } } // Add native DNR ruleset if present if ( assetDetails.dnrURL ) { const result = await fetchText(assetDetails.dnrURL, cacheDir); for ( const rule of JSON.parse(result.content) ) { rules.push(rule); } } const plainGood = await patchRuleset( rules.filter(rule => isSafe(rule) && isRegex(rule) === false) ); log(`\tPlain good: ${plainGood.length}`); log(plainGood .filter(rule => Array.isArray(rule._warning)) .map(rule => rule._warning.map(v => `\t\t${v}`)) .join('\n'), true ); const regexes = await patchRuleset( rules.filter(rule => isSafe(rule) && isRegex(rule)) ); log(`\tMaybe good (regexes): ${regexes.length}`); const redirects = await patchRuleset( rules.filter(rule => isUnsupported(rule) === false && isRedirect(rule) ) ); redirects.forEach(rule => { if ( rule.action.redirect.extensionPath === undefined ) { return; } requiredRedirectResources.add( rule.action.redirect.extensionPath.replace(/^\/+/, '') ); }); log(`\tredirect=: ${redirects.length}`); const removeparamsGood = await patchRuleset( rules.filter(rule => isUnsupported(rule) === false && isRemoveparam(rule) ) ); const removeparamsBad = await patchRuleset( rules.filter(rule => isUnsupported(rule) && isRemoveparam(rule) ) ); log(`\tremoveparams= (accepted/discarded): ${removeparamsGood.length}/${removeparamsBad.length}`); const modifyHeaders = await patchRuleset( rules.filter(rule => isUnsupported(rule) === false && isModifyHeaders(rule) ) ); log(`\tmodifyHeaders=: ${modifyHeaders.length}`); const urlskips = new Map(); for ( const rule of rules ) { if ( isURLSkip(rule) === false ) { continue; } if ( rule.__modifierAction !== 0 ) { continue; } const { condition } = rule; if ( condition.resourceTypes ) { if ( condition.resourceTypes.includes('main_frame') === false ) { continue; } } const { urlFilter, regexFilter, requestDomains } = condition; let re; if ( urlFilter !== undefined ) { re = rePatternFromUrlFilter(urlFilter); } else if ( regexFilter !== undefined ) { re = regexFilter; } else { re = '^'; } const rawSteps = rule.__modifierValue; const steps = rawSteps.includes(' ') && rawSteps.split(/ +/) || [ rawSteps ]; const keyEntry = { re, c: condition.isUrlFilterCaseSensitive, steps, } const key = JSON.stringify(keyEntry); let actualEntry = urlskips.get(key); if ( actualEntry === undefined ) { urlskips.set(key, keyEntry); actualEntry = keyEntry; } if ( requestDomains !== undefined ) { if ( actualEntry.hostnames === undefined ) { actualEntry.hostnames = []; } actualEntry.hostnames.push(...requestDomains); } } log(`\turlskip=: ${urlskips.size}`); const bad = rules.filter(rule => isUnsupported(rule) ); log(`\tUnsupported: ${bad.length}`); log(bad.map(rule => rule._error.map(v => `\t\t${v}`)).join('\n'), true); writeFile(`${rulesetDir}/main/${assetDetails.id}.json`, toJSONRuleset(plainGood) ); if ( regexes.length !== 0 ) { writeFile(`${rulesetDir}/regex/${assetDetails.id}.json`, toJSONRuleset(regexes) ); } if ( removeparamsGood.length !== 0 ) { writeFile(`${rulesetDir}/removeparam/${assetDetails.id}.json`, toJSONRuleset(removeparamsGood) ); } if ( redirects.length !== 0 ) { writeFile(`${rulesetDir}/redirect/${assetDetails.id}.json`, toJSONRuleset(redirects) ); } if ( modifyHeaders.length !== 0 ) { writeFile(`${rulesetDir}/modify-headers/${assetDetails.id}.json`, toJSONRuleset(modifyHeaders) ); } const strictBlocked = new Map(); for ( const rule of plainGood ) { toStrictBlockRule(rule, strictBlocked); } if ( strictBlocked.size !== 0 ) { mergeRules(strictBlocked, 'requestDomains'); writeFile(`${rulesetDir}/strictblock/${assetDetails.id}.json`, toJSONRuleset(Array.from(strictBlocked.values())) ); } if ( urlskips.size !== 0 ) { writeFile(`${rulesetDir}/urlskip/${assetDetails.id}.json`, JSON.stringify(Array.from(urlskips.values()), null, 1) ); } return { total: rules.length, plain: plainGood.length, discarded: removeparamsBad.length, rejected: bad.length, regex: regexes.length, removeparam: removeparamsGood.length, redirect: redirects.length, modifyHeaders: modifyHeaders.length, strictblock: strictBlocked.size, urlskip: urlskips.size, }; } /******************************************************************************/ // TODO: unify css/scriptlet processing code since now css styles are // injected using scriptlet injection. // Load all available scriptlets into a key-val map, where the key is the // scriptlet token, and val is the whole content of the file. let scriptletsMapPromise; function loadAllSourceScriptlets() { if ( scriptletsMapPromise !== undefined ) { return scriptletsMapPromise; } scriptletsMapPromise = fs.readdir('./scriptlets').then(files => { const readTemplateFile = file => fs.readFile(`./scriptlets/${file}`, { encoding: 'utf8' }) .then(text => ({ file, text })); const readPromises = []; for ( const file of files ) { readPromises.push(readTemplateFile(file)); } return Promise.all(readPromises).then(results => { const originalScriptletMap = new Map(); for ( const details of results ) { originalScriptletMap.set( details.file.replace('.template.js', '') .replace('.template.css', ''), details.text ); } return originalScriptletMap; }); }); return scriptletsMapPromise; } /******************************************************************************/ // http://www.cse.yorku.ca/~oz/hash.html#djb2 // Must mirror content script surveyor's version async function processGenericCosmeticFilters( assetDetails, selectorList, exceptionList, declarativeMap ) { const exceptionSet = new Set( exceptionList && exceptionList.filter(a => a.key !== undefined).map(a => a.selector) ); const genericSelectorMap = new Map(); if ( selectorList ) { for ( const { key, selector } of selectorList ) { if ( key === undefined ) { continue; } if ( exceptionSet.has(selector) ) { continue; } const type = key.charCodeAt(0); const hash = hashFromStr(type, key.slice(1)); const selectors = genericSelectorMap.get(hash); if ( selectors === undefined ) { genericSelectorMap.set(hash, selector) } else { genericSelectorMap.set(hash, `${selectors},\n${selector}`) } } } // Specific exceptions const genericExceptionSieve = new Set(); const genericExceptionMap = new Map(); if ( declarativeMap ) { for ( const [ exception, details ] of declarativeMap ) { if ( details.rejected ) { continue; } if ( details.key === undefined ) { continue; } if ( details.matches !== undefined ) { continue; } if ( details.excludeMatches === undefined ) { continue; } const type = details.key.charCodeAt(0); const hash = hashFromStr(type, details.key.slice(1)); genericExceptionSieve.add(hash); for ( const hn of details.excludeMatches ) { const exceptions = genericExceptionMap.get(hn); if ( exceptions === undefined ) { genericExceptionMap.set(hn, exception); } else { genericExceptionMap.set(hn, `${exceptions}\n${exception}`); } } } } if ( genericSelectorMap.size === 0 ) { if ( genericExceptionMap.size === 0 ) { return 0; } } const originalScriptletMap = await loadAllSourceScriptlets(); let patchedScriptlet = originalScriptletMap.get('css-generic').replace( '$rulesetId$', assetDetails.id ); patchedScriptlet = safeReplace(patchedScriptlet, /\bself\.\$genericSelectorMap\$/, `${JSON.stringify(genericSelectorMap, scriptletJsonReplacer)}` ); patchedScriptlet = safeReplace(patchedScriptlet, /\bself\.\$genericExceptionSieve\$/, `${JSON.stringify(genericExceptionSieve, scriptletJsonReplacer)}` ); patchedScriptlet = safeReplace(patchedScriptlet, /\bself\.\$genericExceptionMap\$/, `${JSON.stringify(genericExceptionMap, scriptletJsonReplacer)}` ); writeFile(`${scriptletDir}/generic/${assetDetails.id}.js`, patchedScriptlet ); log(`CSS-generic: ${genericExceptionSieve.size} specific CSS exceptions`); log(`CSS-generic: ${genericSelectorMap.size} plain CSS selectors`); return genericSelectorMap.size + genericExceptionSieve.size; } const hashFromStr = (type, s) => { const len = s.length; const step = len + 7 >>> 3; let hash = (type << 5) + type ^ len; for ( let i = 0; i < len; i += step ) { hash = (hash << 5) + hash ^ s.charCodeAt(i); } return hash & 0xFFFFFF; }; /******************************************************************************/ async function processGenericHighCosmeticFilters( assetDetails, genericSelectorList, genericExceptionList ) { if ( genericSelectorList === undefined ) { return 0; } const genericSelectorSet = new Set( genericSelectorList .filter(a => a.key === undefined) .map(a => a.selector) ); // https://github.com/uBlockOrigin/uBOL-home/issues/365 if ( genericExceptionList ) { for ( const entry of genericExceptionList ) { if ( entry.key !== undefined ) { continue; } globalHighlyGenericExceptionSet.add(entry.selector); } } for ( const selector of globalHighlyGenericExceptionSet ) { if ( genericSelectorSet.has(selector) === false ) { continue; } genericSelectorSet.delete(selector); log(`\tRemoving excepted highly generic filter ##${selector}`); } if ( genericSelectorSet.size === 0 ) { return 0; } const selectorLists = Array.from(genericSelectorSet).sort().join(',\n'); const originalScriptletMap = await loadAllSourceScriptlets(); let patchedScriptlet = originalScriptletMap.get('css-generichigh').replace( '$rulesetId$', assetDetails.id ); patchedScriptlet = safeReplace(patchedScriptlet, /\$selectorList\$/, selectorLists ); writeFile(`${scriptletDir}/generichigh/${assetDetails.id}.css`, patchedScriptlet ); log(`CSS-generic-high: ${genericSelectorSet.size} plain CSS selectors`); return genericSelectorSet.size; } const globalHighlyGenericExceptionSet = new Set(); /******************************************************************************/ // This merges selectors which are used by the same hostnames function groupSelectorsByHostnames(mapin) { if ( mapin === undefined ) { return []; } const merged = new Map(); for ( const [ selector, details ] of mapin ) { if ( details.rejected ) { continue; } const json = JSON.stringify(details); let entries = merged.get(json); if ( entries === undefined ) { entries = new Set(); merged.set(json, entries); } entries.add(selector); } const out = []; for ( const [ json, entries ] of merged ) { const details = JSON.parse(json); details.selectors = Array.from(entries).sort(); out.push(details); } return out; } // This merges hostnames which have the same set of selectors. // // Also, we sort the hostnames to increase likelihood that selector with // same hostnames will end up in same generated scriptlet. function groupHostnamesBySelectors(arrayin) { const contentMap = new Map(); for ( const entry of arrayin ) { const id = uidint32(JSON.stringify(entry.selectors)); let details = contentMap.get(id); if ( details === undefined ) { details = { a: entry.selectors }; contentMap.set(id, details); } if ( entry.matches !== undefined ) { if ( details.y === undefined ) { details.y = new Set(); } for ( const hn of entry.matches ) { details.y.add(hn); } } if ( entry.excludeMatches !== undefined ) { if ( details.n === undefined ) { details.n = new Set(); } for ( const hn of entry.excludeMatches ) { details.n.add(hn); } } } const out = Array.from(contentMap).map(a => [ a[0], { a: a[1].a, y: a[1].y ? Array.from(a[1].y) : undefined, n: a[1].n ? Array.from(a[1].n) : undefined, } ]); return out; } const scriptletHostnameToIdMap = (hostnames, id, map) => { for ( const hn of hostnames ) { const existing = map.get(hn); if ( existing === undefined ) { map.set(hn, id); } else if ( Array.isArray(existing) ) { existing.push(id); } else { map.set(hn, [ existing, id ]); } } }; const scriptletJsonReplacer = (k, v) => { if ( k === 'n' ) { if ( v === undefined || v.size === 0 ) { return; } return Array.from(v); } if ( v instanceof Set || v instanceof Map ) { if ( v.size === 0 ) { return; } return Array.from(v); } return v; }; /******************************************************************************/ function argsMap2List(argsMap, hostnamesMap) { const argsList = [ '' ]; const indexMap = new Map(); for ( const [ id, details ] of argsMap ) { indexMap.set(id, argsList.length); argsList.push(details); } const argsSeqs = [ 0 ]; const argsSeqsIndices = new Map(); for ( const [ hn, ids ] of hostnamesMap ) { const seqKey = JSON.stringify(ids); if ( argsSeqsIndices.has(seqKey) ) { hostnamesMap.set(hn, argsSeqsIndices.get(seqKey)); continue; } const seqIndex = argsSeqs.length; argsSeqsIndices.set(seqKey, seqIndex); hostnamesMap.set(hn, seqIndex); if ( typeof ids === 'number' ) { argsSeqs.push(indexMap.get(ids)); continue; } for ( let i = 0; i < ids.length; i++ ) { argsSeqs.push(-indexMap.get(ids[i])); } argsSeqs[argsSeqs.length-1] = -argsSeqs[argsSeqs.length-1]; } return { argsList, argsSeqs }; } /******************************************************************************/ async function processCosmeticFilters(assetDetails, mapin) { if ( mapin === undefined ) { return 0; } if ( mapin.size === 0 ) { return 0; } const domainBasedEntries = groupHostnamesBySelectors( groupSelectorsByHostnames(mapin) ); // We do not want more than n CSS files per subscription, so we will // group multiple unrelated selectors in the same file, and distinct // css declarations will be injected programmatically according to the // hostname of the current document. // // The cosmetic filters will be injected programmatically as content // script and the decisions to activate the cosmetic filters will be // done at injection time according to the document's hostname. const generatedFiles = []; const argsMap = domainBasedEntries.map(entry => [ entry[0], entry[1].a ? entry[1].a.join('\n') : undefined, ]); const hostnamesMap = new Map(); let hasEntities = false; for ( const [ id, details ] of domainBasedEntries ) { if ( details.y ) { scriptletHostnameToIdMap(details.y, id, hostnamesMap); hasEntities ||= details.y.some(a => a.endsWith('.*')); } if ( details.n ) { scriptletHostnameToIdMap(details.n.map(a => `~${a}`), id, hostnamesMap); hasEntities ||= details.n.some(a => a.endsWith('.*')); } } const { argsList, argsSeqs } = argsMap2List(argsMap, hostnamesMap); const originalScriptletMap = await loadAllSourceScriptlets(); let patchedScriptlet = originalScriptletMap.get('css-specific').replace( '$rulesetId$', assetDetails.id ); patchedScriptlet = safeReplace(patchedScriptlet, /\bself\.\$argsList\$/, `${JSON.stringify(argsList, scriptletJsonReplacer)}` ); patchedScriptlet = safeReplace(patchedScriptlet, /\bself\.\$argsSeqs\$/, `${JSON.stringify(argsSeqs, scriptletJsonReplacer)}` ); patchedScriptlet = safeReplace(patchedScriptlet, /\bself\.\$hostnamesMap\$/, `${JSON.stringify(hostnamesMap, scriptletJsonReplacer)}` ); patchedScriptlet = safeReplace(patchedScriptlet, 'self.$hasEntities$', JSON.stringify(hasEntities) ); writeFile(`${scriptletDir}/specific/${assetDetails.id}.js`, patchedScriptlet); generatedFiles.push(`${assetDetails.id}`); if ( generatedFiles.length !== 0 ) { log(`CSS-specific: ${mapin.size} distinct filters`); log(`\tCombined into ${hostnamesMap.size} distinct hostnames`); } return hostnamesMap.size; } /******************************************************************************/ async function processProceduralCosmeticFilters(assetDetails, mapin) { if ( mapin === undefined ) { return 0; } if ( mapin.size === 0 ) { return 0; } const procedurals = new Map(); mapin.forEach((details, jsonSelector) => { procedurals.set(jsonSelector, details); }); if ( procedurals.size === 0 ) { return 0; } const contentArray = groupHostnamesBySelectors( groupSelectorsByHostnames(procedurals) ); const argsMap = contentArray.map(entry => [ entry[0], entry[1].a, ]); const hostnamesMap = new Map(); let hasEntities = false; for ( const [ id, details ] of contentArray ) { if ( details.y ) { scriptletHostnameToIdMap(details.y, id, hostnamesMap); hasEntities ||= details.y.some(a => a.endsWith('.*')); } if ( details.n ) { scriptletHostnameToIdMap(details.n.map(a => `~${a}`), id, hostnamesMap); hasEntities ||= details.n.some(a => a.endsWith('.*')); } } const { argsList, argsSeqs } = argsMap2List(argsMap, hostnamesMap); const argsListAfter = []; for ( const a of argsList ) { const aAfter = []; for ( let b of a ) { aAfter.push(JSON.parse(b)); } argsListAfter.push(JSON.stringify(aAfter)); } const originalScriptletMap = await loadAllSourceScriptlets(); let patchedScriptlet = originalScriptletMap.get('css-procedural').replace( '$rulesetId$', assetDetails.id ); patchedScriptlet = safeReplace(patchedScriptlet, /\bself\.\$argsList\$/, `${JSON.stringify(argsListAfter, scriptletJsonReplacer)}` ); patchedScriptlet = safeReplace(patchedScriptlet, /\bself\.\$argsSeqs\$/, `${JSON.stringify(argsSeqs, scriptletJsonReplacer)}` ); patchedScriptlet = safeReplace(patchedScriptlet, /\bself\.\$hostnamesMap\$/, `${JSON.stringify(hostnamesMap, scriptletJsonReplacer)}` ); patchedScriptlet = safeReplace(patchedScriptlet, 'self.$hasEntities$', JSON.stringify(hasEntities) ); writeFile(`${scriptletDir}/procedural/${assetDetails.id}.js`, patchedScriptlet); if ( contentArray.length !== 0 ) { log(`Procedural-related distinct filters: ${procedurals.size} distinct combined selectors`); log(`\tCombined into ${hostnamesMap.size} distinct hostnames`); } return hostnamesMap.size; } /******************************************************************************/ async function processScriptletFilters(assetDetails, mapin) { if ( mapin === undefined ) { return 0; } if ( mapin.size === 0 ) { return 0; } makeScriptlet.init(); for ( const details of mapin.values() ) { makeScriptlet.compile(assetDetails, details); } const stats = await makeScriptlet.commit( assetDetails.id, `${scriptletDir}/scriptlet`, writeFile ); if ( stats.length !== 0 ) { scriptletStats.set(assetDetails.id, stats); } makeScriptlet.reset(); return stats.length; } /******************************************************************************/ async function rulesetFromURLs(assetDetails) { log('============================'); log(`Listset for '${assetDetails.id}':`); if ( assetDetails.text === undefined && assetDetails.urls.length !== 0 ) { const text = await fetchList(assetDetails); if ( text === undefined ) { process.exit(1); } assetDetails.text = text; } else { assetDetails.text = ''; } if ( Array.isArray(assetDetails.filters) && assetDetails.filters.length ) { const extra = [ `!#trusted on ${secret}`, ...assetDetails.filters, `!#trusted off ${secret}`, assetDetails.text, ]; assetDetails.text = extra.join('\n').trim(); } if ( assetDetails.text === '' ) { return; } const extensionPaths = []; for ( const [ fname, details ] of redirectResourcesMap ) { const path = `/web_accessible_resources/${fname}`; extensionPaths.push([ fname, path ]); if ( details.alias === undefined ) { continue; } if ( typeof details.alias === 'string' ) { extensionPaths.push([ details.alias, path ]); continue; } if ( Array.isArray(details.alias) === false ) { continue; } for ( const alias of details.alias ) { extensionPaths.push([ alias, path ]); } } const results = await dnrRulesetFromRawLists( [ { name: assetDetails.id, text: assetDetails.text } ], { env, extensionPaths, secret, networkBad } ); networkBad = results.networkBad; // Release memory used by filter list content assetDetails.text = undefined; const netStats = await processNetworkFilters( assetDetails, results.network ); // Split cosmetic filters into two groups: declarative and procedural const declarativeCosmetic = new Map(); const proceduralCosmetic = new Map(); const rejectedCosmetic = []; if ( results.specificCosmetic ) { for ( const [ selector, details ] of results.specificCosmetic ) { if ( details.rejected ) { rejectedCosmetic.push(selector); continue; } if ( selector.startsWith('{') === false ) { declarativeCosmetic.set(selector, details); continue; } const parsed = JSON.parse(selector); parsed.raw = undefined; proceduralCosmetic.set(JSON.stringify(parsed), details); } } if ( rejectedCosmetic.length !== 0 ) { log(`Rejected cosmetic filters: ${rejectedCosmetic.length}`); log(rejectedCosmetic.map(line => `\t${line}`).join('\n'), true); } const genericDetailsForRuleset = {}; if ( Array.isArray(results.network.generichideExclusions) && results.network.generichideExclusions.length !== 0 ) { genericDetailsForRuleset.unhide = results.network.generichideExclusions .filter(hn => hn.endsWith('.*') === false) .sort(); } if ( Array.isArray(results.network.generichideInclusions) && results.network.generichideInclusions.length !== 0 ) { genericDetailsForRuleset.hide = results.network.generichideInclusions .filter(hn => hn.endsWith('.*') === false) .sort(); } if ( genericDetailsForRuleset.unhide || genericDetailsForRuleset.hide ) { genericDetails.set(assetDetails.id, genericDetailsForRuleset); } const genericCosmeticStats = await processGenericCosmeticFilters( assetDetails, results.genericCosmeticFilters, results.genericCosmeticExceptions, declarativeCosmetic ); const genericHighCosmeticStats = await processGenericHighCosmeticFilters( assetDetails, results.genericCosmeticFilters, results.genericCosmeticExceptions, ); const specificCosmeticStats = await processCosmeticFilters( assetDetails, declarativeCosmetic ); const proceduralStats = await processProceduralCosmeticFilters( assetDetails, proceduralCosmetic ); const scriptletStats = await processScriptletFilters( assetDetails, results.scriptlet ); rulesetDetails.push({ id: assetDetails.id, name: assetDetails.name, group: assetDetails.group, parent: assetDetails.parent, enabled: assetDetails.enabled, lang: assetDetails.lang, tags: assetDetails.tags, homeURL: assetDetails.homeURL, filters: { total: results.network.filterCount, accepted: results.network.acceptedFilterCount, rejected: results.network.rejectedFilterCount, }, rules: { total: netStats.total, plain: netStats.plain, regex: netStats.regex, removeparam: netStats.removeparam, redirect: netStats.redirect, modifyHeaders: netStats.modifyHeaders, strictblock: netStats.strictblock, urlskip: netStats.urlskip, discarded: netStats.discarded, rejected: netStats.rejected, }, css: { generic: genericCosmeticStats, generichigh: genericHighCosmeticStats, specific: specificCosmeticStats, procedural: proceduralStats, }, scriptlets: scriptletStats, }); ruleResources.push({ id: assetDetails.id, enabled: assetDetails.enabled, path: `/rulesets/main/${assetDetails.id}.json` }); } /******************************************************************************/ async function main() { let version = ''; { const now = new Date(); const yearPart = now.getUTCFullYear(); const monthPart = now.getUTCMonth() + 1; const dayPart = now.getUTCDate(); const hourPart = Math.floor(now.getUTCHours()); const minutePart = Math.floor(now.getUTCMinutes()); version = `${yearPart}.${monthPart*100+dayPart}.${hourPart*100+minutePart}`; } log(`Version: ${version}`, false); // Get list of rulesets const rulesets = await fs.readFile('rulesets.json', { encoding: 'utf8' }).then(text => JSON.parse(text) ); for ( const ruleset of rulesets ) { if ( ruleset.excludedPlatforms?.includes(platform) ) { continue; } await rulesetFromURLs(ruleset); } logProgress(''); writeFile(`${rulesetDir}/ruleset-details.json`, `${JSON.stringify(rulesetDetails, null, 1)}\n` ); writeFile(`${rulesetDir}/scriptlet-details.json`, `${JSON.stringify(scriptletStats, jsonSetMapReplacer, 1)}\n` ); writeFile(`${rulesetDir}/generic-details.json`, `${JSON.stringify(genericDetails, jsonSetMapReplacer, 1)}\n` ); // Copy required redirect resources for ( const path of requiredRedirectResources ) { copyFile(`./${path}`, `${outputDir}/${path}`); } await Promise.all(writeOps); // Patch manifest // Get manifest content const manifest = await fs.readFile( `${outputDir}/manifest.json`, { encoding: 'utf8' } ).then(text => JSON.parse(text) ); // Patch declarative_net_request key manifest.declarative_net_request = { rule_resources: ruleResources }; // Patch web_accessible_resources key manifest.web_accessible_resources = manifest.web_accessible_resources || []; const web_accessible_resources = { resources: Array.from(requiredRedirectResources).map(path => `${path}`), matches: [ '' ], }; if ( env.includes('chromium') && env.includes('safari') === false ) { web_accessible_resources.use_dynamic_url = true; } manifest.web_accessible_resources.push(web_accessible_resources); // Patch manifest version property manifest.version = version; // Commit changes await fs.writeFile(`${outputDir}/manifest.json`, JSON.stringify(manifest, null, 2) + '\n' ); // Log results const logContent = stdOutput.join('\n') + '\n'; await fs.writeFile(`${outputDir}/log.txt`, logContent); } main(); /******************************************************************************/