UNPKG

@adguard/agtree

Version:
247 lines (244 loc) 9.66 kB
/* * AGTree v4.1.1 (build date: Thu, 23 Apr 2026 09:15:37 GMT) * (c) 2026 Adguard Software Ltd. * Released under the MIT license * https://github.com/AdguardTeam/tsurlfilter/tree/master/packages/agtree#readme */ import { createModifierNode } from '../../ast-utils/modifiers.js'; import { RuleConversionError } from '../../errors/rule-conversion-error.js'; import { ListNodeType, ListItemNodeType } from '../../nodes/index.js'; import { AdblockSyntax } from '../../utils/adblockers.js'; import { clone } from '../../utils/clone.js'; import { REGEX_MARKER, ESCAPE_CHARACTER, SLASH, ADG_PATH_MODIFIER, OPEN_SQUARE_BRACKET, CLOSE_SQUARE_BRACKET } from '../../utils/constants.js'; import { createNodeConversionResult } from '../base-interfaces/conversion-result.js'; /** * @file Path-in-domain converter helper. */ /** * Finds the index of the first escaped forward slash (`\/`) in a regex string, * skipping character classes (`[...]`). * * @param str Regex content without outer `/` delimiters. * * @returns Index of the backslash in the first `\/` sequence, or -1 if not found. */ function findFirstEscapedSlash(str) { let insideCharacterClass = false; for (let i = 0; i < str.length; i += 1) { if (str[i] === OPEN_SQUARE_BRACKET && !insideCharacterClass) { insideCharacterClass = true; } else if (str[i] === CLOSE_SQUARE_BRACKET && insideCharacterClass) { insideCharacterClass = false; } else if (!insideCharacterClass && str[i] === ESCAPE_CHARACTER && i + 1 < str.length && str[i + 1] === SLASH) { return i; } } return -1; } /** * Converts path-in-domain syntax to $path modifier. * * Example: `example.org/path##.ad` → `[$path=/path]example.org##.ad`. * * @param rule Rule to check and convert. * * @returns Conversion result if path-in-domain syntax found, undefined otherwise. * * @throws RuleConversionError if conflicting $path modifier exists. */ function convertPathInDomainToModifier(rule) { // Only process rules with domains if (!rule.domains || rule.domains.children.length === 0) { return undefined; } // Quick check: return early if no domain contains a path indicator. // For non-regex domains, a path requires '/'. // For regex domains, a path requires '\/' (escaped slash). const hasAnyPath = rule.domains.children.some((domainItem) => { const { value } = domainItem; if (value.startsWith(REGEX_MARKER) && value.endsWith(REGEX_MARKER) && value.length > 1) { return value.includes(ESCAPE_CHARACTER + SLASH); } return value.includes(SLASH); }); if (!hasAnyPath) { return undefined; } const domainsWithPaths = []; const domainsWithoutPaths = []; rule.domains.children.forEach((domainItem) => { const domainValue = domainItem.value; // Check if domain is a regex pattern (starts and ends with /) if (domainValue.startsWith(REGEX_MARKER) && domainValue.endsWith(REGEX_MARKER) && domainValue.length > 1) { const inner = domainValue.slice(1, -1); const splitIndex = findFirstEscapedSlash(inner); if (splitIndex !== -1) { // splitIndex points to the backslash in `\/` // Domain part: everything before the backslash, wrapped in / // Path part: from the slash onward (keeping the slash), wrapped in / const domain = `${REGEX_MARKER}${inner.substring(0, splitIndex)}${REGEX_MARKER}`; const path = `${REGEX_MARKER}${inner.substring(splitIndex)}${REGEX_MARKER}`; domainsWithPaths.push({ domain, path, exception: domainItem.exception, }); } else { domainsWithoutPaths.push({ domain: domainValue, exception: domainItem.exception, }); } } else { // Non-regex domain const slashIndex = domainValue.indexOf(SLASH); if (slashIndex !== -1) { const domain = domainValue.substring(0, slashIndex); const path = domainValue.substring(slashIndex); // includes leading / // Skip empty paths if (path === '/') { domainsWithoutPaths.push({ domain, exception: domainItem.exception, }); } else { domainsWithPaths.push({ domain, path, exception: domainItem.exception, }); } } else { domainsWithoutPaths.push({ domain: domainValue, exception: domainItem.exception, }); } } }); if (domainsWithPaths.length === 0) { return undefined; } /* * Exception domains cannot be combined with path-in-domain syntax. * * For example example.org/foo1/bar2,~example.org/${WILDCARD}/bar2##h1 * This can’t be converted because [$path=/${WILDCARD}/bar2]example.org##h1 * may unblock unrelated rules like: "example.org/baz1/bar2##h1". */ const hasException = rule.domains.children.some((d) => d.exception); if (hasException) { // Single exception domain with path — just skip conversion if (rule.domains.children.length === 1) { return undefined; } // Domain list with both exception and path-in-domain — error throw new RuleConversionError('Path-in-domain syntax cannot be used with exception domains'); } // Check for conflicting $path modifier if (rule.modifiers) { const hasPathModifier = rule.modifiers.children.some((mod) => mod.name.value === ADG_PATH_MODIFIER); if (hasPathModifier) { throw new RuleConversionError('Path specified both in domain and $path modifier'); } } // Group domains by path const pathGroups = new Map(); domainsWithPaths.forEach(({ domain, path, exception }) => { if (!pathGroups.has(path)) { pathGroups.set(path, []); } pathGroups.get(path).push({ domain, exception }); }); // If all domains have the same path and there are no domains without paths, // create a single rule with $path modifier if (pathGroups.size === 1 && domainsWithoutPaths.length === 0) { const [path, domains] = Array.from(pathGroups.entries())[0]; const convertedRule = clone(rule); // Set syntax to Adg since we're adding AdGuard modifiers convertedRule.syntax = AdblockSyntax.Adg; // Update domains to remove paths convertedRule.domains = { type: ListNodeType.DomainList, separator: rule.domains.separator, children: domains.map((d) => ({ type: ListItemNodeType.Domain, value: d.domain, exception: d.exception, })), }; // Add $path modifier const pathModifier = createModifierNode(ADG_PATH_MODIFIER, path); if (convertedRule.modifiers) { convertedRule.modifiers = { ...convertedRule.modifiers, children: [...convertedRule.modifiers.children, pathModifier], }; } else { convertedRule.modifiers = { type: 'ModifierList', children: [pathModifier], }; } return createNodeConversionResult([convertedRule], true); } // Multiple paths or mixed (with/without paths) - split into multiple rules const convertedRules = []; // Create rules for domains with paths pathGroups.forEach((domains, path) => { const convertedRule = clone(rule); // Set syntax to Adg since we're adding AdGuard modifiers convertedRule.syntax = AdblockSyntax.Adg; // Set domains for this path convertedRule.domains = { type: 'DomainList', separator: rule.domains.separator, children: domains.map((d) => ({ type: 'Domain', value: d.domain, exception: d.exception, })), }; // Add $path modifier const pathModifier = createModifierNode(ADG_PATH_MODIFIER, path); if (convertedRule.modifiers) { convertedRule.modifiers = { ...convertedRule.modifiers, children: [...convertedRule.modifiers.children, pathModifier], }; } else { convertedRule.modifiers = { type: 'ModifierList', children: [pathModifier], }; } convertedRules.push(convertedRule); }); // Create rule for domains without paths (if any) if (domainsWithoutPaths.length > 0) { const convertedRule = clone(rule); convertedRule.domains = { type: 'DomainList', separator: rule.domains.separator, children: domainsWithoutPaths.map((d) => ({ type: 'Domain', value: d.domain, exception: d.exception, })), }; convertedRules.push(convertedRule); } return createNodeConversionResult(convertedRules, true); } export { convertPathInDomainToModifier };