Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions Sources/ContentBlockerConverter/Compiler/BlockerEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,46 +39,63 @@ 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,
caseSensitive: Bool? = nil,
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
self.caseSensitive = caseSensitive
self.loadContext = loadContext
}

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?
public var caseSensitive: Bool?
public var loadContext: [String]?

// 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"
case caseSensitive = "url-filter-is-case-sensitive"
case loadContext = "load-context"
}

// 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.caseSensitive == rhs.caseSensitive
&& 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
}
}
Expand Down
16 changes: 16 additions & 0 deletions Sources/ContentBlockerConverter/Compiler/BlockerEntryEncoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,22 @@ class BlockerEntryEncoder {
result.append(loadContext.encodeToJSON())
}

if let requestMethod = trigger.requestMethod {
result.append(",\"request-method\":\"")
result.append(requestMethod.escapeForJSON())
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))
Expand Down
65 changes: 61 additions & 4 deletions Sources/ContentBlockerConverter/Compiler/BlockerEntryFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,7 @@ class BlockerEntryFactory {
func createBlockerEntries(rule: Rule) -> [BlockerEntry]? {
do {
if let rule = rule as? NetworkRule {
let entry = try convertNetworkRule(rule: rule)

return [entry]
return try convertNetworkRuleEntries(rule: rule)
}

if let rule = rule as? CosmeticRule {
Expand All @@ -82,13 +80,27 @@ class BlockerEntryFactory {
return nil
}

private func convertNetworkRuleEntries(rule: NetworkRule) throws -> [BlockerEntry] {
if rule.requestMethods.isEmpty {
return [try convertNetworkRule(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)
}
}

/// Converts a network rule into a Safari content blocking rule.
///
/// - Parameters:
/// - rule: Network rule to convert.
/// - Returns: Safari content blocker entry.
/// - Throws: `ConversionError` if the rule cannot be converted.
private func convertNetworkRule(rule: NetworkRule) throws -> BlockerEntry {
private func convertNetworkRule(rule: NetworkRule, requestMethod: String?) throws -> BlockerEntry {
let urlFilter = try createUrlFilterString(rule: rule)

var trigger = BlockerEntry.Trigger(urlFilter: urlFilter)
Expand All @@ -102,6 +114,10 @@ class BlockerEntryFactory {

try updateTriggerForDocumentLevelExceptionRules(rule: rule, trigger: &trigger)

if let requestMethod {
trigger.requestMethod = requestMethod
}

let result = BlockerEntry(trigger: trigger, action: action)

return result
Expand Down Expand Up @@ -447,6 +463,11 @@ class BlockerEntryFactory {
///
/// Domain limitations are controlled by the "if-domain" and "unless-domain" arrays.
private func addDomainOptions(rule: Rule, trigger: inout BlockerEntry.Trigger) throws {
if self.version.isSafari26orGreater() {
try addFrameUrlDomainOptions(rule: rule, trigger: &trigger)
return
}

let included = resolveDomains(domains: rule.permittedDomains)
var excluded = resolveDomains(domains: rule.restrictedDomains)

Expand All @@ -467,6 +488,42 @@ class BlockerEntryFactory {
}
}

private func addFrameUrlDomainOptions(rule: Rule, trigger: inout BlockerEntry.Trigger) throws {
let included = rule.permittedDomains
let excluded = rule.restrictedDomains

if !included.isEmpty && !excluded.isEmpty {
throw ConversionError.invalidDomains(
message: "Safari does not support both permitted and restricted domains"
)
}

if !included.isEmpty {
trigger.ifFrameUrl = try createFrameUrlPatterns(domains: included)
}

if !excluded.isEmpty {
trigger.unlessFrameUrl = try createFrameUrlPatterns(domains: excluded)
}
}

private func createFrameUrlPatterns(domains: [String]) throws -> [String] {
var result: [String] = []
result.reserveCapacity(domains.count)

for domain in domains {
if domain.utf8.last == Chars.WILDCARD {
let prefix = String(domain.dropLast(2))
let escaped = NSRegularExpression.escapedPattern(for: prefix)
result.append(#"^[^:]+://+([^:/]+\.)?\#(escaped)\.[^/:]+[/:]?"#)
} else {
result.append(try SimpleRegex.createRegexText(pattern: "||\(domain)^"))
}
}
Comment on lines +514 to +522
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's inconsistent indentation in this method. The for loop has an extra level of indentation that doesn't match the surrounding code style.


return result
}

/// Adds domain to unless-domains for third-party rules
///
/// This is an attempt to fix this issue:
Expand Down
52 changes: 51 additions & 1 deletion Sources/ContentBlockerConverter/Rules/NetworkRule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public class NetworkRule: Rule {
public var isCheckThirdParty = false
public var isThirdParty = false
public var isMatchCase = false
public var requestMethods: [String] = []
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using Set<String> instead of [String] for the requestMethods property. This would provide more efficient membership checking and eliminate the need for the manual duplicate check on line 219.


// TODO: [ameshkov]: Modifying url-filter for WebSocket was required until
// Safari 15, it can be removed now.
Expand Down Expand Up @@ -176,6 +177,51 @@ public class NetworkRule: Rule {
try addDomains(domainsStr: domains, separator: Chars.PIPE)
}

private static let supportedRequestMethods: Set<String> = [
"get",
"head",
"options",
"trace",
"put",
"delete",
"post",
"patch",
"connect",
]
Comment on lines +180 to +190
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider moving the supportedRequestMethods set inside the setRequestMethods method since it's only used there, or at least mark it as private static to better encapsulate it.


private func setRequestMethods(methods: String, version: SafariVersion) throws {
if !version.isSafari26orGreater() {
throw SyntaxError.invalidModifier(message: "$method is not supported")
}

if methods.isEmpty {
throw SyntaxError.invalidModifier(message: "$method cannot be empty")
}

let values = methods.split(delimiter: Chars.PIPE, escapeChar: Chars.BACKSLASH)
for value in values {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
throw SyntaxError.invalidModifier(message: "$method contains an empty method name")
}

if trimmed.utf8.first == Chars.TILDE {
throw SyntaxError.invalidModifier(
message: "$method does not support excluded methods: \(trimmed)"
)
}

let normalized = trimmed.lowercased()
if !NetworkRule.supportedRequestMethods.contains(normalized) {
throw SyntaxError.invalidModifier(message: "Unsupported $method value: \(trimmed)")
}

if !requestMethods.contains(normalized) {
requestMethods.append(normalized)
}
}
}

/// Checks that the rule and its options is valid.
///
/// - Throws: SyntaxError if the rule is not valid.
Expand Down Expand Up @@ -285,6 +331,8 @@ public class NetworkRule: Rule {
isBadfilter = true
case "domain", "from":
try setNetworkRuleDomains(domains: optionValue)
case "method":
try setRequestMethods(methods: optionValue, version: version)
case "elemhide", "ehide":
try setOptionEnabled(option: .elemhide, value: true)
case "generichide", "ghide":
Expand Down Expand Up @@ -356,7 +404,9 @@ public class NetworkRule: Rule {
throw SyntaxError.invalidModifier(message: "Unsupported modifier: \(optionName)")
}

if optionName != "domain" && optionName != "from" && !optionValue.isEmpty {
if optionName != "domain" && optionName != "from" && optionName != "method"
&& !optionValue.isEmpty
{
throw SyntaxError.invalidModifier(message: "Option \(optionName) must not have value")
}
}
Expand Down
6 changes: 6 additions & 0 deletions Sources/ContentBlockerConverter/Rules/SafariVersion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ public enum SafariVersion: CustomStringConvertible, CustomDebugStringConvertible
return self.doubleValue >= SafariVersion.safari16_4.doubleValue
}

/// Safari 26 adds new content blocker trigger fields like `request-method`
/// and `unless-frame-url`.
public func isSafari26orGreater() -> Bool {
return self.doubleValue >= 26.0
}

/// Detects the Safari version based on the current OS version.
/// - Returns: The detected SafariVersion based on the OS.
public static func autodetect() -> SafariVersion {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,25 @@ final class BlockerEntryEncoderTests: XCTestCase {
)
}
}

func testEntryWithRequestMethodAndFrameUrlCondition() throws {
let converter = BlockerEntryFactory(
errorsCounter: ErrorsCounter(),
version: SafariVersion(26.0)
)
let rule = try NetworkRule(
ruleText: "||example.com/path$domain=test.com,method=post",
for: SafariVersion(26.0)
)

let entries = converter.createBlockerEntries(rule: rule)

if let entries = entries {
let (result, _) = encoder.encode(entries: entries)
XCTAssertEqual(
result,
#"[{"trigger":{"url-filter":"^[^:]+://+([^:/]+\\.)?example\\.com\\/path","request-method":"post","if-frame-url":["^[^:]+://+([^:/]+\\.)?test\\.com[/:]"]},"action":{"type":"block"}}]"#
)
}
}
}
Loading