diff --git a/Sources/SwiftNetwork/Endpoint/IPv4Address.swift b/Sources/SwiftNetwork/Endpoint/IPv4Address.swift index db4ec6d..a707cee 100644 --- a/Sources/SwiftNetwork/Endpoint/IPv4Address.swift +++ b/Sources/SwiftNetwork/Endpoint/IPv4Address.swift @@ -130,6 +130,18 @@ public struct IPv4Address: IPAddress, Hashable, CustomDebugStringConvertible { self = IPv4Address(address) } + /// An IPv4 address parsed from a dotted-decimal string (e.g. `"192.168.1.1"`). + public init?(_ string: String) { + let octets = string.split(separator: ".", maxSplits: 3, omittingEmptySubsequences: false) + guard octets.count == 4, + let a = UInt8(octets[0]), + let b = UInt8(octets[1]), + let c = UInt8(octets[2]), + let d = UInt8(octets[3]) + else { return nil } + self.init((UInt32(a) << 24 | UInt32(b) << 16 | UInt32(c) << 8 | UInt32(d)).bigEndian) + } + static func ipv4AddressString(from address: UInt32) -> String { withUnsafeBytes(of: address) { "\($0[0]).\($0[1]).\($0[2]).\($0[3])" diff --git a/Sources/SwiftNetwork/Endpoint/IPv6Address.swift b/Sources/SwiftNetwork/Endpoint/IPv6Address.swift index 64db1eb..0407456 100644 --- a/Sources/SwiftNetwork/Endpoint/IPv6Address.swift +++ b/Sources/SwiftNetwork/Endpoint/IPv6Address.swift @@ -129,6 +129,58 @@ public struct IPv6Address: IPAddress, Hashable, CustomDebugStringConvertible { self = IPv6Address(address) } + /// An IPv6 address parsed from a string (e.g. `"2001:db8::1"` or `"::1"`). + public init?(_ string: String) { + guard let groups = IPv6Address.parseToGroups(string) else { return nil } + self.init( + ( + (UInt32(groups[0]) << 16 | UInt32(groups[1])).bigEndian, + (UInt32(groups[2]) << 16 | UInt32(groups[3])).bigEndian, + (UInt32(groups[4]) << 16 | UInt32(groups[5])).bigEndian, + (UInt32(groups[6]) << 16 | UInt32(groups[7])).bigEndian + ) + ) + } + + // Parses an IPv6 address string into 8 network-order UInt16 groups. + // Handles :: compression and full notation. + private static func parseToGroups(_ addr: String) -> [UInt16]? { + var searchIndex = addr.startIndex + + // Position of '::' in the string, if present. Used to expand compressed zeros. + var doubleColonRange: Range? = nil + while searchIndex < addr.endIndex { + let nextIndex = addr.index(after: searchIndex) + if nextIndex < addr.endIndex && addr[searchIndex] == ":" && addr[nextIndex] == ":" { + doubleColonRange = searchIndex.. [UInt16]? { + if half.isEmpty { return [] } + var result: [UInt16] = [] + for part in half.split(separator: ":", omittingEmptySubsequences: false) { + guard !part.isEmpty, part.count <= 4, let value = UInt16(part, radix: 16) else { return nil } + result.append(value) + } + return result + } + + if let range = doubleColonRange { + guard let left = parseHalf(String(addr[addr.startIndex..= 0 else { return nil } + return left + [UInt16](repeating: 0, count: zeroCount) + right + } else { + guard let groups = parseHalf(addr), groups.count == 8 else { return nil } + return groups + } + } + static func isIPv4Mapped(from address: (UInt32, UInt32, UInt32, UInt32)) -> Bool { address.0 == 0 && address.1 == 0 && address.2 == UInt32(0x0000_ffff).bigEndian } diff --git a/Sources/SwiftNetwork/Utilities/IPAddress+CIDR.swift b/Sources/SwiftNetwork/Utilities/IPAddress+CIDR.swift new file mode 100644 index 0000000..d72564e --- /dev/null +++ b/Sources/SwiftNetwork/Utilities/IPAddress+CIDR.swift @@ -0,0 +1,178 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +// MARK: - CIDR parsing + +// Parses an IPv4 CIDR string into a masked network address and subnet mask in network byte order. +// Supports shorthand notation (e.g. "17.142/16" expands to "17.142.0.0/16"). +private func parseCIDRv4(_ cidr: String) -> (network: UInt32, mask: UInt32)? { + let parts = cidr.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2, + let prefixLen = Int(parts[1]), prefixLen >= 0, prefixLen <= 32 + else { return nil } + + // Expand shorthand notation: "17.142" -> "17.142.0.0", "10" -> "10.0.0.0" + var addrString = String(parts[0]) + let dotCount = addrString.count(where: { $0 == "." }) + if dotCount < 3 { + addrString += String(repeating: ".0", count: 3 - dotCount) + } + + guard let addr = IPv4Address(addrString) else { return nil } + let hostMask: UInt32 = prefixLen == 0 ? 0 : UInt32.max << UInt32(32 - prefixLen) // shift by 32 is undefined + let mask = hostMask.bigEndian + return (network: addr.addressValue & mask, mask: mask) +} + +// Parses an IPv6 CIDR string into a masked network address and subnet mask, +// each represented as four network-byte-order UInt32 chunks. +@available(Network 0.1.0, *) +private func parseCIDRv6( + _ cidr: String +) -> (network: (UInt32, UInt32, UInt32, UInt32), mask: (UInt32, UInt32, UInt32, UInt32))? { + let parts = cidr.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2, + let prefixLen = Int(parts[1]), prefixLen >= 0, prefixLen <= 128 + else { return nil } + + guard let addr = IPv6Address(String(parts[0])) else { return nil } + let rawNet = addr.addressValue + + // bitsInChunk in 1..32 is safe: shift amount (32 - bitsInChunk) is in 0..31 + func chunkMask(_ bitsInChunk: Int) -> UInt32 { + guard bitsInChunk > 0 else { return 0 } + return (UInt32.max << UInt32(32 - bitsInChunk)).bigEndian + } + + let mask = ( + chunkMask(min(prefixLen, 32)), + chunkMask(min(max(prefixLen - 32, 0), 32)), + chunkMask(min(max(prefixLen - 64, 0), 32)), + chunkMask(min(max(prefixLen - 96, 0), 32)) + ) + + let network = ( + rawNet.0 & mask.0, + rawNet.1 & mask.1, + rawNet.2 & mask.2, + rawNet.3 & mask.3 + ) + + return (network: network, mask: mask) +} + +// MARK: - Domain pattern matching + +/// Returns true if `string` matches `pattern` using right-to-left dot-segment comparison. +/// Supports exact matches, suffix matches ("example.com" matches "www.example.com"), and wildcards +/// (`*.example.com`). Both inputs are case-insensitive; trailing dots are stripped before matching. +func matchesDomainPattern(_ string: String, pattern: String) -> Bool { + let host = (string.hasSuffix(".") ? String(string.dropLast()) : string).lowercased() + let pat = (pattern.hasSuffix(".") ? String(pattern.dropLast()) : pattern).lowercased() + if host == pat { return true } + let hostNodes = host.split(separator: ".", omittingEmptySubsequences: false) + var patNodes = pat.split(separator: ".", omittingEmptySubsequences: false) + // A leading empty segment (from a pattern starting with ".", e.g. ".example.com") + // is treated as a wildcard, matching like "*.example.com". + if patNodes.first?.isEmpty == true { patNodes[0] = "*" } + var j = hostNodes.count - 1 + var k = patNodes.count - 1 + while j >= 0 && k >= 0 { + let pn = patNodes[k] + let hn = hostNodes[j] + if pn == hn { + // A match at either boundary succeeds: a fully-consumed pattern (k == 0) is a + // suffix match, and a fully-consumed host (j == 0) matches even when pattern + // segments remain to the left, so "example.com" matches "www.example.com" and + // "*.example.com" matches the bare "example.com". + if j == 0 || k == 0 { return true } + j -= 1 + k -= 1 + } else if pn == "*" { + while k >= 0 { + let nx = patNodes[k] + if nx != "*" { break } + k -= 1 + } + if k < 0 { return true } + let target = patNodes[k] + while j >= 0 { + if hostNodes[j] == target { break } + j -= 1 + } + } else { + return false + } + } + return false +} + +extension IPv4Address { + /// Returns true if this address falls within the CIDR block in `pattern`, or if its string + /// representation matches `pattern` as a domain pattern. + func matches(pattern: String) -> Bool { + if let cidr = parseCIDRv4(pattern) { + return (addressValue & cidr.mask) == cidr.network + } + return matchesDomainPattern(debugDescription, pattern: pattern) + } +} + +@available(Network 0.1.0, *) +extension IPv6Address { + /// Returns true if the leading bytes of this address match any prefix in `prefixes`. + func isSynthesizedNAT64(prefixes: [NAT64Prefix]) -> Bool { + withUnsafeBytes(of: self.address) { selfBuf in + prefixes.contains { (prefix: NAT64Prefix) in + let len = Int(prefix.length.rawValue) + return withUnsafeBytes(of: prefix.address.address) { prefixBuf in + selfBuf.prefix(len).elementsEqual(prefixBuf.prefix(len)) + } + } + } + } + + /// Returns true if this address falls within the CIDR block in `pattern`, or if its string + /// representation matches `pattern` as a domain pattern. + func matches(pattern: String) -> Bool { + if let cidr = parseCIDRv6(pattern) { + let (a0, a1, a2, a3) = addressValue + let (n0, n1, n2, n3) = cidr.network + let (m0, m1, m2, m3) = cidr.mask + return (a0 & m0) == n0 && (a1 & m1) == n1 && (a2 & m2) == n2 && (a3 & m3) == n3 + } + return matchesDomainPattern(debugDescription, pattern: pattern) + } +} + +@available(Network 0.1.0, *) +extension Endpoint { + /// Returns true if this endpoint matches `pattern`. `"*"` matches all endpoints. Host endpoints + /// are matched by hostname; address endpoints are matched by IP address or CIDR block. + func matchesPattern(_ pattern: String) -> Bool { + if pattern == "*" { return true } + switch type { + case .host(let hostEndpoint): + return matchesDomainPattern(hostEndpoint.name, pattern: pattern) + case .address(let addressEndpoint): + switch addressEndpoint.type { + case .v4(let ipv4, _): return ipv4.matches(pattern: pattern) + case .v6(let ipv6, _): return ipv6.matches(pattern: pattern) + default: return false + } + default: + return false + } + } +} diff --git a/Tests/SwiftNetworkTests/SwiftNetworkCIDRTests.swift b/Tests/SwiftNetworkTests/SwiftNetworkCIDRTests.swift new file mode 100644 index 0000000..c91b308 --- /dev/null +++ b/Tests/SwiftNetworkTests/SwiftNetworkCIDRTests.swift @@ -0,0 +1,308 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import XCTest + +#if canImport(SwiftNetwork) +@_spi(Essentials) @testable import SwiftNetwork +#elseif canImport(Network) +@_spi(Essentials) import Network +#endif + +@available(Network 0.1.0, *) +final class SwiftNetworkCIDRTests: NetTestCase { + + // MARK: - matchesDomainPattern + + func testDomainPattern_exactMatch() { + XCTAssertTrue(matchesDomainPattern("example.com", pattern: "example.com")) + } + + func testDomainPattern_exactMatchCaseInsensitive() { + XCTAssertTrue(matchesDomainPattern("Example.COM", pattern: "example.com")) + XCTAssertTrue(matchesDomainPattern("example.com", pattern: "EXAMPLE.COM")) + } + + func testDomainPattern_trailingDotStripped() { + XCTAssertTrue(matchesDomainPattern("example.com.", pattern: "example.com")) + XCTAssertTrue(matchesDomainPattern("example.com", pattern: "example.com.")) + } + + func testDomainPattern_suffixMatch() { + // "example.com" as pattern matches subdomain "www.example.com" + XCTAssertTrue(matchesDomainPattern("www.example.com", pattern: "example.com")) + XCTAssertTrue(matchesDomainPattern("bar.foo.example.com", pattern: "example.com")) + XCTAssertTrue(matchesDomainPattern("foo.example.com", pattern: "example.com")) + } + + func testDomainPattern_hostShorterThanPatternStillMatchesOnSuffix() { + // Once the host is fully consumed against the pattern's rightmost segments it is a + // match, even if the pattern has extra segments to the left. So a host shorter than + // the pattern can still match on its suffix. + XCTAssertTrue(matchesDomainPattern("example.com", pattern: "www.example.com")) + XCTAssertTrue(matchesDomainPattern("com", pattern: "example.com")) + } + + func testDomainPattern_wildcardOneSegment() { + XCTAssertTrue(matchesDomainPattern("foo.example.com", pattern: "*.example.com")) + XCTAssertTrue(matchesDomainPattern("example.com", pattern: "*.com.")) + XCTAssertTrue(matchesDomainPattern("example.com", pattern: "*.com")) + XCTAssertTrue(matchesDomainPattern("foo.example.com", pattern: "*.example.*")) + XCTAssertTrue(matchesDomainPattern("foo.example.com", pattern: "foo.*.com")) + XCTAssertTrue(matchesDomainPattern("example.com", pattern: "example.*")) + } + + func testDomainPattern_wildcardMatchesDomainItself() { + // A leading wildcard can match zero segments, so "*.example.com" also matches + // "example.com" itself (not just its subdomains). + XCTAssertTrue(matchesDomainPattern("example.com", pattern: "*.example.com")) + // "foo.bar.example.com" has two labels beyond what "example.com" requires, so the + // algorithm truly reaches the pattern's leading "*" node and returns after, + // regardless of how many host segments remain to its left ("foo" is never inspected). + XCTAssertTrue(matchesDomainPattern("foo.bar.example.com", pattern: "*.example.com")) + } + + func testDomainPattern_wildcardMatchesAnything() { + XCTAssertTrue(matchesDomainPattern("example.com", pattern: "*")) + XCTAssertTrue(matchesDomainPattern("foo.bar.baz", pattern: "*")) + XCTAssertTrue(matchesDomainPattern("example.com", pattern: ".")) + } + + func testDomainPattern_tldOnlyPattern() { + // "com" as pattern matches any .com hostname (suffix match) + XCTAssertTrue(matchesDomainPattern("example.com", pattern: "com")) + XCTAssertTrue(matchesDomainPattern("www.example.com", pattern: "com")) + } + + func testDomainPattern_noMatch() { + XCTAssertFalse(matchesDomainPattern("test.com", pattern: "example.com")) + XCTAssertFalse(matchesDomainPattern("notexample.com", pattern: "example.com")) + XCTAssertFalse(matchesDomainPattern("example.com", pattern: "example")) + XCTAssertFalse(matchesDomainPattern("foo.example.com", pattern: "out.example.*")) + } + + func testDomainPattern_leadingDotTreatedAsWildcard() { + // ".example.com" behaves like "*.example.com" + XCTAssertTrue(matchesDomainPattern("www.example.com", pattern: ".example.com")) + XCTAssertTrue(matchesDomainPattern("foo.example.com", pattern: ".example.com")) + // The wildcard can match zero segments, so "example.com" itself matches too. + XCTAssertTrue(matchesDomainPattern("example.com", pattern: ".example.com")) + } + + // MARK: - IPv4Address.matches — CIDR + + func testIPv4Matches_cidrHit() { + let addr = IPv4Address([192, 168, 1, 5])! + XCTAssertTrue(addr.matches(pattern: "192.168.1.0/24")) + } + + func testIPv4Matches_cidrBoundaryHigh() { + let addr = IPv4Address([192, 168, 1, 255])! + XCTAssertTrue(addr.matches(pattern: "192.168.1.0/24")) + } + + func testIPv4Matches_cidrBoundaryLow() { + let addr = IPv4Address([192, 168, 1, 0])! + XCTAssertTrue(addr.matches(pattern: "192.168.1.0/24")) + } + + func testIPv4Matches_cidrMiss() { + let addr = IPv4Address([192, 168, 2, 5])! + XCTAssertFalse(addr.matches(pattern: "192.168.1.0/24")) + } + + func testIPv4Matches_cidrSlash32() { + let addr = IPv4Address([1, 2, 3, 4])! + XCTAssertTrue(addr.matches(pattern: "1.2.3.4/32")) + XCTAssertFalse(addr.matches(pattern: "1.2.3.5/32")) + } + + func testIPv4Matches_cidrSlash0MatchesAll() { + let addr = IPv4Address([9, 8, 7, 6])! + XCTAssertTrue(addr.matches(pattern: "0.0.0.0/0")) + } + + func testIPv4Matches_cidrShorthandTwoOctets() { + // "17.142/16" expands to "17.142.0.0/16" + XCTAssertTrue(IPv4Address([17, 142, 160, 1])!.matches(pattern: "17.142/16")) + XCTAssertTrue(IPv4Address([17, 142, 0, 1])!.matches(pattern: "17.142/16")) + XCTAssertFalse(IPv4Address([17, 143, 0, 1])!.matches(pattern: "17.142/16")) + } + + func testIPv4Matches_cidrShorthandOneOctet() { + // "10/8" expands to "10.0.0.0/8" + XCTAssertTrue(IPv4Address([10, 0, 0, 1])!.matches(pattern: "10/8")) + XCTAssertTrue(IPv4Address([10, 255, 255, 255])!.matches(pattern: "10/8")) + XCTAssertFalse(IPv4Address([11, 0, 0, 1])!.matches(pattern: "10/8")) + } + + // MARK: - IPv4Address.matches — string fallback + + func testIPv4Matches_stringFallbackExact() { + let addr = IPv4Address([1, 2, 3, 4])! + XCTAssertTrue(addr.matches(pattern: "1.2.3.4")) + XCTAssertFalse(addr.matches(pattern: "1.2.3.5")) + } + + func testIPv4Matches_wildcardFallback() { + let addr = IPv4Address([1, 2, 3, 4])! + XCTAssertTrue(addr.matches(pattern: "*")) + } + + // MARK: - IPv6Address.matches — CIDR + + func testIPv6Matches_cidrHit() { + // 2001:db8::1 is inside 2001:db8::/32 + let addr = IPv6Address([ + 0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + ])! + XCTAssertTrue(addr.matches(pattern: "2001:db8::/32")) + } + + func testIPv6Matches_cidrMiss() { + // 2001:db9::1 is outside 2001:db8::/32 + let addr = IPv6Address([ + 0x20, 0x01, 0x0d, 0xb9, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + ])! + XCTAssertFalse(addr.matches(pattern: "2001:db8::/32")) + } + + func testIPv6Matches_cidrSlash128() { + let addr = IPv6Address.loopback + XCTAssertTrue(addr.matches(pattern: "::1/128")) + XCTAssertFalse(addr.matches(pattern: "::2/128")) + } + + func testIPv6Matches_cidrSlash0MatchesAll() { + XCTAssertTrue(IPv6Address.loopback.matches(pattern: "::/0")) + XCTAssertTrue(IPv6Address.any.matches(pattern: "::/0")) + } + + // MARK: - IPv6Address.matches — string fallback + + func testIPv6Matches_stringFallbackExact() { + XCTAssertTrue(IPv6Address.loopback.matches(pattern: "::1")) + XCTAssertFalse(IPv6Address.loopback.matches(pattern: "::2")) + } + + func testIPv6Matches_wildcardFallback() { + XCTAssertTrue(IPv6Address.loopback.matches(pattern: "*")) + } + + // MARK: - IPv6Address.isSynthesizedNAT64 + + func testIsSynthesizedNAT64_emptyPrefixes() { + XCTAssertFalse(IPv6Address.loopback.isSynthesizedNAT64(prefixes: [])) + } + + func testIsSynthesizedNAT64_nonMatchingPrefix() { + let prefix = NAT64Prefix.wellKnownPrefix + XCTAssertFalse(IPv6Address.loopback.isSynthesizedNAT64(prefixes: [prefix])) + } + + func testIsSynthesizedNAT64_wellKnownPrefix() { + let prefix = NAT64Prefix.wellKnownPrefix + let ipv4 = IPv4Address([8, 8, 8, 8])! + let synth = IPv6Address.synthesized(from: ipv4, prefix: prefix)! + XCTAssertTrue(synth.isSynthesizedNAT64(prefixes: [prefix])) + } + + func testIsSynthesizedNAT64_customPrefix96() { + let prefix = NAT64Prefix( + length: .prefixLength96, + address: IPv6Address([0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])! + ) + let ipv4 = IPv4Address([1, 1, 1, 1])! + let synth = IPv6Address.synthesized(from: ipv4, prefix: prefix)! + XCTAssertTrue(synth.isSynthesizedNAT64(prefixes: [prefix])) + XCTAssertFalse(IPv6Address.loopback.isSynthesizedNAT64(prefixes: [prefix])) + } + + func testIsSynthesizedNAT64_customPrefix32() { + let prefix = NAT64Prefix( + length: .prefixLength32, + address: IPv6Address([0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])! + ) + let ipv4 = IPv4Address([1, 2, 3, 4])! + let synth = IPv6Address.synthesized(from: ipv4, prefix: prefix)! + XCTAssertTrue(synth.isSynthesizedNAT64(prefixes: [prefix])) + } + + func testIsSynthesizedNAT64_allPrefixLengths() { + let cases: [(NAT64PrefixLength, [UInt8])] = [ + (.prefixLength40, [0x20, 0x01, 0x0d, 0xb8, 0x01, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + (.prefixLength48, [0x20, 0x01, 0x0d, 0xb8, 0x12, 0x34, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + (.prefixLength56, [0x20, 0x01, 0x0d, 0xb8, 0x12, 0x34, 0x56, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + (.prefixLength64, [0x20, 0x01, 0x0d, 0xb8, 0x12, 0x34, 0x56, 0x78, 0, 0, 0, 0, 0, 0, 0, 0]), + ] + let ipv4 = IPv4Address([8, 8, 8, 8])! + for (length, addrBytes) in cases { + let prefix = NAT64Prefix(length: length, address: IPv6Address(addrBytes)!) + let synth = IPv6Address.synthesized(from: ipv4, prefix: prefix)! + XCTAssertTrue(synth.isSynthesizedNAT64(prefixes: [prefix]), "failed for \(length)") + XCTAssertFalse(IPv6Address.loopback.isSynthesizedNAT64(prefixes: [prefix]), "false positive for \(length)") + } + } + + func testIsSynthesizedNAT64_matchesFirstOfMultiplePrefixes() { + let wellKnown = NAT64Prefix.wellKnownPrefix + let custom = NAT64Prefix( + length: .prefixLength96, + address: IPv6Address([0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])! + ) + let ipv4 = IPv4Address([8, 8, 8, 8])! + let synth = IPv6Address.synthesized(from: ipv4, prefix: wellKnown)! + XCTAssertTrue(synth.isSynthesizedNAT64(prefixes: [custom, wellKnown])) + } + + // MARK: - Endpoint.matchesPattern + + func testEndpointMatchesPattern_wildcardMatchesAll() { + let hostEP = Endpoint(hostname: "example.com", port: 80) + let addrEP = Endpoint(address: IPv4Address([1, 2, 3, 4])!, port: 443) + XCTAssertTrue(hostEP.matchesPattern("*")) + XCTAssertTrue(addrEP.matchesPattern("*")) + } + + func testEndpointMatchesPattern_hostEndpointDomain() { + let ep = Endpoint(hostname: "www.example.com", port: 80) + XCTAssertTrue(ep.matchesPattern("example.com")) + XCTAssertTrue(ep.matchesPattern("*.example.com")) + XCTAssertFalse(ep.matchesPattern("test.com")) + } + + func testEndpointMatchesPattern_ipv4AddressCIDR() { + let ep = Endpoint(address: IPv4Address([192, 168, 1, 10])!, port: 443) + XCTAssertTrue(ep.matchesPattern("192.168.1.0/24")) + XCTAssertFalse(ep.matchesPattern("10.0.0.0/8")) + } + + func testEndpointMatchesPattern_ipv4AddressString() { + let ep = Endpoint(address: IPv4Address([1, 2, 3, 4])!, port: 80) + XCTAssertTrue(ep.matchesPattern("1.2.3.4")) + XCTAssertFalse(ep.matchesPattern("1.2.3.5")) + } + + func testEndpointMatchesPattern_ipv6AddressCIDR() { + let addr = IPv6Address([ + 0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + ])! + let ep = Endpoint(address: addr, port: 443) + XCTAssertTrue(ep.matchesPattern("2001:db8::/32")) + XCTAssertFalse(ep.matchesPattern("2001:db9::/32")) + } +}