diff --git a/CHANGELOG.md b/CHANGELOG.md index 5884376..d3f7f4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Support domain wildcards (`domain.*`) and domain regexes on Safari 26+ using `if-frame-url`/`unless-frame-url`: [#100] + [unreleased]: https://github.com/AdguardTeam/SafariConverterLib/compare/v4.1.0...HEAD +[#100]: https://github.com/AdguardTeam/SafariConverterLib/issues/100 ## [v4.1.0] diff --git a/README.md b/README.md index ca5f234..7e8ccaf 100644 --- a/README.md +++ b/README.md @@ -155,11 +155,13 @@ certainly supports the most important types of those rules. `$domain=example.org|~sub.example.org`). Please upvote the [feature request][webkitmixeddomainsissue] to WebKit to lift this limitation. - - "Any TLD" (i.e. `domain.*`) is not fully supported. In the current - implementation the converter just replaces `.*` with top 100 popular TLDs. - This implementation will be improved [in the future][iftopurlissue]. - - Using regular expressions in `$domain` is not supported, but it also will - be improved [in the future][iftopurlissue]. + - "Any TLD" (i.e. `domain.*`): + - Safari 26+: supported via `if-frame-url`/`unless-frame-url`. + - Safari < 26: the converter replaces `.*` with top 100 popular TLDs. + - Using regular expressions in `$domain` (i.e. `$domain=/regexp/`): + - Safari 26+: supported via `if-frame-url`/`unless-frame-url` (limited to + regex that is [supported by Safari][safariregex]). + - Safari < 26: not supported. - `$denyallow` - this modifier is supported via converting `$denyallow` rule to a set of rules (one blocking rule + several unblocking rules). @@ -239,7 +241,6 @@ certainly supports the most important types of those rules. [safariregex]: https://developer.apple.com/documentation/safariservices/creating-a-content-blocker#Capture-URLs-by-pattern [webkitmixeddomainsissue]: https://bugs.webkit.org/show_bug.cgi?id=226076 [domainmodifier]: https://adguard.com/kb/general/ad-filtering/create-own-filters/#domain-modifier -[iftopurlissue]: https://github.com/AdguardTeam/SafariConverterLib/issues/20#issuecomment-2532818732 [#69]: https://github.com/AdguardTeam/SafariConverterLib/issues/69 [#70]: https://github.com/AdguardTeam/SafariConverterLib/issues/70 [#71]: https://github.com/AdguardTeam/SafariConverterLib/issues/71 @@ -289,11 +290,13 @@ additional extension. #### Limitations of cosmetic rules - Specifying domains is subject to limitations: - - "Any TLD" (i.e. `domain.*`) is not fully supported. In the current - implementation the converter just replaces `.*` with top 100 popular TLDs. - This implementation will be improved [in the future][iftopurlissue]. - - Using regular expressions in `$domain` is not supported, but it also will - be improved [in the future][iftopurlissue]. + - "Any TLD" (i.e. `domain.*`): + - Safari 26+: supported via `if-frame-url`/`unless-frame-url`. + - Safari < 26: the converter replaces `.*` with top 100 popular TLDs. + - Using regular expressions in `$domain` (i.e. `$domain=/regexp/`): + - Safari 26+: supported via `if-frame-url`/`unless-frame-url` (limited to + regex that is [supported by Safari][safariregex]). + - Safari < 26: not supported. - CSS exception rules (`#@#`) are supported with limitations: - The same limitations for specifying domains as with other cosmetic rules. diff --git a/Sources/ContentBlockerConverter/Compiler/BlockerEntry.swift b/Sources/ContentBlockerConverter/Compiler/BlockerEntry.swift index 85c9c80..b4cd9e1 100644 --- a/Sources/ContentBlockerConverter/Compiler/BlockerEntry.swift +++ b/Sources/ContentBlockerConverter/Compiler/BlockerEntry.swift @@ -39,8 +39,10 @@ public struct BlockerEntry: Codable, Equatable, CustomStringConvertible { public struct Trigger: Codable, Equatable { public init( ifDomain: [String]? = nil, + ifFrameUrl: [String]? = nil, urlFilter: String? = nil, unlessDomain: [String]? = nil, + unlessFrameUrl: [String]? = nil, loadType: [String]? = nil, resourceType: [String]? = nil, requestMethod: String? = nil, @@ -48,8 +50,10 @@ public struct BlockerEntry: Codable, Equatable, CustomStringConvertible { loadContext: [String]? = nil ) { self.ifDomain = ifDomain + self.ifFrameUrl = ifFrameUrl self.urlFilter = urlFilter self.unlessDomain = unlessDomain + self.unlessFrameUrl = unlessFrameUrl self.loadType = loadType self.resourceType = resourceType self.requestMethod = requestMethod @@ -58,8 +62,10 @@ public struct BlockerEntry: Codable, Equatable, CustomStringConvertible { } public var ifDomain: [String]? + public var ifFrameUrl: [String]? public var urlFilter: String? public var unlessDomain: [String]? + public var unlessFrameUrl: [String]? public var loadType: [String]? public var resourceType: [String]? public var requestMethod: String? @@ -69,8 +75,10 @@ public struct BlockerEntry: Codable, Equatable, CustomStringConvertible { // swiftlint:disable:next nesting enum CodingKeys: String, CodingKey { case ifDomain = "if-domain" + case ifFrameUrl = "if-frame-url" case urlFilter = "url-filter" case unlessDomain = "unless-domain" + case unlessFrameUrl = "unless-frame-url" case loadType = "load-type" case resourceType = "resource-type" case requestMethod = "request-method" @@ -81,9 +89,14 @@ public struct BlockerEntry: Codable, Equatable, CustomStringConvertible { // Custom Equatable implementation public static func == (lhs: Trigger, rhs: Trigger) -> Bool { return lhs.ifDomain == rhs.ifDomain && lhs.urlFilter == rhs.urlFilter - && lhs.unlessDomain == rhs.unlessDomain && lhs.loadType == rhs.loadType - && lhs.resourceType == rhs.resourceType && lhs.requestMethod == rhs.requestMethod - && lhs.caseSensitive == rhs.caseSensitive && lhs.loadContext == rhs.loadContext + && lhs.ifFrameUrl == rhs.ifFrameUrl + && lhs.unlessDomain == rhs.unlessDomain + && lhs.unlessFrameUrl == rhs.unlessFrameUrl + && lhs.loadType == rhs.loadType + && lhs.resourceType == rhs.resourceType + && lhs.requestMethod == rhs.requestMethod + && lhs.caseSensitive == rhs.caseSensitive + && lhs.loadContext == rhs.loadContext } } diff --git a/Sources/ContentBlockerConverter/Compiler/BlockerEntryEncoder.swift b/Sources/ContentBlockerConverter/Compiler/BlockerEntryEncoder.swift index 926e313..10547ea 100644 --- a/Sources/ContentBlockerConverter/Compiler/BlockerEntryEncoder.swift +++ b/Sources/ContentBlockerConverter/Compiler/BlockerEntryEncoder.swift @@ -126,6 +126,16 @@ class BlockerEntryEncoder { result.append("\"") } + if let ifFrameUrl = trigger.ifFrameUrl { + result.append(",\"if-frame-url\":") + result.append(ifFrameUrl.encodeToJSON(escape: true)) + } + + if let unlessFrameUrl = trigger.unlessFrameUrl { + result.append(",\"unless-frame-url\":") + result.append(unlessFrameUrl.encodeToJSON(escape: true)) + } + if let ifDomain = trigger.ifDomain { result.append(",\"if-domain\":") result.append(ifDomain.encodeToJSON(escape: true)) diff --git a/Sources/ContentBlockerConverter/Compiler/BlockerEntryFactory.swift b/Sources/ContentBlockerConverter/Compiler/BlockerEntryFactory.swift index d5a04ca..1f5ce25 100644 --- a/Sources/ContentBlockerConverter/Compiler/BlockerEntryFactory.swift +++ b/Sources/ContentBlockerConverter/Compiler/BlockerEntryFactory.swift @@ -67,8 +67,7 @@ class BlockerEntryFactory { return try convertCosmeticRuleMixedDomains(rule: rule) } - let entry = try convertCosmeticRule(rule: rule) - return [entry] + return try convertCosmeticRuleEntries(rule: rule) } } catch { self.errorsCounter.add() @@ -89,50 +88,59 @@ class BlockerEntryFactory { /// - Throws: `ConversionError` if the rule cannot be converted. private func convertNetworkRuleEntries(rule: NetworkRule) throws -> [BlockerEntry] { if rule.requestMethods.isEmpty { - return [try convertNetworkRule(rule: rule, requestMethod: nil)] + return try convertNetworkRuleEntries(rule: rule, requestMethod: nil) } if !self.version.isSafari26orGreater() { throw ConversionError.unsupportedRule(message: "$method is not supported") } - return try rule.requestMethods.map { method in - try convertNetworkRule(rule: rule, requestMethod: method) + var entries: [BlockerEntry] = [] + entries.reserveCapacity(rule.requestMethods.count) + + for method in rule.requestMethods { + entries.append( + contentsOf: try convertNetworkRuleEntries( + rule: rule, + requestMethod: method + ) + ) } + return entries } - /// Converts a network rule into a Safari content blocking rule. + /// Converts a network rule into one or more Safari content blocking rules with a fixed request method. /// /// - Parameters: /// - rule: Network rule to convert. - /// - Returns: Safari content blocker entry. + /// - requestMethod: HTTP request method to match, if any. + /// - Returns: Array of Safari content blocker entries. /// - Throws: `ConversionError` if the rule cannot be converted. - private func convertNetworkRule( + private func convertNetworkRuleEntries( rule: NetworkRule, requestMethod: String? - ) throws -> BlockerEntry { + ) throws -> [BlockerEntry] { let urlFilter = try createUrlFilterString(rule: rule) - var trigger = BlockerEntry.Trigger(urlFilter: urlFilter) + var baseTrigger = BlockerEntry.Trigger(urlFilter: urlFilter) let action = BlockerEntry.Action(type: rule.isWhiteList ? "ignore-previous-rules" : "block") - try addResourceType(rule: rule, trigger: &trigger) - addLoadContext(rule: rule, trigger: &trigger) - addThirdParty(rule: rule, trigger: &trigger) - addMatchCase(rule: rule, trigger: &trigger) - try addDomainOptions(rule: rule, trigger: &trigger) + try addResourceType(rule: rule, trigger: &baseTrigger) + addLoadContext(rule: rule, trigger: &baseTrigger) + addThirdParty(rule: rule, trigger: &baseTrigger) + addMatchCase(rule: rule, trigger: &baseTrigger) - try updateTriggerForDocumentLevelExceptionRules(rule: rule, trigger: &trigger) + updateTriggerForDocumentLevelExceptionRules(rule: rule, trigger: &baseTrigger) + var triggers = try createTriggersWithDomainOptions(rule: rule, baseTrigger: baseTrigger) if let requestMethod { - trigger.requestMethod = requestMethod + for index in triggers.indices { + triggers[index].requestMethod = requestMethod + } } - let result = BlockerEntry(trigger: trigger, action: action) - - return result + return triggers.map { BlockerEntry(trigger: $0, action: action) } } - /// Validates if the cosmetic rule can be converted into a content blocker rule. /// /// - Parameters: @@ -162,18 +170,15 @@ class BlockerEntryFactory { /// - rule: Cosmetic rule to convert. /// - Returns: Content blocker entry. /// - Throws: `ConversionError` if the rule cannot be converted. - private func convertCosmeticRule(rule: CosmeticRule) throws -> BlockerEntry { + private func convertCosmeticRuleEntries(rule: CosmeticRule) throws -> [BlockerEntry] { try validateCosmeticRule(rule: rule) let urlFilter = try createUrlFilterStringForCosmetic(rule: rule) - var trigger = BlockerEntry.Trigger(urlFilter: urlFilter) let action = BlockerEntry.Action(type: "css-display-none", selector: rule.content) - try addDomainOptions(rule: rule, trigger: &trigger) - - let result = BlockerEntry(trigger: trigger, action: action) - - return result + let baseTrigger = BlockerEntry.Trigger(urlFilter: urlFilter) + let triggers = try createTriggersWithDomainOptions(rule: rule, baseTrigger: baseTrigger) + return triggers.map { BlockerEntry(trigger: $0, action: action) } } /// Creates several `css-display-none` entries for a cosmetic rule that has @@ -469,14 +474,24 @@ class BlockerEntryFactory { } } - /// Adds domain limitations to the rule's trigger block. + /// Applies domain limitations to the rule's trigger block. /// - /// Domain limitations are controlled by the "if-domain" and "unless-domain" arrays. - private func addDomainOptions(rule: Rule, trigger: inout BlockerEntry.Trigger) throws { - let included = resolveDomains(domains: rule.permittedDomains) - var excluded = resolveDomains(domains: rule.restrictedDomains) - - addUnlessDomainForThirdParty(rule: rule, domains: &excluded) + /// For older Safari versions we use `if-domain`/`unless-domain`. + /// Starting from Safari 26, to support `domain.*` and domain regexes we use a hybrid approach: + /// prefer `if-domain`/`unless-domain` where possible and fall back to + /// `if-frame-url`/`unless-frame-url` for `domain.*` and regex domains. + /// + /// - Parameters: + /// - rule: Rule for which we apply domain limitations. + /// - baseTrigger: Trigger without domain-related fields. + /// - Returns: One or more triggers (for Safari 26+ we may need to split domain limitations). + /// - Throws: `ConversionError` if the domain limitations cannot be represented. + private func createTriggersWithDomainOptions( + rule: Rule, + baseTrigger: BlockerEntry.Trigger + ) throws -> [BlockerEntry.Trigger] { + let included = rule.permittedDomains + let excluded = rule.restrictedDomains if !included.isEmpty && !excluded.isEmpty { throw ConversionError.invalidDomains( @@ -484,12 +499,179 @@ class BlockerEntryFactory { ) } - if !included.isEmpty { - trigger.ifDomain = included + if !self.version.isSafari26orGreater() { + return [createLegacyDomainTrigger(rule: rule, baseTrigger: baseTrigger)] + } + + // No domain limitations. + if included.isEmpty && excluded.isEmpty { + return [baseTrigger] + } + + let domains = included.isEmpty ? excluded : included + let (plain, special) = splitDomainsForSafari26(domains: domains) + + return try createTriggersWithFrameUrlSupport( + included: included, + plain: plain, + special: special, + baseTrigger: baseTrigger + ) + } + + /// Creates a trigger using the legacy domain fields (`if-domain`/`unless-domain`). + /// + /// This path is used for Safari versions earlier than Safari 26, where we cannot + /// fall back to `if-frame-url`/`unless-frame-url` for advanced domain patterns. + private func createLegacyDomainTrigger( + rule: Rule, + baseTrigger: BlockerEntry.Trigger + ) -> BlockerEntry.Trigger { + var trigger = baseTrigger + + let resolvedIncluded = resolveDomains(domains: rule.permittedDomains) + var resolvedExcluded = resolveDomains(domains: rule.restrictedDomains) + addUnlessDomainForThirdParty(rule: rule, domains: &resolvedExcluded) + + if !resolvedIncluded.isEmpty { + trigger.ifDomain = resolvedIncluded + } + + if !resolvedExcluded.isEmpty { + trigger.unlessDomain = resolvedExcluded + } + + return trigger + } + + private func createTriggersWithFrameUrlSupport( + included: [String], + plain: [String], + special: [String], + baseTrigger: BlockerEntry.Trigger + ) throws -> [BlockerEntry.Trigger] { + var triggers: [BlockerEntry.Trigger] = [] + triggers.reserveCapacity((plain.isEmpty ? 0 : 1) + (special.isEmpty ? 0 : 1)) + + if !plain.isEmpty { + var trigger = baseTrigger + // Prepend * to include subdomains. + let wildcardDomains = plain.map { "*" + $0 } + if included.isEmpty { + trigger.unlessDomain = wildcardDomains + } else { + trigger.ifDomain = wildcardDomains + } + triggers.append(trigger) } - if !excluded.isEmpty { - trigger.unlessDomain = excluded + if !special.isEmpty { + var trigger = baseTrigger + let frameUrlPatterns = try createFrameUrlPatterns(domains: special) + if included.isEmpty { + trigger.unlessFrameUrl = frameUrlPatterns + } else { + trigger.ifFrameUrl = frameUrlPatterns + } + triggers.append(trigger) + } + + return triggers + } + + private func splitDomainsForSafari26( + domains: [String] + ) -> ( + plain: [String], + special: [String] + ) { + var plain: [String] = [] + var special: [String] = [] + plain.reserveCapacity(domains.count) + + for domain in domains { + if SimpleRegex.isRegexPattern(domain) || isTldWildcardDomain(domain) { + special.append(domain) + } else { + plain.append(domain) + } + } + + return (plain, special) + } + + private func isTldWildcardDomain(_ domain: String) -> Bool { + return domain.hasSuffix(".*") + } + + private func createFrameUrlPatterns(domains: [String]) throws -> [String] { + return try domains.map { domain in + if isTldWildcardDomain(domain) { + return try createTldWildcardFrameUrlPattern(domain: domain) + } else if SimpleRegex.isRegexPattern(domain) { + return try createRegexFrameUrlPattern(domain: domain) + } + + throw ConversionError.invalidDomains( + message: "Unsupported frame-url domain pattern: \(domain)" + ) + } + } + + private func createTldWildcardFrameUrlPattern(domain: String) throws -> String { + // Convert `example.*` to a regex that matches `example.`. + let prefix = String(domain.dropLast(2)) + let escaped = prefix.replacingOccurrences(of: ".", with: #"\."#) + let pattern = #"^[^:]+://+([^:/]+\.)?\#(escaped)\.[^/:]+([/:?#].*)?$"# + try validateSafariRegex(pattern: pattern, context: domain) + return pattern + } + + private func createRegexFrameUrlPattern(domain: String) throws -> String { + guard var inner = SimpleRegex.extractRegex(domain) else { + throw ConversionError.invalidDomains( + message: "Invalid regular expression in domain: \(domain)" + ) + } + + inner = SimpleRegex.unescapeDomainRegex(inner) + + var hostAnchored = false + if inner.utf8.first == Chars.CARET { + hostAnchored = true + inner = String(inner.dropFirst()) + } + + if inner.utf8.last == Chars.DOLLAR { + inner = String(inner.dropLast()) + } + + if inner.isEmpty { + throw ConversionError.invalidDomains( + message: "Empty regular expression in domain" + ) + } + + let pattern: String + if hostAnchored { + pattern = #"^[^:]+://+\#(inner)([/:?#].*)?$"# + } else { + pattern = #"^[^:]+://+([^:/]+\.)?\#(inner)([/:?#].*)?$"# + } + + try validateSafariRegex(pattern: pattern, context: domain) + return pattern + } + + private func validateSafariRegex(pattern: String, context: String) throws { + let support = SafariRegex.isSupported(pattern: pattern) + switch support { + case .success: + return + case .failure(let error): + throw ConversionError.unsupportedRegExp( + message: "Unsupported regexp in domain \(context): \(error)" + ) } } @@ -548,7 +730,7 @@ class BlockerEntryFactory { private func updateTriggerForDocumentLevelExceptionRules( rule: NetworkRule, trigger: inout BlockerEntry.Trigger - ) throws { + ) { if !rule.isWhiteList { return } @@ -568,8 +750,9 @@ class BlockerEntryFactory { return } - rule.permittedDomains.append(ruleDomain.domain) - try addDomainOptions(rule: rule, trigger: &trigger) + if !rule.permittedDomains.contains(ruleDomain.domain) { + rule.permittedDomains.append(ruleDomain.domain) + } // Note, that for some domains it is crucial to use `.*` pattern as otherwise // Safari fails to match the page URL. diff --git a/Sources/ContentBlockerConverter/Rules/CosmeticRule.swift b/Sources/ContentBlockerConverter/Rules/CosmeticRule.swift index f7ca1dd..6c442ed 100644 --- a/Sources/ContentBlockerConverter/Rules/CosmeticRule.swift +++ b/Sources/ContentBlockerConverter/Rules/CosmeticRule.swift @@ -130,7 +130,7 @@ public class CosmeticRule: Rule { // Support for *## for generic rules // https://github.com/AdguardTeam/SafariConverterLib/issues/11 if !(domains.utf8.count == 1 && domains.utf8.first == Chars.WILDCARD) { - try setCosmeticRuleDomains(domains: domains) + try setCosmeticRuleDomains(domains: domains, version: version) } } @@ -258,13 +258,13 @@ public class CosmeticRule: Rule { } /// Parses a single cosmetic option. - private func parseOption(name: String, value: String) throws { + private func parseOption(name: String, value: String, version: SafariVersion) throws { switch name { case "domain", "from": if value.isEmpty { throw SyntaxError.invalidModifier(message: "$domain modifier cannot be empty") } - try addDomains(domainsStr: value, separator: Chars.PIPE) + try addDomains(domainsStr: value, separator: Chars.PIPE, version: version) case "path": if value.isEmpty { throw SyntaxError.invalidRule(message: "$path modifier cannot be empty") @@ -299,7 +299,7 @@ public class CosmeticRule: Rule { /// https://adguard.com/kb/general/ad-filtering/create-own-filters/#non-basic-rules-modifiers /// /// - Returns: what's left of the domains string or nil if the rule only has cosmetic options. - private func parseCosmeticOptions(domains: String) throws -> String? { + private func parseCosmeticOptions(domains: String, version: SafariVersion) throws -> String? { let startIndex = domains.utf8.index(domains.utf8.startIndex, offsetBy: 2) guard let endIndex = domains.utf8.lastIndex(of: Chars.SQUARE_BRACKET_CLOSE) else { throw SyntaxError.invalidModifier(message: "Invalid option format") @@ -321,7 +321,7 @@ public class CosmeticRule: Rule { optionValue = String(option[option.utf8.index(after: valueIndex)...]) } - try parseOption(name: optionName, value: optionValue) + try parseOption(name: optionName, value: optionValue, version: version) } // Parse what's left after the options string. @@ -334,15 +334,19 @@ public class CosmeticRule: Rule { return nil } - func setCosmeticRuleDomains(domains: String) throws { + func setCosmeticRuleDomains(domains: String, version: SafariVersion) throws { if domains.utf8.first == Chars.SQUARE_BRACKET_OPEN { - if let remainingDomains = try parseCosmeticOptions(domains: domains), + if let remainingDomains = try parseCosmeticOptions(domains: domains, version: version), !remainingDomains.isEmpty { - try addDomains(domainsStr: remainingDomains, separator: Chars.COMMA) + try addDomains( + domainsStr: remainingDomains, + separator: Chars.COMMA, + version: version + ) } } else { - try addDomains(domainsStr: domains, separator: Chars.COMMA) + try addDomains(domainsStr: domains, separator: Chars.COMMA, version: version) } } } diff --git a/Sources/ContentBlockerConverter/Rules/NetworkRule.swift b/Sources/ContentBlockerConverter/Rules/NetworkRule.swift index 809b18e..c2fb5e0 100644 --- a/Sources/ContentBlockerConverter/Rules/NetworkRule.swift +++ b/Sources/ContentBlockerConverter/Rules/NetworkRule.swift @@ -174,12 +174,12 @@ public class NetworkRule: Rule { } /// Sets rule domains from the $domain modifier. - private func setNetworkRuleDomains(domains: String) throws { + private func setNetworkRuleDomains(domains: String, version: SafariVersion) throws { if domains.isEmpty { throw SyntaxError.invalidModifier(message: "$domain cannot be empty") } - try addDomains(domainsStr: domains, separator: Chars.PIPE) + try addDomains(domainsStr: domains, separator: Chars.PIPE, version: version) } /// Supported HTTP request methods for the `$method` modifier. @@ -337,7 +337,7 @@ public class NetworkRule: Rule { case "badfilter": isBadfilter = true case "domain", "from": - try setNetworkRuleDomains(domains: optionValue) + try setNetworkRuleDomains(domains: optionValue, version: version) case "method": try setRequestMethods(methods: optionValue) case "elemhide", "ehide": diff --git a/Sources/ContentBlockerConverter/Rules/Rule.swift b/Sources/ContentBlockerConverter/Rules/Rule.swift index 3c00329..5d52a09 100644 --- a/Sources/ContentBlockerConverter/Rules/Rule.swift +++ b/Sources/ContentBlockerConverter/Rules/Rule.swift @@ -23,14 +23,20 @@ public class Rule { /// starts with `~` it will be added to `restrictedDomains`, /// otherwise to `permittedDomains`. /// - separator: Separator character for the domains list. + /// - version: Safari version which will use that rule. /// /// - Throws: `SyntaxError` if the list is invalid. - func addDomains(domainsStr: String, separator: UInt8) throws { + func addDomains(domainsStr: String, separator: UInt8, version: SafariVersion) throws { let utf8 = domainsStr.utf8 var currentIndex = utf8.startIndex var domainStartIndex = currentIndex var nonASCIIFound = false var restricted = false + var insideRegex = false + // Number of consecutive backslashes immediately preceding `currentIndex`. + // Used to detect whether a slash is escaped inside a regex domain (odd count) + // or can terminate the regex (even count). + var precedingBackslashCount = 0 /// Creates domain string from `current` buffer and adds it to the corresponding list. /// @@ -44,22 +50,41 @@ public class Rule { } var domain = String(domainsStr[domainStartIndex..= SafariVersion.safari16_4.doubleValue } - /// Starting from Safari 26 content blockers add new trigger fields like - /// `request-method`. + /// Starting from Safari 26 content blockers add new trigger fields like `request-method`, + /// `if-frame-url`, and `unless-frame-url`. public func isSafari26orGreater() -> Bool { return self.doubleValue >= SafariVersion.safari26.doubleValue } diff --git a/Sources/ContentBlockerConverter/Rules/SimpleRegex.swift b/Sources/ContentBlockerConverter/Rules/SimpleRegex.swift index ea952a9..a255c3c 100644 --- a/Sources/ContentBlockerConverter/Rules/SimpleRegex.swift +++ b/Sources/ContentBlockerConverter/Rules/SimpleRegex.swift @@ -158,4 +158,44 @@ public enum SimpleRegex { return String(pattern[startIndex.. String { + let utf8 = pattern.utf8 + var index = utf8.startIndex + let endIndex = utf8.endIndex + var bytes: [UInt8] = [] + bytes.reserveCapacity(utf8.count) + + while index < endIndex { + let char = utf8[index] + if char == Chars.BACKSLASH { + let nextIndex = utf8.index(after: index) + if nextIndex < endIndex { + let nextChar = utf8[nextIndex] + if nextChar == Chars.SLASH || nextChar == UInt8(ascii: "$") + || nextChar == UInt8(ascii: ",") || nextChar == UInt8(ascii: "|") + { + bytes.append(nextChar) + index = utf8.index(after: nextIndex) + continue + } + } + } + + bytes.append(char) + index = utf8.index(after: index) + } + + return String(bytes: bytes, encoding: .utf8) ?? pattern + } } diff --git a/Tests/ContentBlockerConverterTests/Compiler/BlockerEntryEncoderTests.swift b/Tests/ContentBlockerConverterTests/Compiler/BlockerEntryEncoderTests.swift index afcbc82..210770b 100644 --- a/Tests/ContentBlockerConverterTests/Compiler/BlockerEntryEncoderTests.swift +++ b/Tests/ContentBlockerConverterTests/Compiler/BlockerEntryEncoderTests.swift @@ -49,4 +49,22 @@ final class BlockerEntryEncoderTests: XCTestCase { ) } } + + func testFrameUrlEntry() throws { + let converter = BlockerEntryFactory( + errorsCounter: ErrorsCounter(), + version: SafariVersion.safari26 + ) + let rule = try NetworkRule(ruleText: "||example.com/path$domain=test.*") + + let entries = converter.createBlockerEntries(rule: rule) + + if let entries = entries { + let (result, _) = encoder.encode(entries: entries) + XCTAssertEqual( + result, + #"[{"trigger":{"url-filter":"^[^:]+://+([^:/]+\\.)?example\\.com\\/path","if-frame-url":["^[^:]+://+([^:/]+\\.)?test\\.[^/:]+([/:?#].*)?$"]},"action":{"type":"block"}}]"# + ) + } + } } diff --git a/Tests/ContentBlockerConverterTests/Compiler/BlockerEntryFactoryTests.swift b/Tests/ContentBlockerConverterTests/Compiler/BlockerEntryFactoryTests.swift index e24ff9c..0450a28 100644 --- a/Tests/ContentBlockerConverterTests/Compiler/BlockerEntryFactoryTests.swift +++ b/Tests/ContentBlockerConverterTests/Compiler/BlockerEntryFactoryTests.swift @@ -128,6 +128,81 @@ final class BlockerEntryFactoryTests: XCTestCase { } } + // MARK: - Safari 26 domains + + func testSafari26FrameUrlDomainOptions() { + let testCases: [TestCase] = [ + TestCase( + ruleText: "||example.com/path$domain=test.com", + version: SafariVersion.safari26, + expectedEntry: BlockerEntry( + trigger: BlockerEntry.Trigger( + ifDomain: ["*test.com"], + urlFilter: #"^[^:]+://+([^:/]+\.)?example\.com\/path"# + ), + action: BlockerEntry.Action(type: "block") + ) + ), + TestCase( + ruleText: "||example.com/path$domain=test.*", + version: SafariVersion.safari26, + expectedEntry: BlockerEntry( + trigger: BlockerEntry.Trigger( + ifFrameUrl: [#"^[^:]+://+([^:/]+\.)?test\.[^/:]+([/:?#].*)?$"#], + urlFilter: #"^[^:]+://+([^:/]+\.)?example\.com\/path"# + ), + action: BlockerEntry.Action(type: "block") + ) + ), + TestCase( + ruleText: "||example.com/path$domain=test.com|test.*", + version: SafariVersion.safari26, + expectedEntries: [ + BlockerEntry( + trigger: BlockerEntry.Trigger( + ifDomain: ["*test.com"], + urlFilter: #"^[^:]+://+([^:/]+\.)?example\.com\/path"# + ), + action: BlockerEntry.Action(type: "block") + ), + BlockerEntry( + trigger: BlockerEntry.Trigger( + ifFrameUrl: [#"^[^:]+://+([^:/]+\.)?test\.[^/:]+([/:?#].*)?$"#], + urlFilter: #"^[^:]+://+([^:/]+\.)?example\.com\/path"# + ), + action: BlockerEntry.Action(type: "block") + ), + ] + ), + TestCase( + ruleText: "||example.com/path$domain=~test.*", + version: SafariVersion.safari26, + expectedEntry: BlockerEntry( + trigger: BlockerEntry.Trigger( + urlFilter: #"^[^:]+://+([^:/]+\.)?example\.com\/path"#, + unlessFrameUrl: [#"^[^:]+://+([^:/]+\.)?test\.[^/:]+([/:?#].*)?$"#] + ), + action: BlockerEntry.Action(type: "block") + ) + ), + TestCase( + ruleText: #"||example.com/path$domain=/test\.[a-z]+/"#, + version: SafariVersion.safari26, + expectedEntry: BlockerEntry( + trigger: BlockerEntry.Trigger( + ifFrameUrl: [#"^[^:]+://+([^:/]+\.)?test\.[a-z]+([/:?#].*)?$"#], + urlFilter: #"^[^:]+://+([^:/]+\.)?example\.com\/path"# + ), + action: BlockerEntry.Action(type: "block") + ) + ), + ] + + for testCase in testCases { + runTest(testCase) + } + } + // MARK: - Request methods func testRequestMethodRules() { diff --git a/Tests/ContentBlockerConverterTests/ContentBlockerConverterPerformanceTests.swift b/Tests/ContentBlockerConverterTests/ContentBlockerConverterPerformanceTests.swift index e79e4d7..2ae627a 100644 --- a/Tests/ContentBlockerConverterTests/ContentBlockerConverterPerformanceTests.swift +++ b/Tests/ContentBlockerConverterTests/ContentBlockerConverterPerformanceTests.swift @@ -51,7 +51,6 @@ extension ContentBlockerConverterTests { } /// Benchmark test for convertArray performance. - /// /// Baseline results (Aug 8, 2025): /// - Machine: MacBook Pro M4 Max, 48GB RAM /// - OS: macOS 26 @@ -64,6 +63,12 @@ extension ContentBlockerConverterTests { /// - Swift: 6.2 /// - Average execution time: ~0.787 sec /// + /// Baseline results (Dec 25, 2025): + /// - Machine: Apple M3, 16GB RAM + /// - OS: macOS 26.2 + /// - Swift: 6.2.3 + /// - Average execution time: ~0.924 sec + /// /// To get your machine info: `system_profiler SPHardwareDataType` /// To get your macOS version: `sw_vers` /// To get your Swift version: `swift --version` @@ -106,6 +111,12 @@ extension ContentBlockerConverterTests { /// - Average execution time: ~0.196 seconds /// - No considerable changes since the prev baseline so the diff is due to env changes. /// + /// Baseline results (Dec 25, 2025): + /// - Machine: Apple M3, 16GB RAM + /// - OS: macOS 26.2 + /// - Swift: 6.2.3 + /// - Average execution time: ~0.223 seconds + /// /// To get your machine info: `system_profiler SPHardwareDataType` /// To get your macOS version: `sw_vers` /// To get your Swift version: `swift --version` diff --git a/Tests/ContentBlockerConverterTests/ContentBlockerConverterTests.swift b/Tests/ContentBlockerConverterTests/ContentBlockerConverterTests.swift index 22716d8..fa3b6bb 100644 --- a/Tests/ContentBlockerConverterTests/ContentBlockerConverterTests.swift +++ b/Tests/ContentBlockerConverterTests/ContentBlockerConverterTests.swift @@ -1484,24 +1484,12 @@ final class ContentBlockerConverterTests: XCTestCase { rules: [ "||example.org$domain=/реклама\\.рф/" ], - expectedSafariRulesJSON: #""" - [ - { - "action" : { - "type" : "block" - }, - "trigger" : { - "if-domain" : [ - "*xn--\/\\-7kcax4ahj5a.xn--\/-4tbm" - ], - "url-filter" : "^[^:]+:\/\/+([^:\/]+\\.)?example\\.org" - } - } - ] - """#, + version: SafariVersion.safari26, + expectedSafariRulesJSON: ConversionResult.EMPTY_RESULT_JSON, expectedSourceRulesCount: 1, expectedSourceSafariCompatibleRulesCount: 1, - expectedSafariRulesCount: 1 + expectedSafariRulesCount: 0, + expectedErrorsCount: 1 ), ] diff --git a/Tests/ContentBlockerConverterTests/Rules/CosmeticRuleTests.swift b/Tests/ContentBlockerConverterTests/Rules/CosmeticRuleTests.swift index dd4cb99..dc60383 100644 --- a/Tests/ContentBlockerConverterTests/Rules/CosmeticRuleTests.swift +++ b/Tests/ContentBlockerConverterTests/Rules/CosmeticRuleTests.swift @@ -281,7 +281,8 @@ final class CosmeticRuleTests: XCTestCase { XCTAssertThrowsError(try CosmeticRule(ruleText: "jp,##.banner")) XCTAssertThrowsError(try CosmeticRule(ruleText: "jp,b##.banner")) XCTAssertThrowsError(try CosmeticRule(ruleText: "jp,~b##.banner")) - XCTAssertThrowsError(try CosmeticRule(ruleText: "com,/example/##.banner")) + XCTAssertThrowsError(try CosmeticRule(ruleText: "com,/example/##.banner", for: .safari16_4)) + XCTAssertNoThrow(try CosmeticRule(ruleText: "com,/example/##.banner", for: .safari26)) XCTAssertThrowsError(try CosmeticRule(ruleText: "[$path=/path]#@#.banner")) } diff --git a/Tests/ContentBlockerConverterTests/Rules/NetworkRuleTests.swift b/Tests/ContentBlockerConverterTests/Rules/NetworkRuleTests.swift index e58c50e..3d73242 100644 --- a/Tests/ContentBlockerConverterTests/Rules/NetworkRuleTests.swift +++ b/Tests/ContentBlockerConverterTests/Rules/NetworkRuleTests.swift @@ -216,6 +216,14 @@ final class NetworkRuleTests: XCTestCase { expectedPermittedDomains: ["example.org"], expectedRestrictedDomains: ["sub.example.org"] ), + TestCase( + // Regex domain should terminate on a slash preceded by an even number of backslashes. + ruleText: #"||example.org^$domain=/test\\/|example.net"#, + version: SafariVersion.safari26, + expectedUrlRuleText: "||example.org^", + expectedUrlRegExpSource: "^[^:]+://+([^:/]+\\.)?example\\.org[/:]", + expectedPermittedDomains: [#"/test\\/"#, "example.net"] + ), TestCase( // Test $domain for TLD. ruleText: "||example.org^$domain=jp|~co", @@ -421,6 +429,8 @@ final class NetworkRuleTests: XCTestCase { XCTAssertThrowsError(try NetworkRule(ruleText: "||example.org^$domain=~e")) // $domain with regexes are not supported. XCTAssertThrowsError(try NetworkRule(ruleText: "||example.org^$domain=/example.org/")) + // Empty $domain regex should be rejected. + XCTAssertThrowsError(try NetworkRule(ruleText: "||example.org^$domain=//")) // $domain with regexes are not supported. XCTAssertThrowsError( try NetworkRule(ruleText: "||example.org^$domain=example.org|/test.com/") diff --git a/Tests/ContentBlockerConverterTests/Rules/SimpleRegexTests.swift b/Tests/ContentBlockerConverterTests/Rules/SimpleRegexTests.swift index 087af9b..0b5e880 100644 --- a/Tests/ContentBlockerConverterTests/Rules/SimpleRegexTests.swift +++ b/Tests/ContentBlockerConverterTests/Rules/SimpleRegexTests.swift @@ -87,4 +87,26 @@ final class SimpleRegexTests: XCTestCase { ) } } + + func testUnescapeDomainRegex() { + let testCases: [(pattern: String, expected: String)] = [ + (#"abc"#, #"abc"#), + (#"a\/b"#, #"a/b"#), + (#"a\|b"#, #"a|b"#), + (#"a\$b"#, #"a$b"#), + (#"a\,b"#, #"a,b"#), + (#"a\.b"#, #"a\.b"#), + (#"a\\/"#, #"a\/"#), + ("a\\", "a\\"), + ] + + for (pattern, expected) in testCases { + let result = SimpleRegex.unescapeDomainRegex(pattern) + XCTAssertEqual( + result, + expected, + "Pattern '\(pattern)': expected unescapeDomainRegex to return \(expected), but got \(result)" + ) + } + } }