UNPKG

@adguard/agtree

Version:
386 lines (383 loc) 18.5 kB
/* * AGTree v3.2.2 (build date: Tue, 08 Jul 2025 13:39:47 GMT) * (c) 2025 Adguard Software Ltd. * Released under the MIT license * https://github.com/AdguardTeam/tsurlfilter/tree/master/packages/agtree#readme */ import { CosmeticRuleSeparator } from '../../nodes/index.js'; import { RuleConverterBase } from '../base-interfaces/rule-converter-base.js'; import { AdblockSyntax } from '../../utils/adblockers.js'; import { QuoteUtils, QuoteType } from '../../utils/quotes.js'; import { EMPTY, ADG_DOMAINS_MODIFIER, PIPE_MODIFIER_SEPARATOR, SPACE } from '../../utils/constants.js'; import { getScriptletName, setScriptletName, transformAllScriptletArguments, setScriptletQuoteType, transformNthScriptletArgument } from '../../ast-utils/scriptlets.js'; import { RuleConversionError } from '../../errors/rule-conversion-error.js'; import { createNodeConversionResult } from '../base-interfaces/conversion-result.js'; import { cloneScriptletRuleNode, cloneDomainListNode, cloneModifierListNode } from '../../ast-utils/clone.js'; import '../../compatibility-tables/modifiers.js'; import '../../compatibility-tables/redirects.js'; import { scriptletsCompatibilityTable } from '../../compatibility-tables/scriptlets.js'; import { GenericPlatform } from '../../compatibility-tables/platforms.js'; import '../../compatibility-tables/schemas/base.js'; import '../../compatibility-tables/schemas/modifier.js'; import '../../compatibility-tables/schemas/redirect.js'; import '../../compatibility-tables/schemas/scriptlet.js'; import '../../compatibility-tables/schemas/platform.js'; import '../../compatibility-tables/utils/platform-helpers.js'; import '../../compatibility-tables/schemas/resource-type.js'; import '../../compatibility-tables/utils/resource-type-helpers.js'; import { isUndefined, isNull } from '../../utils/type-guards.js'; import { DomainListParser } from '../../parser/misc/domain-list-parser.js'; /** * @file Scriptlet injection rule converter */ const ABP_SCRIPTLET_PREFIX = 'abp-'; const UBO_SCRIPTLET_PREFIX = 'ubo-'; const UBO_SCRIPTLET_PREFIX_LENGTH = UBO_SCRIPTLET_PREFIX.length; const UBO_SCRIPTLET_JS_SUFFIX = '.js'; const UBO_SCRIPTLET_JS_SUFFIX_LENGTH = UBO_SCRIPTLET_JS_SUFFIX.length; const COMMA_SEPARATOR = ','; const ADG_SET_CONSTANT_NAME = 'set-constant'; const ADG_SET_CONSTANT_EMPTY_STRING = ''; const ADG_SET_CONSTANT_EMPTY_ARRAY = 'emptyArr'; const ADG_SET_CONSTANT_EMPTY_OBJECT = 'emptyObj'; const UBO_SET_CONSTANT_EMPTY_STRING = '\'\''; const UBO_SET_CONSTANT_EMPTY_ARRAY = '[]'; const UBO_SET_CONSTANT_EMPTY_OBJECT = '{}'; const ADG_PREVENT_FETCH_NAME = 'prevent-fetch'; const ADG_PREVENT_FETCH_EMPTY_STRING = ''; const ADG_PREVENT_FETCH_WILDCARD = '*'; const UBO_NO_FETCH_IF_WILDCARD = '/^/'; const UBO_REMOVE_CLASS_NAME = 'remove-class.js'; const UBO_REMOVE_ATTR_NAME = 'remove-attr.js'; const setConstantAdgToUboMap = { [ADG_SET_CONSTANT_EMPTY_STRING]: UBO_SET_CONSTANT_EMPTY_STRING, [ADG_SET_CONSTANT_EMPTY_ARRAY]: UBO_SET_CONSTANT_EMPTY_ARRAY, [ADG_SET_CONSTANT_EMPTY_OBJECT]: UBO_SET_CONSTANT_EMPTY_OBJECT, }; const REMOVE_ATTR_CLASS_APPLYING = new Set([ 'asap', 'stay', 'complete', ]); /** * Scriptlet injection rule converter class * * @todo Implement `convertToUbo` and `convertToAbp` */ class ScriptletRuleConverter extends RuleConverterBase { /** * Converts a scriptlet injection rule to AdGuard format, if possible. * * @param rule Rule node to convert * @returns An object which follows the {@link NodeConversionResult} interface. Its `result` property contains * the array of converted rule nodes, and its `isConverted` flag indicates whether the original rule was converted. * If the rule was not converted, the result array will contain the original node with the same object reference * @throws If the rule is invalid or cannot be converted */ static convertToAdg(rule) { // Ignore AdGuard rules if (rule.syntax === AdblockSyntax.Adg) { return createNodeConversionResult([rule], false); } const separator = rule.separator.value; let convertedSeparator = separator; convertedSeparator = rule.exception ? CosmeticRuleSeparator.AdgJsInjectionException : CosmeticRuleSeparator.AdgJsInjection; const convertedScriptlets = []; for (const scriptlet of rule.body.children) { // Clone the node to avoid any side effects const scriptletClone = cloneScriptletRuleNode(scriptlet); // Remove possible quotes just to make it easier to work with the scriptlet name const scriptletName = QuoteUtils.setStringQuoteType(getScriptletName(scriptletClone), QuoteType.None); // Add prefix if it's not already there let prefix; // In uBO / ABP syntax, if a parameter contains the separator character, it should be escaped, // but during the conversion, we need to unescape them, because AdGuard syntax uses quotes to // distinguish between parameters. let charToUnescape; switch (rule.syntax) { case AdblockSyntax.Abp: prefix = ABP_SCRIPTLET_PREFIX; charToUnescape = SPACE; break; case AdblockSyntax.Ubo: prefix = UBO_SCRIPTLET_PREFIX; charToUnescape = COMMA_SEPARATOR; break; default: prefix = EMPTY; } if (!scriptletName.startsWith(prefix)) { setScriptletName(scriptletClone, `${prefix}${scriptletName}`); } if (!isUndefined(charToUnescape)) { transformAllScriptletArguments(scriptletClone, (value) => { if (!isNull(value)) { return QuoteUtils.unescapeSingleEscapedOccurrences(value, charToUnescape); } return value; }); } if (rule.syntax === AdblockSyntax.Ubo) { const scriptletData = scriptletsCompatibilityTable.getFirst(scriptletName, GenericPlatform.UboAny); // Some scriptlets have special values that need to be converted if (scriptletData && (scriptletData.name === UBO_REMOVE_CLASS_NAME || scriptletData.name === UBO_REMOVE_ATTR_NAME) && scriptletClone.children.length > 2) { const selectors = []; let applying = null; let lastArg = scriptletClone.children.pop(); // The very last argument might be the 'applying' parameter if (lastArg) { if (REMOVE_ATTR_CLASS_APPLYING.has(lastArg.value)) { applying = lastArg.value; } else { selectors.push(lastArg.value); } } while (scriptletClone.children.length > 2) { lastArg = scriptletClone.children.pop(); if (lastArg) { selectors.push(lastArg.value.trim()); } } // Set last arg to be the combined selectors (in reverse order, because we popped them) if (selectors.length > 0) { scriptletClone.children.push({ type: 'Value', value: selectors.reverse().join(', '), }); } // Push back the 'applying' parameter if it was found previously if (!isNull(applying)) { // If we don't have any selectors, // we need to add an empty parameter before the 'applying' one if (selectors.length === 0) { scriptletClone.children.push({ type: 'Value', value: EMPTY, }); } scriptletClone.children.push({ type: 'Value', value: applying, }); } } } // ADG scriptlet parameters should be quoted, and single quoted are preferred setScriptletQuoteType(scriptletClone, QuoteType.Single); convertedScriptlets.push(scriptletClone); } if (rule.body.children.length === 0) { const convertedScriptletNode = { category: rule.category, type: rule.type, syntax: AdblockSyntax.Adg, exception: rule.exception, domains: cloneDomainListNode(rule.domains), separator: { type: 'Value', value: convertedSeparator, }, body: { type: rule.body.type, children: [], }, }; if (rule.modifiers) { convertedScriptletNode.modifiers = cloneModifierListNode(rule.modifiers); } return createNodeConversionResult([convertedScriptletNode], true); } return createNodeConversionResult(convertedScriptlets.map((scriptlet) => { const res = { category: rule.category, type: rule.type, syntax: AdblockSyntax.Adg, exception: rule.exception, domains: cloneDomainListNode(rule.domains), separator: { type: 'Value', value: convertedSeparator, }, body: { type: rule.body.type, children: [scriptlet], }, }; if (rule.modifiers) { res.modifiers = cloneModifierListNode(rule.modifiers); } return res; }), true); } /** * Converts a scriptlet injection rule to uBlock format, if possible. * * @param rule Rule node to convert * @returns An object which follows the {@link NodeConversionResult} interface. Its `result` property contains * the array of converted rule nodes, and its `isConverted` flag indicates whether the original rule was converted. * If the rule was not converted, the result array will contain the original node with the same object reference * @throws If the rule is invalid or cannot be converted */ static convertToUbo(rule) { // Ignore uBlock rules if (rule.syntax === AdblockSyntax.Ubo) { return createNodeConversionResult([rule], false); } let ruleDomainsList = cloneDomainListNode(rule.domains); if (rule.syntax === AdblockSyntax.Adg && rule.modifiers?.children.length) { const { modifiers } = rule; // Validate modifiers structure if (!modifiers || !modifiers.children || modifiers.children.length === 0) { throw new RuleConversionError('Invalid modifiers in AdGuard rule.'); } // Check for single domain modifier const [domainModifier] = modifiers.children; const hasSingleDomainModifier = (modifiers.children.length === 1 && domainModifier.name?.value === ADG_DOMAINS_MODIFIER && domainModifier.value?.value); if (!hasSingleDomainModifier) { throw new RuleConversionError('uBlock Origin scriptlet injection rules do not support cosmetic rule modifiers.'); } // Validate domain modifier if (!domainModifier.value?.value) { throw new RuleConversionError('Invalid domain modifier in AdGuard rule.'); } // Parse domain list const parsedDomainList = DomainListParser.parse(domainModifier.value.value, {}, domainModifier.start, PIPE_MODIFIER_SEPARATOR); // Merge domain lists if (ruleDomainsList) { ruleDomainsList.children.push(...parsedDomainList.children); } else { ruleDomainsList = parsedDomainList; } } const separator = rule.separator.value; let convertedSeparator = separator; convertedSeparator = rule.exception ? CosmeticRuleSeparator.ElementHidingException : CosmeticRuleSeparator.ElementHiding; const convertedScriptlets = []; for (const scriptlet of rule.body.children) { // Clone the node to avoid any side effects const scriptletClone = cloneScriptletRuleNode(scriptlet); // Remove possible quotes just to make it easier to work with the scriptlet name const scriptletName = QuoteUtils.setStringQuoteType(getScriptletName(scriptletClone), QuoteType.None); let uboScriptletName; if (rule.syntax === AdblockSyntax.Adg && scriptletName.startsWith(UBO_SCRIPTLET_PREFIX)) { // Special case: AdGuard syntax 'preserves' the original scriptlet name, // so we need to convert it back by removing the uBO prefix uboScriptletName = scriptletName.slice(UBO_SCRIPTLET_PREFIX_LENGTH); } else { // Otherwise, try to find the corresponding uBO scriptlet name, or use the original one if not found const uboScriptlet = scriptletsCompatibilityTable.getFirst(scriptletName, GenericPlatform.UboAny); if (!uboScriptlet) { throw new RuleConversionError(`Scriptlet "${scriptletName}" is not supported in uBlock Origin.`); } uboScriptletName = uboScriptlet.name; } // Remove the '.js' suffix if it's there - its presence is not mandatory if (uboScriptletName.endsWith(UBO_SCRIPTLET_JS_SUFFIX)) { uboScriptletName = uboScriptletName.slice(0, -UBO_SCRIPTLET_JS_SUFFIX_LENGTH); } setScriptletName(scriptletClone, uboScriptletName); setScriptletQuoteType(scriptletClone, QuoteType.None); // Escape unescaped commas in parameters, because uBlock Origin uses them as separators. // For example, the following AdGuard rule: // // example.com#%#//scriptlet('spoof-css', '.adsbygoogle, #ads', 'visibility', 'visible') // // ↓↓ should be converted to ↓↓ // // example.com##+js(spoof-css.js, .adsbygoogle\, #ads, visibility, visible) // ------------ ------------------- ---------- ------- // arg 0 arg 1 arg 2 arg 3 // // and we need to escape the comma in the second argument to prevent it from being treated // as two separate arguments. transformAllScriptletArguments(scriptletClone, (value) => { if (!isNull(value)) { return QuoteUtils.escapeUnescapedOccurrences(value, COMMA_SEPARATOR); } return value; }); // Unescape spaces in parameters, because uBlock Origin doesn't treat them as separators. if (rule.syntax === AdblockSyntax.Abp) { transformAllScriptletArguments(scriptletClone, (value) => { if (!isNull(value)) { return QuoteUtils.unescapeSingleEscapedOccurrences(value, SPACE); } return value; }); } // Some scriptlets have special values that need to be converted switch (scriptletName) { case ADG_SET_CONSTANT_NAME: transformNthScriptletArgument(scriptletClone, 2, (value) => { if (!isNull(value)) { return setConstantAdgToUboMap[value] ?? value; } return value; }); break; case ADG_PREVENT_FETCH_NAME: transformNthScriptletArgument(scriptletClone, 1, (value) => { if (value === ADG_PREVENT_FETCH_EMPTY_STRING || value === ADG_PREVENT_FETCH_WILDCARD) { return UBO_NO_FETCH_IF_WILDCARD; } return value; }); break; } convertedScriptlets.push(scriptletClone); } // TODO: Refactor redundant code if (rule.body.children.length === 0) { const convertedScriptletNode = { category: rule.category, type: rule.type, syntax: AdblockSyntax.Ubo, exception: rule.exception, domains: cloneDomainListNode(rule.domains), separator: { type: 'Value', value: convertedSeparator, }, body: { type: rule.body.type, children: [], }, }; if (rule.modifiers) { convertedScriptletNode.modifiers = cloneModifierListNode(rule.modifiers); } return createNodeConversionResult([convertedScriptletNode], true); } return createNodeConversionResult(convertedScriptlets.map((scriptlet) => { const res = { category: rule.category, type: rule.type, syntax: AdblockSyntax.Ubo, exception: rule.exception, domains: ruleDomainsList, separator: { type: 'Value', value: convertedSeparator, }, body: { type: rule.body.type, children: [scriptlet], }, }; return res; }), true); } } export { ScriptletRuleConverter };