Skip to content

Commit 5321362

Browse files
committed
Add CIDR and domain pattern matching as utilities
1 parent 3a91bdb commit 5321362

4 files changed

Lines changed: 530 additions & 0 deletions

File tree

Sources/SwiftNetwork/Endpoint/IPv4Address.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,18 @@ public struct IPv4Address: IPAddress, Hashable, CustomDebugStringConvertible {
130130
self = IPv4Address(address)
131131
}
132132

133+
/// An IPv4 address parsed from a dotted-decimal string (e.g. `"192.168.1.1"`).
134+
public init?(_ string: String) {
135+
let octets = string.split(separator: ".", maxSplits: 3, omittingEmptySubsequences: false)
136+
guard octets.count == 4,
137+
let a = UInt8(octets[0]),
138+
let b = UInt8(octets[1]),
139+
let c = UInt8(octets[2]),
140+
let d = UInt8(octets[3])
141+
else { return nil }
142+
self.init((UInt32(a) << 24 | UInt32(b) << 16 | UInt32(c) << 8 | UInt32(d)).bigEndian)
143+
}
144+
133145
static func ipv4AddressString(from address: UInt32) -> String {
134146
withUnsafeBytes(of: address) {
135147
"\($0[0]).\($0[1]).\($0[2]).\($0[3])"

Sources/SwiftNetwork/Endpoint/IPv6Address.swift

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,58 @@ public struct IPv6Address: IPAddress, Hashable, CustomDebugStringConvertible {
129129
self = IPv6Address(address)
130130
}
131131

132+
/// An IPv6 address parsed from a string (e.g. `"2001:db8::1"` or `"::1"`).
133+
public init?(_ string: String) {
134+
guard let groups = IPv6Address.parseToGroups(string) else { return nil }
135+
self.init(
136+
(
137+
(UInt32(groups[0]) << 16 | UInt32(groups[1])).bigEndian,
138+
(UInt32(groups[2]) << 16 | UInt32(groups[3])).bigEndian,
139+
(UInt32(groups[4]) << 16 | UInt32(groups[5])).bigEndian,
140+
(UInt32(groups[6]) << 16 | UInt32(groups[7])).bigEndian
141+
)
142+
)
143+
}
144+
145+
// Parses an IPv6 address string into 8 network-order UInt16 groups.
146+
// Handles :: compression and full notation.
147+
private static func parseToGroups(_ addr: String) -> [UInt16]? {
148+
var searchIndex = addr.startIndex
149+
150+
// Position of '::' in the string, if present. Used to expand compressed zeros.
151+
var doubleColonRange: Range<String.Index>? = nil
152+
while searchIndex < addr.endIndex {
153+
let nextIndex = addr.index(after: searchIndex)
154+
if nextIndex < addr.endIndex && addr[searchIndex] == ":" && addr[nextIndex] == ":" {
155+
doubleColonRange = searchIndex..<addr.index(after: nextIndex)
156+
break
157+
}
158+
searchIndex = nextIndex
159+
}
160+
161+
func parseHalf(_ half: String) -> [UInt16]? {
162+
if half.isEmpty { return [] }
163+
var result: [UInt16] = []
164+
for part in half.split(separator: ":", omittingEmptySubsequences: false) {
165+
guard !part.isEmpty, part.count <= 4, let value = UInt16(part, radix: 16) else { return nil }
166+
result.append(value)
167+
}
168+
return result
169+
}
170+
171+
if let range = doubleColonRange {
172+
guard let left = parseHalf(String(addr[addr.startIndex..<range.lowerBound])),
173+
let right = parseHalf(String(addr[range.upperBound..<addr.endIndex]))
174+
else { return nil }
175+
let zeroCount = 8 - left.count - right.count
176+
guard zeroCount >= 0 else { return nil }
177+
return left + [UInt16](repeating: 0, count: zeroCount) + right
178+
} else {
179+
guard let groups = parseHalf(addr), groups.count == 8 else { return nil }
180+
return groups
181+
}
182+
}
183+
132184
static func isIPv4Mapped(from address: (UInt32, UInt32, UInt32, UInt32)) -> Bool {
133185
address.0 == 0 && address.1 == 0 && address.2 == UInt32(0x0000_ffff).bigEndian
134186
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift open source project
4+
//
5+
// Copyright (c) 2026 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
// MARK: - CIDR parsing
16+
17+
// Parses an IPv4 CIDR string into a masked network address and subnet mask in network byte order.
18+
// Supports shorthand notation (e.g. "17.142/16" expands to "17.142.0.0/16").
19+
private func parseCIDRv4(_ cidr: String) -> (network: UInt32, mask: UInt32)? {
20+
let parts = cidr.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false)
21+
guard parts.count == 2,
22+
let prefixLen = Int(parts[1]), prefixLen >= 0, prefixLen <= 32
23+
else { return nil }
24+
25+
// Expand shorthand notation: "17.142" -> "17.142.0.0", "10" -> "10.0.0.0"
26+
var addrString = String(parts[0])
27+
let dotCount = addrString.count(where: { $0 == "." })
28+
if dotCount < 3 {
29+
addrString += String(repeating: ".0", count: 3 - dotCount)
30+
}
31+
32+
guard let addr = IPv4Address(addrString) else { return nil }
33+
let hostMask: UInt32 = prefixLen == 0 ? 0 : UInt32.max << UInt32(32 - prefixLen) // shift by 32 is undefined
34+
let mask = hostMask.bigEndian
35+
return (network: addr.address & mask, mask: mask)
36+
}
37+
38+
// Parses an IPv6 CIDR string into a masked network address and subnet mask,
39+
// each represented as four network-byte-order UInt32 chunks.
40+
@available(Network 0.1.0, *)
41+
private func parseCIDRv6(
42+
_ cidr: String
43+
) -> (network: (UInt32, UInt32, UInt32, UInt32), mask: (UInt32, UInt32, UInt32, UInt32))? {
44+
let parts = cidr.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false)
45+
guard parts.count == 2,
46+
let prefixLen = Int(parts[1]), prefixLen >= 0, prefixLen <= 128
47+
else { return nil }
48+
49+
guard let addr = IPv6Address(String(parts[0])) else { return nil }
50+
let rawNet = addr.address
51+
52+
// bitsInChunk in 1..32 is safe: shift amount (32 - bitsInChunk) is in 0..31
53+
func chunkMask(_ bitsInChunk: Int) -> UInt32 {
54+
guard bitsInChunk > 0 else { return 0 }
55+
return (UInt32.max << UInt32(32 - bitsInChunk)).bigEndian
56+
}
57+
58+
let mask = (
59+
chunkMask(min(prefixLen, 32)),
60+
chunkMask(min(max(prefixLen - 32, 0), 32)),
61+
chunkMask(min(max(prefixLen - 64, 0), 32)),
62+
chunkMask(min(max(prefixLen - 96, 0), 32))
63+
)
64+
65+
let network = (
66+
rawNet.0 & mask.0,
67+
rawNet.1 & mask.1,
68+
rawNet.2 & mask.2,
69+
rawNet.3 & mask.3
70+
)
71+
72+
return (network: network, mask: mask)
73+
}
74+
75+
// MARK: - Domain pattern matching
76+
77+
/// Returns true if `string` matches `pattern` using right-to-left dot-segment comparison.
78+
/// Supports exact matches, suffix matches ("apple.com" matches "www.apple.com"), and wildcards
79+
/// (`*.apple.com`). Both inputs are case-insensitive; trailing dots are stripped before matching.
80+
func matchesDomainPattern(_ string: String, pattern: String) -> Bool {
81+
let host = (string.hasSuffix(".") ? String(string.dropLast()) : string).lowercased()
82+
let pat = (pattern.hasSuffix(".") ? String(pattern.dropLast()) : pattern).lowercased()
83+
if host == pat { return true }
84+
let hostNodes = host.split(separator: ".", omittingEmptySubsequences: false)
85+
var patNodes = pat.split(separator: ".", omittingEmptySubsequences: false)
86+
// A leading empty segment (from a pattern starting with ".", e.g. ".apple.com")
87+
// is treated as a wildcard, matching like "*.apple.com".
88+
if patNodes.first?.isEmpty == true { patNodes[0] = "*" }
89+
var j = hostNodes.count - 1
90+
var k = patNodes.count - 1
91+
while j >= 0 && k >= 0 {
92+
let pn = patNodes[k]
93+
let hn = hostNodes[j]
94+
if pn == hn {
95+
if k == 0 { return true } // a fully-consumed pattern is a suffix match
96+
j -= 1
97+
k -= 1
98+
} else if pn == "*" {
99+
while k >= 0 {
100+
let nx = patNodes[k]
101+
if nx != "*" { break }
102+
k -= 1
103+
}
104+
if k < 0 { return true }
105+
let target = patNodes[k]
106+
while j >= 0 {
107+
if hostNodes[j] == target { break }
108+
j -= 1
109+
}
110+
} else {
111+
return false
112+
}
113+
}
114+
return false
115+
}
116+
117+
extension IPv4Address {
118+
/// Returns true if this address falls within the CIDR block in `pattern`, or if its string
119+
/// representation matches `pattern` as a domain pattern.
120+
func matches(pattern: String) -> Bool {
121+
if let cidr = parseCIDRv4(pattern) {
122+
return (addressValue & cidr.mask) == cidr.network
123+
}
124+
return matchesDomainPattern(debugDescription, pattern: pattern)
125+
}
126+
}
127+
128+
@available(Network 0.1.0, *)
129+
extension IPv6Address {
130+
/// Returns true if the leading bytes of this address match any prefix in `prefixes`.
131+
func isSynthesizedNAT64(prefixes: [NAT64Prefix]) -> Bool {
132+
withUnsafeBytes(of: self.address) { selfBuf in
133+
prefixes.contains { (prefix: NAT64Prefix) in
134+
let len = Int(prefix.length.rawValue)
135+
return withUnsafeBytes(of: prefix.address.address) { prefixBuf in
136+
selfBuf.prefix(len).elementsEqual(prefixBuf.prefix(len))
137+
}
138+
}
139+
}
140+
}
141+
142+
/// Returns true if this address falls within the CIDR block in `pattern`, or if its string
143+
/// representation matches `pattern` as a domain pattern.
144+
func matches(pattern: String) -> Bool {
145+
if let cidr = parseCIDRv6(pattern) {
146+
let (a0, a1, a2, a3) = addressValue
147+
let (n0, n1, n2, n3) = cidr.network
148+
let (m0, m1, m2, m3) = cidr.mask
149+
return (a0 & m0) == n0 && (a1 & m1) == n1 && (a2 & m2) == n2 && (a3 & m3) == n3
150+
}
151+
return matchesDomainPattern(debugDescription, pattern: pattern)
152+
}
153+
}
154+
155+
@available(Network 0.1.0, *)
156+
extension Endpoint {
157+
/// Returns true if this endpoint matches `pattern`. `"*"` matches all endpoints. Host endpoints
158+
/// are matched by hostname; address endpoints are matched by IP address or CIDR block.
159+
func matchesPattern(_ pattern: String) -> Bool {
160+
if pattern == "*" { return true }
161+
switch type {
162+
case .host(let hostEndpoint):
163+
return matchesDomainPattern(hostEndpoint.name, pattern: pattern)
164+
case .address(let addressEndpoint):
165+
switch addressEndpoint.type {
166+
case .v4(let ipv4, _): return ipv4.matches(pattern: pattern)
167+
case .v6(let ipv6, _): return ipv6.matches(pattern: pattern)
168+
default: return false
169+
}
170+
default:
171+
return false
172+
}
173+
}
174+
}

0 commit comments

Comments
 (0)