diff --git a/Sources/Containerization/ContainerManager.swift b/Sources/Containerization/ContainerManager.swift index 72de285d..8ac38413 100644 --- a/Sources/Containerization/ContainerManager.swift +++ b/Sources/Containerization/ContainerManager.swift @@ -49,19 +49,19 @@ public struct ContainerManager: Sendable { nonisolated(unsafe) private let reference: vmnet_network_ref /// The IPv4 subnet of this network. - public let subnet: CIDRAddress + public let subnet: CIDRv4 - /// The gateway address of this network. - public var gateway: IPv4Address { + /// The IPv4 gateway address of this network. + public var ipv4Gateway: IPv4Address { subnet.gateway } struct Allocator: Sendable { private let addressAllocator: any AddressAllocator - private let cidr: CIDRAddress + private let cidr: CIDRv4 private var allocations: [String: UInt32] - init(cidr: CIDRAddress) throws { + init(cidr: CIDRv4) throws { self.cidr = cidr self.allocations = .init() let size = Int(cidr.upper.value - cidr.lower.value - 3) @@ -71,14 +71,14 @@ public struct ContainerManager: Sendable { ) } - mutating func allocate(_ id: String) throws -> String { + mutating func allocate(_ id: String) throws -> CIDRv4 { if allocations[id] != nil { throw ContainerizationError(.exists, message: "allocation with id \(id) already exists") } let index = try addressAllocator.allocate() allocations[id] = index - let ip = IPv4Address(fromValue: index) - return try CIDRAddress(ip, prefixLength: cidr.prefixLength).description + let ip = IPv4Address(index) + return try CIDRv4(ip, prefix: cidr.prefix) } mutating func release(_ id: String) throws { @@ -91,8 +91,8 @@ public struct ContainerManager: Sendable { /// A network interface supporting the vmnet_network_ref. public struct Interface: Containerization.Interface, VZInterface, Sendable { - public let address: String - public let gateway: String? + public let ipv4Address: CIDRv4 + public let ipv4Gateway: IPv4Address? public let macAddress: String? // `reference` isn't used concurrently. @@ -100,12 +100,12 @@ public struct ContainerManager: Sendable { public init( reference: vmnet_network_ref, - address: String, - gateway: String, + ipv4Address: CIDRv4, + ipv4Gateway: IPv4Address, macAddress: String? = nil ) { - self.address = address - self.gateway = gateway + self.ipv4Address = ipv4Address + self.ipv4Gateway = ipv4Gateway self.macAddress = macAddress self.reference = reference } @@ -126,7 +126,7 @@ public struct ContainerManager: Sendable { /// Creates a new network. /// - Parameter subnet: The subnet to use for this network. - public init(subnet: String? = nil) throws { + public init(subnet: CIDRv4? = nil) throws { var status: vmnet_return_t = .VMNET_FAILURE guard let config = vmnet_network_configuration_create(.VMNET_SHARED_MODE, &status) else { throw ContainerizationError(.unsupported, message: "failed to create vmnet config with status \(status)") @@ -135,7 +135,7 @@ public struct ContainerManager: Sendable { vmnet_network_configuration_disable_dhcp(config) if let subnet { - try Self.configureSubnet(config, subnet: try CIDRAddress(subnet)) + try Self.configureSubnet(config, subnet: subnet) } guard let ref = vmnet_network_create(config, &status), status == .VMNET_SUCCESS else { @@ -152,11 +152,11 @@ public struct ContainerManager: Sendable { /// Returns a new interface for use with a container. /// - Parameter id: The container ID. public mutating func create(_ id: String) throws -> Containerization.Interface? { - let address = try allocator.allocate(id) + let ipv4Address = try allocator.allocate(id) return Self.Interface( reference: self.reference, - address: address, - gateway: self.gateway.description, + ipv4Address: ipv4Address, + ipv4Gateway: self.ipv4Gateway, ) } @@ -166,7 +166,7 @@ public struct ContainerManager: Sendable { try allocator.release(id) } - private static func getSubnet(_ ref: vmnet_network_ref) throws -> CIDRAddress { + private static func getSubnet(_ ref: vmnet_network_ref) throws -> CIDRv4 { var subnet = in_addr() var mask = in_addr() vmnet_network_get_ipv4_subnet(ref, &subnet, &mask) @@ -174,19 +174,19 @@ public struct ContainerManager: Sendable { let sa = UInt32(bigEndian: subnet.s_addr) let mv = UInt32(bigEndian: mask.s_addr) - let lower = IPv4Address(fromValue: sa & mv) - let upper = IPv4Address(fromValue: lower.value + ~mv) + let lower = IPv4Address(sa & mv) + let upper = IPv4Address(lower.value + ~mv) - return try CIDRAddress(lower: lower, upper: upper) + return try CIDRv4(lower: lower, upper: upper) } - private static func configureSubnet(_ config: vmnet_network_configuration_ref, subnet: CIDRAddress) throws { + private static func configureSubnet(_ config: vmnet_network_configuration_ref, subnet: CIDRv4) throws { let gateway = subnet.gateway var ga = in_addr() inet_pton(AF_INET, gateway.description, &ga) - let mask = IPv4Address(fromValue: subnet.prefixLength.prefixMask32) + let mask = IPv4Address(subnet.prefix.prefixMask32) var ma = in_addr() inet_pton(AF_INET, mask.description, &ma) @@ -415,7 +415,8 @@ public struct ContainerManager: Sendable { } if let interface = try self.network?.create(id) { config.interfaces = [interface] - config.dns = .init(nameservers: [interface.gateway!]) + // FIXME: throw instead of crash here if we can't unwrap? + config.dns = .init(nameservers: [interface.ipv4Gateway!.description]) } config.bootLog = BootLog.file(path: self.containerRoot.appendingPathComponent(id).appendingPathComponent("bootlog.log")) try configuration(&config) @@ -454,10 +455,10 @@ public struct ContainerManager: Sendable { } } -extension CIDRAddress { +extension CIDRv4 { /// The gateway address of the network. public var gateway: IPv4Address { - IPv4Address(fromValue: self.lower.value + 1) + IPv4Address(self.lower.value + 1) } } diff --git a/Sources/Containerization/Interface.swift b/Sources/Containerization/Interface.swift index d1428e1d..6211728c 100644 --- a/Sources/Containerization/Interface.swift +++ b/Sources/Containerization/Interface.swift @@ -14,14 +14,16 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerizationExtras + /// A network interface. public protocol Interface: Sendable { /// The interface IPv4 address and subnet prefix length, as a CIDR address. /// Example: `192.168.64.3/24` - var address: String { get } + var ipv4Address: CIDRv4 { get } /// The IP address for the default route, or nil for no default route. - var gateway: String? { get } + var ipv4Gateway: IPv4Address? { get } /// The interface MAC address, or nil to auto-configure the address. var macAddress: String? { get } diff --git a/Sources/Containerization/LinuxContainer.swift b/Sources/Containerization/LinuxContainer.swift index aad39051..dc502e2d 100644 --- a/Sources/Containerization/LinuxContainer.swift +++ b/Sources/Containerization/LinuxContainer.swift @@ -434,10 +434,10 @@ extension LinuxContainer { // 3. If a gateway IP address is present, add the default route. for (index, i) in self.interfaces.enumerated() { let name = "eth\(index)" - try await agent.addressAdd(name: name, address: i.address) + try await agent.addressAdd(name: name, ipv4Address: i.ipv4Address) try await agent.up(name: name, mtu: 1280) - if let gateway = i.gateway { - try await agent.routeAddDefault(name: name, gateway: gateway) + if let ipv4Gateway = i.ipv4Gateway { + try await agent.routeAddDefault(name: name, ipv4Gateway: ipv4Gateway) } } diff --git a/Sources/Containerization/LinuxPod.swift b/Sources/Containerization/LinuxPod.swift index 1bbf1f2f..670f8f89 100644 --- a/Sources/Containerization/LinuxPod.swift +++ b/Sources/Containerization/LinuxPod.swift @@ -393,10 +393,10 @@ extension LinuxPod { // 3. If a gateway IP address is present, add the default route. for (index, i) in self.interfaces.enumerated() { let name = "eth\(index)" - try await agent.addressAdd(name: name, address: i.address) + try await agent.addressAdd(name: name, ipv4Address: i.ipv4Address) try await agent.up(name: name, mtu: 1280) - if let gateway = i.gateway { - try await agent.routeAddDefault(name: name, gateway: gateway) + if let ipv4Gateway = i.ipv4Gateway { + try await agent.routeAddDefault(name: name, ipv4Gateway: ipv4Gateway) } } diff --git a/Sources/Containerization/NATInterface.swift b/Sources/Containerization/NATInterface.swift index 895eaab7..01d4fd2f 100644 --- a/Sources/Containerization/NATInterface.swift +++ b/Sources/Containerization/NATInterface.swift @@ -14,14 +14,16 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerizationExtras + public struct NATInterface: Interface { - public var address: String - public var gateway: String? + public var ipv4Address: CIDRv4 + public var ipv4Gateway: IPv4Address? public var macAddress: String? - public init(address: String, gateway: String?, macAddress: String? = nil) { - self.address = address - self.gateway = gateway + public init(ipv4Address: CIDRv4, ipv4Gateway: IPv4Address?, macAddress: String? = nil) { + self.ipv4Address = ipv4Address + self.ipv4Gateway = ipv4Gateway self.macAddress = macAddress } } diff --git a/Sources/Containerization/NATNetworkInterface.swift b/Sources/Containerization/NATNetworkInterface.swift index 296eb8d4..16b98cd1 100644 --- a/Sources/Containerization/NATNetworkInterface.swift +++ b/Sources/Containerization/NATNetworkInterface.swift @@ -19,6 +19,7 @@ import vmnet import Virtualization import ContainerizationError +import ContainerizationExtras import Foundation import Synchronization @@ -26,8 +27,8 @@ import Synchronization /// container/virtual machine. @available(macOS 26, *) public final class NATNetworkInterface: Interface, Sendable { - public let address: String - public let gateway: String? + public let ipv4Address: CIDRv4 + public let ipv4Gateway: IPv4Address? public let macAddress: String? @available(macOS 26, *) @@ -36,25 +37,25 @@ public final class NATNetworkInterface: Interface, Sendable { @available(macOS 26, *) public init( - address: String, - gateway: String?, + ipv4Address: CIDRv4, + ipv4Gateway: IPv4Address?, reference: sending vmnet_network_ref, macAddress: String? = nil ) { - self.address = address - self.gateway = gateway + self.ipv4Address = ipv4Address + self.ipv4Gateway = ipv4Gateway self.macAddress = macAddress self.reference = reference } - @available(macOS, obsoleted: 26, message: "Use init(address:gateway:reference:macAddress:) instead") + @available(macOS, obsoleted: 26, message: "Use init(ipv4Address:ipv4Gateway:reference:macAddress:) instead") public init( - address: String, - gateway: String?, + ipv4Address: CIDRv4, + ipv4Gateway: IPv4Address?, macAddress: String? = nil ) { - self.address = address - self.gateway = gateway + self.ipv4Address = ipv4Address + self.ipv4Gateway = ipv4Gateway self.macAddress = macAddress self.reference = nil } diff --git a/Sources/Containerization/SandboxContext/SandboxContext.pb.swift b/Sources/Containerization/SandboxContext/SandboxContext.pb.swift index 9412156d..1af7b26d 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.pb.swift +++ b/Sources/Containerization/SandboxContext/SandboxContext.pb.swift @@ -798,7 +798,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_IpAddrAddRequest: Sendable { public var interface: String = String() - public var address: String = String() + public var ipv4Address: String = String() public var unknownFields = SwiftProtobuf.UnknownStorage() @@ -822,9 +822,9 @@ public struct Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkRequest: Senda public var interface: String = String() - public var address: String = String() + public var dstIpv4Addr: String = String() - public var srcAddr: String = String() + public var srcIpv4Addr: String = String() public var unknownFields = SwiftProtobuf.UnknownStorage() @@ -848,7 +848,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultRequest: Se public var interface: String = String() - public var gateway: String = String() + public var ipv4Gateway: String = String() public var unknownFields = SwiftProtobuf.UnknownStorage() @@ -2636,7 +2636,7 @@ extension Com_Apple_Containerization_Sandbox_V3_IpAddrAddRequest: SwiftProtobuf. public static let protoMessageName: String = _protobuf_package + ".IpAddrAddRequest" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .same(proto: "interface"), - 2: .same(proto: "address"), + 2: .same(proto: "ipv4Address"), ] public mutating func decodeMessage(decoder: inout D) throws { @@ -2646,7 +2646,7 @@ extension Com_Apple_Containerization_Sandbox_V3_IpAddrAddRequest: SwiftProtobuf. // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { case 1: try { try decoder.decodeSingularStringField(value: &self.interface) }() - case 2: try { try decoder.decodeSingularStringField(value: &self.address) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.ipv4Address) }() default: break } } @@ -2656,15 +2656,15 @@ extension Com_Apple_Containerization_Sandbox_V3_IpAddrAddRequest: SwiftProtobuf. if !self.interface.isEmpty { try visitor.visitSingularStringField(value: self.interface, fieldNumber: 1) } - if !self.address.isEmpty { - try visitor.visitSingularStringField(value: self.address, fieldNumber: 2) + if !self.ipv4Address.isEmpty { + try visitor.visitSingularStringField(value: self.ipv4Address, fieldNumber: 2) } try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_IpAddrAddRequest, rhs: Com_Apple_Containerization_Sandbox_V3_IpAddrAddRequest) -> Bool { if lhs.interface != rhs.interface {return false} - if lhs.address != rhs.address {return false} + if lhs.ipv4Address != rhs.ipv4Address {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -2693,8 +2693,8 @@ extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkRequest: SwiftProt public static let protoMessageName: String = _protobuf_package + ".IpRouteAddLinkRequest" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .same(proto: "interface"), - 2: .same(proto: "address"), - 3: .same(proto: "srcAddr"), + 2: .same(proto: "dstIpv4Addr"), + 3: .same(proto: "srcIpv4Addr"), ] public mutating func decodeMessage(decoder: inout D) throws { @@ -2704,8 +2704,8 @@ extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkRequest: SwiftProt // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { case 1: try { try decoder.decodeSingularStringField(value: &self.interface) }() - case 2: try { try decoder.decodeSingularStringField(value: &self.address) }() - case 3: try { try decoder.decodeSingularStringField(value: &self.srcAddr) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.dstIpv4Addr) }() + case 3: try { try decoder.decodeSingularStringField(value: &self.srcIpv4Addr) }() default: break } } @@ -2715,19 +2715,19 @@ extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkRequest: SwiftProt if !self.interface.isEmpty { try visitor.visitSingularStringField(value: self.interface, fieldNumber: 1) } - if !self.address.isEmpty { - try visitor.visitSingularStringField(value: self.address, fieldNumber: 2) + if !self.dstIpv4Addr.isEmpty { + try visitor.visitSingularStringField(value: self.dstIpv4Addr, fieldNumber: 2) } - if !self.srcAddr.isEmpty { - try visitor.visitSingularStringField(value: self.srcAddr, fieldNumber: 3) + if !self.srcIpv4Addr.isEmpty { + try visitor.visitSingularStringField(value: self.srcIpv4Addr, fieldNumber: 3) } try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkRequest, rhs: Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkRequest) -> Bool { if lhs.interface != rhs.interface {return false} - if lhs.address != rhs.address {return false} - if lhs.srcAddr != rhs.srcAddr {return false} + if lhs.dstIpv4Addr != rhs.dstIpv4Addr {return false} + if lhs.srcIpv4Addr != rhs.srcIpv4Addr {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -2756,7 +2756,7 @@ extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultRequest: SwiftP public static let protoMessageName: String = _protobuf_package + ".IpRouteAddDefaultRequest" public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .same(proto: "interface"), - 2: .same(proto: "gateway"), + 2: .same(proto: "ipv4Gateway"), ] public mutating func decodeMessage(decoder: inout D) throws { @@ -2766,7 +2766,7 @@ extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultRequest: SwiftP // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { case 1: try { try decoder.decodeSingularStringField(value: &self.interface) }() - case 2: try { try decoder.decodeSingularStringField(value: &self.gateway) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.ipv4Gateway) }() default: break } } @@ -2776,15 +2776,15 @@ extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultRequest: SwiftP if !self.interface.isEmpty { try visitor.visitSingularStringField(value: self.interface, fieldNumber: 1) } - if !self.gateway.isEmpty { - try visitor.visitSingularStringField(value: self.gateway, fieldNumber: 2) + if !self.ipv4Gateway.isEmpty { + try visitor.visitSingularStringField(value: self.ipv4Gateway, fieldNumber: 2) } try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultRequest, rhs: Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultRequest) -> Bool { if lhs.interface != rhs.interface {return false} - if lhs.gateway != rhs.gateway {return false} + if lhs.ipv4Gateway != rhs.ipv4Gateway {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/Sources/Containerization/SandboxContext/SandboxContext.proto b/Sources/Containerization/SandboxContext/SandboxContext.proto index 6405bc1a..a4f7dd21 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.proto +++ b/Sources/Containerization/SandboxContext/SandboxContext.proto @@ -235,22 +235,22 @@ message IpLinkSetResponse {} message IpAddrAddRequest { string interface = 1; - string address = 2; + string ipv4Address = 2; } message IpAddrAddResponse {} message IpRouteAddLinkRequest { string interface = 1; - string address = 2; - string srcAddr = 3; + string dstIpv4Addr = 2; + string srcIpv4Addr = 3; } message IpRouteAddLinkResponse {} message IpRouteAddDefaultRequest { string interface = 1; - string gateway = 2; + string ipv4Gateway = 2; } message IpRouteAddDefaultResponse {} diff --git a/Sources/Containerization/VirtualMachineAgent.swift b/Sources/Containerization/VirtualMachineAgent.swift index a15a344d..c3950de4 100644 --- a/Sources/Containerization/VirtualMachineAgent.swift +++ b/Sources/Containerization/VirtualMachineAgent.swift @@ -15,6 +15,7 @@ //===----------------------------------------------------------------------===// import ContainerizationError +import ContainerizationExtras import ContainerizationOCI import Foundation @@ -66,8 +67,8 @@ public protocol VirtualMachineAgent: Sendable { // Networking func up(name: String, mtu: UInt32?) async throws func down(name: String) async throws - func addressAdd(name: String, address: String) async throws - func routeAddDefault(name: String, gateway: String) async throws + func addressAdd(name: String, ipv4Address: CIDRv4) async throws + func routeAddDefault(name: String, ipv4Gateway: IPv4Address) async throws func configureDNS(config: DNS, location: String) async throws func configureHosts(config: Hosts, location: String) async throws diff --git a/Sources/Containerization/Vminitd.swift b/Sources/Containerization/Vminitd.swift index 81703401..1800978d 100644 --- a/Sources/Containerization/Vminitd.swift +++ b/Sources/Containerization/Vminitd.swift @@ -15,6 +15,7 @@ //===----------------------------------------------------------------------===// import ContainerizationError +import ContainerizationExtras import ContainerizationOCI import ContainerizationOS import Foundation @@ -359,20 +360,20 @@ extension Vminitd { } /// Add an IP address to the sandbox's network interfaces. - public func addressAdd(name: String, address: String) async throws { + public func addressAdd(name: String, ipv4Address: CIDRv4) async throws { _ = try await client.ipAddrAdd( .with { $0.interface = name - $0.address = address + $0.ipv4Address = ipv4Address.description }) } /// Set the default route in the sandbox's environment. - public func routeAddDefault(name: String, gateway: String) async throws { + public func routeAddDefault(name: String, ipv4Gateway: IPv4Address) async throws { _ = try await client.ipRouteAddDefault( .with { $0.interface = name - $0.gateway = gateway + $0.ipv4Gateway = ipv4Gateway.description }) } diff --git a/Sources/ContainerizationExtras/CIDR.swift b/Sources/ContainerizationExtras/CIDR.swift new file mode 100644 index 00000000..ed3f2446 --- /dev/null +++ b/Sources/ContainerizationExtras/CIDR.swift @@ -0,0 +1,135 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +/// Describes an IPv4 or IPv6 CIDR address block. +@frozen +public enum CIDR: CustomStringConvertible, Equatable, Sendable, Hashable { + + case v4(IPv4Address, Prefix) + case v6(IPv6Address, Prefix) + + /// Create a CIDR address block. + public init(_ cidr: String) throws { + if let cidrV4 = try? CIDRv4(cidr) { + self = .v4(cidrV4.address, cidrV4.prefix) + } else if let cidrV6 = try? CIDRv6(cidr) { + self = .v6(cidrV6.address, cidrV6.prefix) + } else { + throw Error.invalidCIDR(cidr: cidr) + } + } + + /// Create a CIDR address from a member IP and a prefix length. + public init(_ address: IPAddress, prefix: Prefix) throws { + switch address { + case .v4(let addr): + guard prefix.length <= 32 else { + throw Error.invalidCIDR(cidr: "\(address)/\(prefix)") + } + self = .v4(addr, prefix) + case .v6(let addr): + guard prefix.length <= 128 else { + throw Error.invalidCIDR(cidr: "\(address)/\(prefix)") + } + self = .v6(addr, prefix) + } + } + + /// Create the smallest CIDR block that includes the lower and upper bounds. + /// + /// For type-safe construction, prefer `v4Range(lower:upper:)` or `v6Range(lower:upper:)`. + public init(lower: IPAddress, upper: IPAddress) throws { + switch (lower, upper) { + case (.v4(let lowerAddr), .v4(let upperAddr)): + let cidr = try CIDRv4(lower: lowerAddr, upper: upperAddr) + self = .v4(cidr.address, cidr.prefix) + case (.v6(let lowerAddr), .v6(let upperAddr)): + let cidr = try CIDRv6(lower: lowerAddr, upper: upperAddr) + self = .v6(cidr.address, cidr.prefix) + default: + throw Error.invalidAddressRange(lower: lower.description, upper: upper.description) + } + } + + /// The IP component of this CIDR address. + @inlinable + public var address: IPAddress { + switch self { + case .v4(let addr, _): + return .v4(addr) + case .v6(let addr, _): + return .v6(addr) + } + } + + /// The prefix length of this CIDR address. + @inlinable + public var prefix: Prefix { + switch self { + case .v4(_, let prefix), .v6(_, let prefix): + return prefix + } + } + + /// The lowest address in this CIDR block + @inlinable + public var lower: IPAddress { + switch self { + case (.v4(let addr, let prefix)): + return .v4(IPv4Address(addr.value & prefix.prefixMask32)) + case (.v6(let addr, let prefix)): + return .v6(IPv6Address(addr.value & prefix.prefixMask128)) + } + } + + /// The highest address in this CIDR block (broadcast address). + @inlinable + public var upper: IPAddress { + switch self { + case .v4(let addr, let prefix): + return .v4(IPv4Address(addr.value | prefix.suffixMask32)) + case .v6(let addr, let prefix): + return .v6(IPv6Address(addr.value | prefix.suffixMask128, zone: addr.zone)) + } + } + + /// Return true if the CIDR block contains the specified address. + /// + /// Compares network portion of the given IP address. + @inlinable + public func contains(_ ip: IPAddress) -> Bool { + switch (self, ip) { + case (.v4(let network, let prefix), .v4(let ip)): + return network.value == (ip.value & prefix.prefixMask32) + case (.v6(let network, let prefix), .v6(let ip)): + return (network.zone == ip.zone) && (network.value == (ip.value & prefix.prefixMask128)) + default: + return false + } + } + + /// Retrieve the text representation of the CIDR block. + public var description: String { + "\(address)/\(prefix)" + } +} + +extension CIDR { + public enum Error: Swift.Error { + case invalidCIDR(cidr: String) + case invalidAddressRange(lower: String, upper: String) + } +} diff --git a/Sources/ContainerizationExtras/CIDRAddress.swift b/Sources/ContainerizationExtras/CIDRAddress.swift deleted file mode 100644 index c14de2d9..00000000 --- a/Sources/ContainerizationExtras/CIDRAddress.swift +++ /dev/null @@ -1,132 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the Containerization project authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// https://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -//===----------------------------------------------------------------------===// - -/// Describes an IPv4 CIDR address block. -public struct CIDRAddress: CustomStringConvertible, Equatable, Sendable { - - /// The base IPv4 address of the CIDR block. - public let lower: IPv4Address - - /// The last IPv4 address of the CIDR block - public let upper: IPv4Address - - /// The IPv4 address component of the CIDR block. - public let address: IPv4Address - - /// The address prefix length for the CIDR block, which determines its size. - public let prefixLength: PrefixLength - - /// Create an CIDR address block from its text representation. - public init(_ cidr: String) throws { - let split = cidr.components(separatedBy: "/") - guard split.count == 2 else { - throw NetworkAddressError.invalidCIDR(cidr: cidr) - } - address = try IPv4Address(split[0]) - guard let prefixLength = PrefixLength(split[1]) else { - throw NetworkAddressError.invalidCIDR(cidr: cidr) - } - guard prefixLength >= 0 && prefixLength <= 32 else { - throw NetworkAddressError.invalidCIDR(cidr: cidr) - } - - self.prefixLength = prefixLength - lower = address.prefix(prefixLength: prefixLength) - upper = IPv4Address(fromValue: lower.value + prefixLength.suffixMask32) - } - - /// Create a CIDR address from a member IP and a prefix length. - public init(_ address: IPv4Address, prefixLength: PrefixLength) throws { - guard prefixLength >= 0 && prefixLength <= 32 else { - throw NetworkAddressError.invalidCIDR(cidr: "\(address)/\(prefixLength)") - } - - self.prefixLength = prefixLength - self.address = address - lower = address.prefix(prefixLength: prefixLength) - upper = IPv4Address(fromValue: lower.value + prefixLength.suffixMask32) - } - - /// Create the smallest CIDR block that includes the lower and upper bounds. - public init(lower: IPv4Address, upper: IPv4Address) throws { - guard lower.value <= upper.value else { - throw NetworkAddressError.invalidAddressRange(lower: lower.description, upper: upper.description) - } - - address = lower - for prefixLength: PrefixLength in 1...32 { - // find the first case where a subnet mask would put lower and upper in different CIDR block - let mask = prefixLength.prefixMask32 - - if (lower.value & mask) != (upper.value & mask) { - self.prefixLength = prefixLength - 1 - self.lower = address.prefix(prefixLength: self.prefixLength) - self.upper = IPv4Address(fromValue: self.lower.value + self.prefixLength.suffixMask32) - return - } - } - - // if lower and upper are same, create a /32 block - self.prefixLength = 32 - self.lower = lower - self.upper = upper - } - - /// Get the offset of the specified address, relative to the - /// base address for the CIDR block, returning nil if the block - /// does not contain the address. - public func getIndex(_ address: IPv4Address) -> UInt32? { - guard address.value >= lower.value && address.value <= upper.value else { - return nil - } - - return address.value - lower.value - } - - /// Return true if the CIDR block contains the specified address. - public func contains(ipv4: IPv4Address) -> Bool { - lower.value <= ipv4.value && ipv4.value <= upper.value - } - - /// Return true if the CIDR block contains all addresses of another CIDR block. - public func contains(cidr: CIDRAddress) -> Bool { - lower.value <= cidr.lower.value && cidr.upper.value <= upper.value - } - - /// Return true if the CIDR block shares any addresses with another CIDR block. - public func overlaps(cidr: CIDRAddress) -> Bool { - (lower.value <= cidr.lower.value && upper.value >= cidr.lower.value) - || (upper.value >= cidr.upper.value && lower.value <= cidr.upper.value) - } - - /// Retrieve the text representation of the CIDR block. - public var description: String { - "\(address)/\(prefixLength)" - } -} - -extension CIDRAddress: Codable { - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - let text = try container.decode(String.self) - try self.init(text) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(self.description) - } -} diff --git a/Sources/ContainerizationExtras/CIDRv4.swift b/Sources/ContainerizationExtras/CIDRv4.swift new file mode 100644 index 00000000..fb7ead78 --- /dev/null +++ b/Sources/ContainerizationExtras/CIDRv4.swift @@ -0,0 +1,101 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +/// Describes an IPv4 CIDR address block. +@frozen +public struct CIDRv4: CustomStringConvertible, Equatable, Sendable, Hashable { + /// The IP component of this CIDR address. + public let address: IPv4Address + + /// The prefix length of this CIDR address. + public let prefix: Prefix + + /// Create a CIDR address block. + public init(_ cidr: String) throws { + let split = cidr.split(separator: "/") + guard split.count == 2 else { + throw CIDR.Error.invalidCIDR(cidr: cidr) + } + guard let prefixLength = UInt8(split[1]), let prefix = Prefix(length: prefixLength) else { + throw CIDR.Error.invalidCIDR(cidr: cidr) + } + + let address = try IPv4Address(String(split[0])) + try self.init(address, prefix: prefix) + } + + /// Create a CIDR address from a member IP and a prefix length. + public init(_ address: IPv4Address, prefix: Prefix) throws { + guard prefix.length <= 32 else { + throw CIDR.Error.invalidCIDR(cidr: "\(address)/\(prefix)") + } + self.address = address + self.prefix = prefix + } + + /// Create the smallest IPv4 CIDR block that includes the lower and upper bounds. + /// + /// - Parameters: + /// - lower: The lower bound IPv4 address + /// - upper: The upper bound IPv4 address + /// - Returns: The smallest CIDR block containing both addresses + /// - Throws: If lower > upper + public init(lower: IPv4Address, upper: IPv4Address) throws { + guard lower.value <= upper.value else { + throw CIDR.Error.invalidAddressRange(lower: lower.description, upper: upper.description) + } + + for length in 1...32 { + let prefixLength = Prefix(unchecked: UInt8(length)) + let mask = prefixLength.prefixMask32 + if (lower.value & mask) != (upper.value & mask) { + let prefix = Prefix(unchecked: UInt8(length - 1)) + let networkAddr = IPv4Address(lower.value & prefix.prefixMask32) + try self.init(networkAddr, prefix: prefix) + return + } + } + // Same address - /32 block + let prefix = Prefix(unchecked: 32) + let networkAddr = IPv4Address(lower.value & prefix.prefixMask32) + try self.init(networkAddr, prefix: prefix) + } + + /// The lowest address in this CIDR block + @inlinable + public var lower: IPv4Address { + IPv4Address(address.value & prefix.prefixMask32) + } + + /// The highest address in this CIDR block (broadcast address). + @inlinable + public var upper: IPv4Address { + IPv4Address(address.value | prefix.suffixMask32) + } + + /// Return true if the CIDR block contains the specified address. + /// + /// Compares network portion of the given IP address. + @inlinable + public func contains(_ ip: IPv4Address) -> Bool { + (address.value & prefix.prefixMask32) == (ip.value & prefix.prefixMask32) + } + + /// Retrieve the text representation of the CIDR block. + public var description: String { + "\(address)/\(prefix)" + } +} diff --git a/Sources/ContainerizationExtras/CIDRv6.swift b/Sources/ContainerizationExtras/CIDRv6.swift new file mode 100644 index 00000000..42be276b --- /dev/null +++ b/Sources/ContainerizationExtras/CIDRv6.swift @@ -0,0 +1,102 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +/// Describes an IPv4 or IPv6 CIDR address block. +@frozen +public struct CIDRv6: CustomStringConvertible, Equatable, Sendable, Hashable { + + /// The IP component of this CIDR address. + public let address: IPv6Address + + /// The prefix length of this CIDR address. + public let prefix: Prefix + + /// Create a CIDR address block. + public init(_ cidr: String) throws { + let split = cidr.split(separator: "/") + guard split.count == 2 else { + throw CIDR.Error.invalidCIDR(cidr: cidr) + } + guard let prefixLength = UInt8(split[1]), let prefix = Prefix(length: prefixLength) else { + throw CIDR.Error.invalidCIDR(cidr: cidr) + } + + let address = try IPv6Address(String(split[0])) + try self.init(address, prefix: prefix) + } + + /// Create a CIDR address from a member IP and a prefix length. + public init(_ address: IPv6Address, prefix: Prefix) throws { + guard prefix.length <= 128 else { + throw CIDR.Error.invalidCIDR(cidr: "\(address)/\(prefix)") + } + self.address = address + self.prefix = prefix + } + + /// Create the smallest IPv6 CIDR block that includes the lower and upper bounds. + /// + /// - Parameters: + /// - lower: The lower bound IPv6 address + /// - upper: The upper bound IPv6 address + /// - Returns: The smallest CIDR block containing both addresses + /// - Throws: If lower > upper or zones don't match + public init(lower: IPv6Address, upper: IPv6Address) throws { + guard lower.value <= upper.value && lower.zone == upper.zone else { + throw CIDR.Error.invalidAddressRange(lower: lower.description, upper: upper.description) + } + + for length in 1...128 { + let prefixLength = Prefix(unchecked: UInt8(length)) + let mask = prefixLength.prefixMask128 + if (lower.value & mask) != (upper.value & mask) { + let prefix = Prefix(unchecked: UInt8(length - 1)) + let networkAddr = IPv6Address(lower.value & prefix.prefixMask128, zone: lower.zone) + try self.init(networkAddr, prefix: prefix) + return + } + } + // Same address - /128 block + let prefix = Prefix(unchecked: 128) + let networkAddr = IPv6Address(lower.value & prefix.prefixMask128, zone: lower.zone) + try self.init(networkAddr, prefix: prefix) + } + + /// The lowest address in this CIDR block + @inlinable + public var lower: IPv6Address { + IPv6Address(address.value & prefix.prefixMask128) + } + + /// The highest address in this CIDR block (broadcast address). + @inlinable + public var upper: IPv6Address { + IPv6Address(address.value | prefix.suffixMask128, zone: address.zone) + } + + /// Return true if the CIDR block contains the specified address. + /// + /// Compares network portion of the given IP address. + @inlinable + public func contains(_ ip: IPv6Address) -> Bool { + (address.zone == ip.zone) && ((address.value & prefix.prefixMask128) == (ip.value & prefix.prefixMask128)) + } + + /// Retrieve the text representation of the CIDR block. + public var description: String { + "\(address)/\(prefix)" + } +} diff --git a/Sources/ContainerizationExtras/IPAddress.swift b/Sources/ContainerizationExtras/IPAddress.swift index 91850e1a..774ee87a 100644 --- a/Sources/ContainerizationExtras/IPAddress.swift +++ b/Sources/ContainerizationExtras/IPAddress.swift @@ -14,79 +14,122 @@ // limitations under the License. //===----------------------------------------------------------------------===// -/// Facilitates conversion between IPv4 address representations. -public struct IPv4Address: Codable, CustomStringConvertible, Equatable, Sendable { - /// The address as a 32-bit integer. - public let value: UInt32 +/// Represents an IP address that can be either IPv4 or IPv6. +@frozen +public enum IPAddress: Sendable, Hashable, CustomStringConvertible, Equatable { + /// An IPv4 address + case v4(IPv4Address) - /// Create an address from a dotted-decimal string, such as "192.168.64.10". - public init(_ fromString: String) throws { - let split = fromString.components(separatedBy: ".") - if split.count != 4 { - throw NetworkAddressError.invalidStringAddress(address: fromString) - } + /// An IPv6 address + case v6(IPv6Address) - var parsedValue: UInt32 = 0 - for index in 0..<4 { - guard let octet = UInt8(split[index]) else { - throw NetworkAddressError.invalidStringAddress(address: fromString) + /// Parses an IP address string, automatically detecting IPv4 or IPv6 format. + /// + /// - Parameter string: IP address string to parse + /// - Returns: An `IPAddress` containing either an IPv4 or IPv6 address + /// - Throws: `IPAddressError.unableToParse` if invalid + public init(_ string: String) throws { + let utf8 = string.utf8 + var hasColon = false + var hasDot = false + + for byte in utf8 { + if byte == 58 { // ASCII ':' + hasColon = true + break + } + if byte == 46 { // ASCII '.' + hasDot = true } - parsedValue |= UInt32(octet) << ((3 - index) * 8) } - value = parsedValue - } - - /// Create an address from an array of four bytes in network order (big-endian), - /// such as [192, 168, 64, 10]. - public init(fromNetworkBytes: [UInt8]) throws { - guard fromNetworkBytes.count == 4 else { - throw NetworkAddressError.invalidNetworkByteAddress(address: fromNetworkBytes) + if hasColon { + let ipv6 = try IPv6Address.parse(string) + self = .v6(ipv6) + } else if hasDot { + let ipv4 = try IPv4Address(string) + self = .v4(ipv4) + } else { + throw IPAddressError.unableToParse } - - value = - (UInt32(fromNetworkBytes[0]) << 24) - | (UInt32(fromNetworkBytes[1]) << 16) - | (UInt32(fromNetworkBytes[2]) << 8) - | UInt32(fromNetworkBytes[3]) } - /// Create an address from a 32-bit integer, such as 0xc0a8_400a. - public init(fromValue: UInt32) { - value = fromValue - } - - /// Retrieve the address as an array of bytes in network byte order. - public var networkBytes: [UInt8] { - [ - UInt8((value >> 24) & 0xff), - UInt8((value >> 16) & 0xff), - UInt8((value >> 8) & 0xff), - UInt8(value & 0xff), - ] - } - - /// Retrieve the address as a dotted decimal string. + /// String representation of the IP address. public var description: String { - networkBytes.map(String.init).joined(separator: ".") + switch self { + case .v4(let addr): + return addr.description + case .v6(let addr): + return addr.description + } } - /// Create the base IPv4 address for a network that contains this - /// address and uses the specified subnet mask length. - public func prefix(prefixLength: PrefixLength) -> IPv4Address { - IPv4Address(fromValue: value & prefixLength.prefixMask32) + /// Returns `true` if this is an IPv4 address. + @inlinable + public var isV4: Bool { + if case .v4 = self { + return true + } + return false } -} -extension IPv4Address { - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - let text = try container.decode(String.self) - try self.init(text) + /// Returns `true` if this is an IPv6 address. + @inlinable + public var isV6: Bool { + if case .v6 = self { + return true + } + return false } - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(self.description) + /// Returns the underlying IPv4 address if this is an IPv4 address, otherwise `nil`. + @inlinable + public var ipv4: IPv4Address? { + if case .v4(let addr) = self { + return addr + } + return nil + } + + /// Returns the underlying IPv6 address if this is an IPv6 address, otherwise `nil`. + @inlinable + public var ipv6: IPv6Address? { + if case .v6(let addr) = self { + return addr + } + return nil + } + + /// Returns `true` if this is a loopback address (127.0.0.0/8 or ::1). + @inlinable + public var isLoopback: Bool { + switch self { + case .v4(let addr): + return addr.isLoopback + case .v6(let addr): + return addr.isLoopback + } + } + + /// Returns `true` if this is a multicast address. + @inlinable + public var isMulticast: Bool { + switch self { + case .v4(let addr): + return addr.isMulticast + case .v6(let addr): + return addr.isMulticast + } + } + + /// Returns `true` if this is an unspecified address (0.0.0.0 or ::). + @inlinable + public var isUnspecified: Bool { + switch self { + case .v4(let addr): + return addr.isUnspecified + case .v6(let addr): + return addr.isUnspecified + } } } diff --git a/Sources/ContainerizationExtras/IPAddressError.swift b/Sources/ContainerizationExtras/IPAddressError.swift new file mode 100644 index 00000000..de3f853d --- /dev/null +++ b/Sources/ContainerizationExtras/IPAddressError.swift @@ -0,0 +1,66 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +public struct IPAddressError: Error, Equatable, Hashable, CustomStringConvertible { + public var description: String { + String(describing: self.base) + } + + @usableFromInline + enum Base: Equatable, Hashable, Sendable { + case unableToParse + case invalidZoneIdentifier + case invalidIPv4Suffix + case multipleEllipsis + case invalidHexGroup + case malformedAddress + case incompleteAddress + } + + @usableFromInline + let base: Base + + @inlinable + init(_ base: Base) { self.base = base } + + public static var unableToParse: Self { + Self(.unableToParse) + } + + public static var invalidZoneIdentifier: Self { + Self(.invalidZoneIdentifier) + } + + public static var invalidIPv4SuffixInIPv6Address: Self { + Self(.invalidIPv4Suffix) + } + + public static var multipleEllipsis: Self { + Self(.multipleEllipsis) + } + + public static var invalidHexGroup: Self { + Self(.invalidHexGroup) + } + + public static var malformedAddress: Self { + Self(.malformedAddress) + } + + public static var incompleteAddress: Self { + Self(.incompleteAddress) + } +} diff --git a/Sources/ContainerizationExtras/IPv4Address.swift b/Sources/ContainerizationExtras/IPv4Address.swift new file mode 100644 index 00000000..cfa6caa4 --- /dev/null +++ b/Sources/ContainerizationExtras/IPv4Address.swift @@ -0,0 +1,217 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +@frozen +public struct IPv4Address: Sendable, Hashable, CustomStringConvertible, Equatable, Comparable { + public let value: UInt32 + + @inlinable + public init(_ value: UInt32) { + self.value = value + } + + /// Creates an IPv4Address from a string representation. + /// + /// - Parameter string: The IPv4 address string in dotted decimal notation (e.g., "192.168.1.1") + /// - Throws: `IPAddressError.unableToParse` if the string is not a valid IPv4 address + @inlinable + public init(_ string: String) throws { + self.value = try Self.parse(string) + } + + @inlinable + public var bytes: [UInt8] { + Self.bytes(value) + } + + @usableFromInline + static func bytes(_ value: UInt32) -> [UInt8] { + var result = [UInt8](repeating: 0, count: 4) + result[0] = UInt8((value >> 24) & 0xff) + result[1] = UInt8((value >> 16) & 0xff) + result[2] = UInt8((value >> 8) & 0xff) + result[3] = UInt8(value & 0xff) + return result + } + + // TODO: spans? + @available(macOS 26.0, *) + @usableFromInline + static func bytes(_ value: UInt32) -> InlineArray<4, UInt8> { + let result: InlineArray<4, UInt8> = [ + UInt8((value >> 24) & 0xff), + UInt8((value >> 16) & 0xff), + UInt8((value >> 8) & 0xff), + UInt8(value & 0xff), + ] + return result + } + + @inlinable + public var description: String { + "\(bytes[0]).\(bytes[1]).\(bytes[2]).\(bytes[3])" + } + + /// Parses an IPv4 address string in dotted decimal notation into a UInt32 representation. + /// + /// ## Validation Rules + /// - Exactly 4 octets separated by dots + /// - Each octet must be 0-255 + /// - No leading zeros (except for "0" itself) + /// - No whitespace characters + /// - Only digits and dots allowed + /// + /// ## Examples + /// ```swift + /// IPv4Address.parse("192.168.1.1") // Returns: 3232235777 + /// IPv4Address.parse("127.0.0.1") // Returns: 2130706433 + /// IPv4Address.parse("0.0.0.0") // Returns: 0 + /// IPv4Address.parse("255.255.255.255") // Returns: 4294967295 + /// + /// // Invalid examples: + /// IPv4Address.parse("192.168.1") // Wrong number of octets + /// IPv4Address.parse("192.168.1.256") // Octet out of range + /// IPv4Address.parse("192.168.001.1") // Leading zeros + /// IPv4Address.parse(" 192.168.1.1 ") // Whitespace + /// ``` + /// + /// - Parameter s: The IPv4 address string to parse + /// - Returns: The 32-bit representation of the IP address, or `nil` if parsing fails + /// - Note: The returned value is in network byte order (big-endian) + @usableFromInline + internal static func parse(_ s: String) throws -> UInt32 { + guard !s.isEmpty, s.count >= 7, s.count <= 15 else { + throw IPAddressError.unableToParse + } + + // IP addresses should only contain ASCII digits and dots + let utf8 = s.utf8 + for byte in utf8 { + // ASCII whitespace: space(32), tab(9), newline(10), return(13) + if byte == 32 || byte == 9 || byte == 10 || byte == 13 { + throw IPAddressError.unableToParse + } + } + + // accumulator for the 32bit representation of the IPv4 address + var result: UInt32 = 0 + + // tracking octet count, max 4 allowed + var octetCount = 0 + var currentOctet = 0 + + // number of digits in the string representation of the octet, max 3 + var digitCount = 0 + + for byte in utf8 { + if byte == 46 { // ASCII '.' + // Validate octet before processing + guard octetCount < 3, digitCount > 0, digitCount <= 3, currentOctet <= 255 else { + throw IPAddressError.unableToParse + } + + // Shift result and add current octet + result = (result << 8) | UInt32(currentOctet) + + // Reset for next octet + octetCount += 1 + currentOctet = 0 + digitCount = 0 + + } else if byte >= 48 && byte <= 57 { // ASCII '0'-'9' + let digit = Int(byte - 48) + + digitCount += 1 + + // Check for invalid leading zeros: "01", "001", etc. + // Allow single "0" but reject multi-digit numbers starting with 0 + if digitCount == 1 && digit == 0 { + // First digit is 0 - this is only valid if it's the only digit + currentOctet = 0 + } else if digitCount > 1 && currentOctet == 0 { + // We had a leading zero and now have more digits - invalid + throw IPAddressError.unableToParse + } else { + // Normal case: build the octet value + currentOctet = currentOctet * 10 + digit + } + + // Early termination if octet becomes too large + guard currentOctet <= 255, digitCount <= 3 else { + throw IPAddressError.unableToParse + } + + } else { + throw IPAddressError.unableToParse + } + } + + // Validate final octet + guard octetCount == 3, digitCount > 0, digitCount <= 3, currentOctet <= 255 else { + throw IPAddressError.unableToParse + } + + return (result << 8) | UInt32(currentOctet) + } + + // MARK: - Address Classification Methods + + /// Returns `true` if this is the unspecified address (0.0.0.0). + /// + /// Per RFC 791, 0.0.0.0 is the "this network" address. + @inlinable + public var isUnspecified: Bool { + value == 0 + } + + /// Returns `true` if this is a loopback address (127.0.0.0/8). + /// + /// Per RFC 1122 Section 3.2.1.3, the entire 127.0.0.0/8 block is reserved for loopback. + @inlinable + public var isLoopback: Bool { + (value & 0xFF00_0000) == 0x7F00_0000 + } + + /// Returns `true` if this is a multicast address (224.0.0.0/4). + /// + /// Per RFC 1112, addresses in the range 224.0.0.0 to 239.255.255.255 are multicast addresses. + @inlinable + public var isMulticast: Bool { + (value & 0xF000_0000) == 0xE000_0000 + } + + /// Returns `true` if this is a link-local address (169.254.0.0/16). + /// + /// Per RFC 3927, 169.254.0.0/16 is reserved for link-local addresses (APIPA/Auto-IP). + @inlinable + public var isLinkLocal: Bool { + (value & 0xFFFF_0000) == 0xA9FE_0000 + } + + /// Returns `true` if this is the limited broadcast address (255.255.255.255). + /// + /// Per RFC 919/922, 255.255.255.255 is the limited broadcast address. + @inlinable + public var isBroadcast: Bool { + value == 0xFFFF_FFFF + } + + /// Compares two IPv4 addresses numerically. + @inlinable + public static func < (lhs: IPv4Address, rhs: IPv4Address) -> Bool { + lhs.value < rhs.value + } +} diff --git a/Sources/ContainerizationExtras/IPv6Address+Parse.swift b/Sources/ContainerizationExtras/IPv6Address+Parse.swift new file mode 100644 index 00000000..40e04b5c --- /dev/null +++ b/Sources/ContainerizationExtras/IPv6Address+Parse.swift @@ -0,0 +1,333 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +extension IPv6Address { + /// Parses an IPv6 address string into an IPv6Address instance. + /// + /// Follows RFC 4291 and RFC 5952. + /// + /// This function supports standard IPv6 notation including: + /// - Full addresses: `2001:0db8:0000:0042:0000:8a2e:0370:7334` + /// - Zero compression: `2001:db8::8a2e:370:7334` + /// - Leading zero omission: `2001:db8:0:42:0:8a2e:370:7334` + /// - Unspecified address: `::` + /// - Zone identifiers: `fe80::1%eth0` + /// + /// - Parameter input: IPv6 address string (with optional zone identifier after %) + /// - Returns: An `IPv6Address` instance representing the parsed address + /// + /// ## Example Usage + /// ```swift + /// let addr1 = try IPv6Address.parse("2001:db8::1") + /// let addr2 = try IPv6Address.parse("::") // Unspecified address + /// let addr3 = try IPv6Address.parse("fe80::1%eth0") // With zone identifier + /// ``` + static func parse(_ input: String) throws -> IPv6Address { + var ipBytes = [UInt8](repeating: 0, count: 16) + var ellipsisPosition: Int? + + // Extract zone identifier + let (address, zone) = try extractZoneIdentifier(from: input) + + // RFC 4291 Section 2.2.3: IPv4 suffix must be at the end (last 32 bits) + var remainingAddress = address + var ipv6ByteLimit = 16 // Maximum bytes available for IPv6 hex groups + var hasIPv4Suffix = false + + // check if the IPv6 address has IPv4 address in it. + if let (ipv6Part, ipv4Bytes) = try extractIPv4Suffix(from: address) { + // If IPv4 present, save directly to last 4 bytes. + ipBytes[12] = ipv4Bytes[0] + ipBytes[13] = ipv4Bytes[1] + ipBytes[14] = ipv4Bytes[2] + ipBytes[15] = ipv4Bytes[3] + + // Update address and limit IPv6 parsing to first 12 bytes (6 groups max) + remainingAddress = ipv6Part + ipv6ByteLimit = 12 + hasIPv4Suffix = true + } + + // Handle leading ellipsis in the IPv6 part + if remainingAddress.utf8.starts(with: [58, 58]) { // "::" + ellipsisPosition = 0 + remainingAddress = String(remainingAddress.dropFirst(2)) + + // Special case: "::" represents the unspecified address (all zeros) + // But if we have IPv4 suffix, the IPv4 bytes are already set correctly + if remainingAddress.isEmpty { + // If we have IPv4 suffix, ipBytes already has the IPv4 data, just return + if hasIPv4Suffix { + return Self(ipBytes, zone: zone) + } + + // Pure "::" - Return the unspecified address, handling zone identifiers + if let zone = zone, !zone.isEmpty { + return Self(ipBytes, zone: zone) + } + return .unspecified + } + } + + // Parse IPv6 hex groups up to the byte limit + var byteIndex = 0 + let utf8 = remainingAddress.utf8 + var currentPosition = utf8.startIndex + + while byteIndex < ipv6ByteLimit && currentPosition < utf8.endIndex { + let (hexValue, nextPosition) = try parseHexadecimal( + from: utf8, + startingAt: currentPosition + ) + + // Store the UInt16 in network-byte order + ipBytes[byteIndex] = UInt8(hexValue >> 8) + ipBytes[byteIndex + 1] = UInt8(hexValue & 0xFF) + byteIndex += 2 + currentPosition = nextPosition + + // Terminate early if we have consumed the whole string + if currentPosition == utf8.endIndex { + break + } + + // Parse separator and handle ellipsis detection + currentPosition = try skipColonSeparator( + from: utf8, + at: currentPosition, + currentByteIndex: byteIndex, + ellipsisPosition: &ellipsisPosition + ) + } + + // Validate complete consumption of input + guard currentPosition >= utf8.endIndex else { + throw IPAddressError.malformedAddress + } + + // Apply ellipsis expansion for the IPv6 portion + try expandEllipsis( + in: &ipBytes, + parsedBytes: byteIndex, + ellipsisPosition: ellipsisPosition, + byteLimit: ipv6ByteLimit + ) + let value = ipBytes.reduce(UInt128(0)) { ($0 << 8) | UInt128($1) } + return Self(value, zone: zone) + } + + // MARK: - Helper Functions + + /// Extracts IPv4 suffix if present at the end of the address + /// + /// Follows: RFC 4291 Section 2.2.3: Alternative form x:x:x:x:x:x:d.d.d.d + /// IPv4 must be the last 32 bits and preceded by a colon + /// + /// - Parameter input: The IPv6 address string to check + /// - Returns: Optional tuple of (IPv6 part without IPv4, IPv4 bytes array) if IPv4 found, nil otherwise + /// - Throws: `IPAddressError.invalidIPv4Suffix` for invalid IPv4 addresses + internal static func extractIPv4Suffix(from input: String) throws -> (String, [UInt8])? { + // must contain a dot to be IPv4 + guard input.utf8.contains(46) else { // ASCII '.' + return nil + } + + // IPv4 address must be present after last colon + guard let lastColonIndex = input.lastIndex(of: ":") else { + return nil + } + + // TODO: maybe refactor for performance + let afterColon = input.index(after: lastColonIndex) + guard afterColon < input.endIndex else { + return nil + } + + let possibleIPv4 = String(input[afterColon...]) + guard let ipv4Value = try? IPv4Address.parse(possibleIPv4) else { + throw IPAddressError.invalidIPv4SuffixInIPv6Address + } + + // Check if lastColonIndex is the second ':' of '::'. If so, ensure to include it. + let isDoubleColon = lastColonIndex > input.startIndex && input[input.index(before: lastColonIndex)] == ":" + let ipv6Part = isDoubleColon ? String(input[...lastColonIndex]) : String(input[.. (String, String?) { + guard let percentIndex = input.lastIndex(of: "%") else { + return (input, nil) + } + + let zoneStartIndex = input.index(after: percentIndex) + guard zoneStartIndex < input.endIndex else { + throw IPAddressError.invalidZoneIdentifier + } + + let addressPart = String(input[.. (UInt16, String.UTF8View.Index) { + var accumulator: UInt16 = 0 + var digitCount = 0 + var currentIndex = startIndex + + while currentIndex < group.endIndex && digitCount < 4 { + let byte = group[currentIndex] + + // Fast hex digit parsing using ASCII values + let hexValue: UInt16 + if byte >= 48 && byte <= 57 { // '0'-'9' + hexValue = UInt16(byte - 48) + } else if byte >= 65 && byte <= 70 { // 'A'-'F' + hexValue = UInt16(byte - 65 + 10) + } else if byte >= 97 && byte <= 102 { // 'a'-'f' + hexValue = UInt16(byte - 97 + 10) + } else { + break // Not a hex digit + } + + accumulator = (accumulator << 4) + hexValue + digitCount += 1 + currentIndex = group.index(after: currentIndex) + } + + guard digitCount > 0 else { + // No hex digits found + throw IPAddressError.invalidHexGroup + } + return (accumulator, currentIndex) + } + + /// Parses a colon separator between IPv6 groups and detects ellipsis notation (::). + /// + /// - Parameters: + /// - utf8: The UTF-8 view being parsed + /// - position: Current position in the UTF-8 view (must point to a colon) + /// - currentByteIndex: Current byte index in the IP array + /// - ellipsisPosition: Inout parameter tracking ellipsis position + /// - Returns: Next position in the UTF-8 view after parsing separator + /// + /// ## Example + /// ```swift + /// let utf8 = "2001:db8::1".utf8 + /// var ellipsisPos: Int? = nil + /// // After parsing "2001", position points to first ':' + /// let nextPos = try skipColonSeparator(from: utf8, at: position, + /// currentByteIndex: 2, + /// ellipsisPosition: &ellipsisPos) + /// // For single colon: nextPos points to 'd' in 'db8' + /// // For double colon (::): ellipsisPos = 2, nextPos points to '1' + /// ``` + private static func skipColonSeparator( + from group: String.UTF8View, + at position: String.UTF8View.Index, + currentByteIndex: Int, + ellipsisPosition: inout Int? + ) throws -> String.UTF8View.Index { + // Expect colon separator + guard group[position] == 58 else { // ASCII ':' + throw IPAddressError.malformedAddress + } + + let afterFirstColon = group.index(after: position) + guard afterFirstColon < group.endIndex else { + // Trailing colon not allowed + throw IPAddressError.malformedAddress + } + + // Check for double colon, return position after that + if group[afterFirstColon] == 58 { // ASCII ':' + guard ellipsisPosition == nil else { + // Multiple :: not allowed + throw IPAddressError.multipleEllipsis + } + ellipsisPosition = currentByteIndex + let afterSecondColon = group.index(after: afterFirstColon) + return afterSecondColon + } + return afterFirstColon + } + + /// Expands ellipsis for IPv6 addresses + /// + /// - Parameters: + /// - ipBytes: Inout array of IP bytes to modify + /// - parsedBytes: Number of bytes already parsed for IPv6 groups + /// - ellipsisPosition: Optional position where ellipsis was found + /// - byteLimit: Maximum bytes available for IPv6 (16 for pure IPv6, 12 if IPv4 suffix present) + /// - Throws: `IPAddressError.incompleteAddress` for invalid address lengths + private static func expandEllipsis( + in ipBytes: inout [UInt8], + parsedBytes: Int, + ellipsisPosition: Int?, + byteLimit: Int = 16 + ) throws { + guard let ellipsisPosition = ellipsisPosition else { + // No ellipsis - validate we have exactly filled the available bytes + guard parsedBytes == byteLimit else { + throw IPAddressError.incompleteAddress // Incomplete address without ellipsis + } + return + } + + // Calculate expansion within the byte limit + let bytesToExpand = byteLimit - parsedBytes + guard bytesToExpand > 0 else { + throw IPAddressError.malformedAddress // No room for ellipsis expansion + } + + let suffixBytes = Array(ipBytes[ellipsisPosition..> 112) & 0xFFFF), + UInt16((value >> 96) & 0xFFFF), + UInt16((value >> 80) & 0xFFFF), + UInt16((value >> 64) & 0xFFFF), + UInt16((value >> 48) & 0xFFFF), + UInt16((value >> 32) & 0xFFFF), + UInt16((value >> 16) & 0xFFFF), + UInt16(value & 0xFFFF), + ] + + // Find the longest run of consecutive zeros for :: compression + var longestZeroStart = -1 + var longestZeroLength = 0 + var currentZeroStart = -1 + var currentZeroLength = 0 + + for (index, group) in groups.enumerated() { + if group == 0 { + if currentZeroStart == -1 { + currentZeroStart = index + currentZeroLength = 1 + } else { + currentZeroLength += 1 + } + } else { + if currentZeroLength > longestZeroLength { + longestZeroStart = currentZeroStart + longestZeroLength = currentZeroLength + } + currentZeroStart = -1 + currentZeroLength = 0 + } + } + if currentZeroLength > longestZeroLength { + longestZeroStart = currentZeroStart + longestZeroLength = currentZeroLength + } + + let useCompression = longestZeroLength >= 2 + + var result = "" + var index = 0 + + while index < 8 { + if useCompression && index == longestZeroStart { + if index == 0 { + result += "::" + } else { + result += ":" + } + // Skip the compressed zeros + index += longestZeroLength + + // If we compressed to the end, we're done + if index >= 8 { + break + } + } else { + // Add the group in lowercase hex without leading zeros + result += String(groups[index], radix: 16, uppercase: false) + index += 1 + + // Add colon if not at the end + if index < 8 { + result += ":" + } + } + } + + if let zone = zone { + result += "%" + zone + } + + return result + } + + @inlinable + public var bytes: [UInt8] { + Self.bytes(self.value) + } + + @usableFromInline + internal static func bytes(_ value: UInt128) -> [UInt8] { + var result = [UInt8](repeating: 0, count: 16) + result[0] = UInt8((value >> 120) & 0xff) + result[1] = UInt8((value >> 112) & 0xff) + result[2] = UInt8((value >> 104) & 0xff) + result[3] = UInt8((value >> 96) & 0xff) + result[4] = UInt8((value >> 88) & 0xff) + result[5] = UInt8((value >> 80) & 0xff) + result[6] = UInt8((value >> 72) & 0xff) + result[7] = UInt8((value >> 64) & 0xff) + result[8] = UInt8((value >> 56) & 0xff) + result[9] = UInt8((value >> 48) & 0xff) + result[10] = UInt8((value >> 40) & 0xff) + result[11] = UInt8((value >> 32) & 0xff) + result[12] = UInt8((value >> 24) & 0xff) + result[13] = UInt8((value >> 16) & 0xff) + result[14] = UInt8((value >> 8) & 0xff) + result[15] = UInt8(value & 0xff) + return result + } + + @available(macOS 26.0, *) + @usableFromInline + internal static func bytes(_ value: UInt128) -> InlineArray<16, UInt8> { + let result: InlineArray<16, UInt8> = [ + UInt8((value >> 120) & 0xff), + UInt8((value >> 112) & 0xff), + UInt8((value >> 104) & 0xff), + UInt8((value >> 96) & 0xff), + UInt8((value >> 88) & 0xff), + UInt8((value >> 80) & 0xff), + UInt8((value >> 72) & 0xff), + UInt8((value >> 64) & 0xff), + UInt8((value >> 56) & 0xff), + UInt8((value >> 48) & 0xff), + UInt8((value >> 40) & 0xff), + UInt8((value >> 32) & 0xff), + UInt8((value >> 24) & 0xff), + UInt8((value >> 16) & 0xff), + UInt8((value >> 8) & 0xff), + UInt8(value & 0xff), + ] + return result + } + + /// The unspecified IPv6 address (::) + public static let unspecified = IPv6Address(0) + + /// The loopback IPv6 address (::1) + public static let loopback = IPv6Address(1) + + // MARK: - Address Classification Methods + + /// Returns `true` if this is the unspecified address (::). + @inlinable + public var isUnspecified: Bool { + value == 0 + } + + /// Returns `true` if this is the loopback address (::1). + @inlinable + public var isLoopback: Bool { + value == 1 + } + + /// Returns `true` if this is a multicast address (ff00::/8). + @inlinable + public var isMulticast: Bool { + (value >> 120) == 0xFF + } + + /// Returns `true` if this is a link-local unicast address (fe80::/10). + @inlinable + public var isLinkLocal: Bool { + (value >> 118) == 0x3FA // fe80::/10 = top 10 bits are 1111111010 + } + + /// Returns `true` if this is a unique local address (fc00::/7). + @inlinable + public var isUniqueLocal: Bool { + (value >> 121) == 0x7E // fc00::/7 = top 7 bits are 1111110 + } + + /// Returns `true` if this is a global unicast address. + @inlinable + public var isGlobalUnicast: Bool { + !isUnspecified && !isLoopback && !isMulticast && !isLinkLocal && !isUniqueLocal + } + + /// Returns `true` if this is a documentation address (2001:db8::/32). + @inlinable + public var isDocumentation: Bool { + (value >> 96) == 0x2001_0DB8 // 2001:db8::/32 + } + /// Compares two IPv6 addresses numerically, then by zone if values are equal. + @inlinable + public static func < (lhs: IPv6Address, rhs: IPv6Address) -> Bool { + if lhs.value != rhs.value { + return lhs.value < rhs.value + } + // Same value, compare zones lexicographically + return (lhs.zone ?? "") < (rhs.zone ?? "") + } +} diff --git a/Sources/ContainerizationExtras/NetworkAddress+Allocator.swift b/Sources/ContainerizationExtras/NetworkAddress+Allocator.swift index 85a22a94..f2566026 100644 --- a/Sources/ContainerizationExtras/NetworkAddress+Allocator.swift +++ b/Sources/ContainerizationExtras/NetworkAddress+Allocator.swift @@ -29,7 +29,7 @@ extension IPv4Address { } return Int(address.value - lower) }, - indexToAddress: { IPv4Address(fromValue: lower + UInt32($0)) } + indexToAddress: { IPv4Address(lower + UInt32($0)) } ) } } diff --git a/Sources/ContainerizationExtras/NetworkAddress.swift b/Sources/ContainerizationExtras/NetworkAddress.swift deleted file mode 100644 index d9b97545..00000000 --- a/Sources/ContainerizationExtras/NetworkAddress.swift +++ /dev/null @@ -1,69 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the Containerization project authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// https://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -//===----------------------------------------------------------------------===// - -/// Errors related to IP and CIDR addresses. -public enum NetworkAddressError: Swift.Error, Equatable, CustomStringConvertible { - case invalidStringAddress(address: String) - case invalidNetworkByteAddress(address: [UInt8]) - case invalidCIDR(cidr: String) - case invalidAddressForSubnet(address: String, cidr: String) - case invalidAddressRange(lower: String, upper: String) - - public var description: String { - switch self { - case .invalidStringAddress(let address): - return "invalid IP address string \(address)" - case .invalidNetworkByteAddress(let address): - return "invalid IP address bytes \(address)" - case .invalidCIDR(let cidr): - return "invalid CIDR block: \(cidr)" - case .invalidAddressForSubnet(let address, let cidr): - return "invalid address \(address) for subnet \(cidr)" - case .invalidAddressRange(let lower, let upper): - return "invalid range for addresses \(lower) and \(upper)" - } - } -} - -public typealias PrefixLength = UInt8 - -extension PrefixLength { - /// Compute a bit mask that passes the suffix bits, given the network prefix mask length. - public var suffixMask32: UInt32 { - if self <= 0 { - return 0xffff_ffff - } - return self >= 32 ? 0x0000_0000 : (1 << (32 - self)) - 1 - } - - /// Compute a bit mask that passes the prefix bits, given the network prefix mask length. - public var prefixMask32: UInt32 { - ~self.suffixMask32 - } - - /// Compute a bit mask that passes the suffix bits, given the network prefix mask length. - public var suffixMask48: UInt64 { - if self <= 0 { - return 0x0000_ffff_ffff_ffff - } - return self >= 48 ? 0x0000_0000_0000_0000 : (1 << (48 - self)) - 1 - } - - /// Compute a bit mask that passes the prefix bits, given the network prefix mask length. - public var prefixMask48: UInt64 { - ~self.suffixMask48 & 0x0000_ffff_ffff_ffff - } -} diff --git a/Sources/ContainerizationExtras/Prefix.swift b/Sources/ContainerizationExtras/Prefix.swift new file mode 100644 index 00000000..b067fba4 --- /dev/null +++ b/Sources/ContainerizationExtras/Prefix.swift @@ -0,0 +1,88 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +/// CIDR prefix length (e.g., `/24` for a 24-bit network mask). +@frozen +public struct Prefix: Sendable, CustomStringConvertible, Hashable { + public let length: UInt8 + + /// Create a prefix (0-128). Use `ipv4(_:)` or `ipv6(_:)` for version-specific validation. + public init?(length: UInt8) { + guard length <= 128 else { return nil } + self.length = length + } + + /// Create an IPv4 prefix (0-32). Returns `nil` if length > 32. + public static func ipv4(_ length: UInt8) -> Prefix? { + guard length <= 32 else { return nil } + return Prefix(unchecked: length) + } + + /// Create an IPv6 prefix (0-128). Returns `nil` if length > 128. + public static func ipv6(_ length: UInt8) -> Prefix? { + guard length <= 128 else { return nil } + return Prefix(unchecked: length) + } + + /// Internal unchecked initializer for known-valid values. + internal init(unchecked length: UInt8) { + self.length = length + } + + public var description: String { + "\(length)" + } +} + +extension Prefix { + /// Computes a 32-bit mask for the suffix (host) portion of an IPv4 address. + /// + /// Example: Prefix `/24` → `0x0000_00FF` (255 host addresses) + @inlinable + public var suffixMask32: UInt32 { + if self.length <= 0 { + return 0xffff_ffff + } + return self.length >= 32 ? 0x0000_0000 : (1 << (32 - self.length)) - 1 + } + + /// Network portion mask (high-order bits) for IPv4. + /// + /// Example: Prefix `/24` → `0xFFFF_FF00` (255.255.255.0) + @inlinable + public var prefixMask32: UInt32 { + ~self.suffixMask32 + } + + /// Computes a 128-bit mask for the suffix (host) portion of an IPv6 address. + /// + /// Example: Prefix `/64` → `0x0000_0000_0000_0000_FFFF_FFFF_FFFF_FFFF` + @inlinable + public var suffixMask128: UInt128 { + if self.length <= 0 { + return UInt128.max + } + return self.length >= 128 ? 0 : (1 << (128 - self.length)) - 1 + } + + /// Network portion mask (high-order bits) for IPv6. + /// + /// Example: Prefix `/64` → `0xFFFF_FFFF_FFFF_FFFF_0000_0000_0000_0000` + @inlinable + public var prefixMask128: UInt128 { + ~self.suffixMask128 + } +} diff --git a/Sources/ContainerizationNetlink/NetlinkSession.swift b/Sources/ContainerizationNetlink/NetlinkSession.swift index 7d17aba8..6df025c2 100644 --- a/Sources/ContainerizationNetlink/NetlinkSession.swift +++ b/Sources/ContainerizationNetlink/NetlinkSession.swift @@ -193,8 +193,8 @@ public struct NetlinkSession { /// Adds an IPv4 address to an interface. /// - Parameters: /// - interface: The name of the interface. - /// - address: The IPv4 address to add. - public func addressAdd(interface: String, address: String) throws { + /// - ipv4Address: The CIDRv4 address describing the interface IP and subnet prefix length. + public func addressAdd(interface: String, ipv4Address: CIDRv4) throws { // ip addr add [addr] dev [interface] // ip address {add|change|replace} IFADDR dev IFNAME [ LIFETIME ] [ CONFFLAG-LIST ] // IFADDR := PREFIX | ADDR peer PREFIX @@ -205,9 +205,9 @@ public struct NetlinkSession { // CONFFLAG := [ home | nodad | mngtmpaddr | noprefixroute | autojoin ] // LIFETIME := [ valid_lft LFT ] [ preferred_lft LFT ] // LFT := forever | SECONDS - let parsed = try parseCIDR(cidr: address) let interfaceIndex = try getInterfaceIndex(interface) - let ipAddressBytes = try IPv4Address(parsed.address).networkBytes + + let ipAddressBytes = ipv4Address.address.bytes let addressAttrSize = RTAttribute.size + MemoryLayout.size * ipAddressBytes.count let requestSize = NetlinkMessageHeader.size + AddressInfo.size + 2 * addressAttrSize var requestBuffer = [UInt8](repeating: 0, count: requestSize) @@ -224,7 +224,7 @@ public struct NetlinkSession { let requestInfo = AddressInfo( family: UInt8(AddressFamily.AF_INET), - prefixLength: parsed.prefix, + prefixLength: ipv4Address.prefix.length, flags: 0, scope: NetlinkScope.RT_SCOPE_UNIVERSE, index: UInt32(interfaceIndex)) @@ -253,37 +253,22 @@ public struct NetlinkSession { } } - private func parseCIDR(cidr: String) throws -> (address: String, prefix: UInt8) { - let split = cidr.components(separatedBy: "/") - guard split.count == 2 else { - throw NetworkAddressError.invalidCIDR(cidr: cidr) - } - let address = split[0] - guard let prefixLength = PrefixLength(split[1]) else { - throw NetworkAddressError.invalidCIDR(cidr: cidr) - } - guard prefixLength >= 0 && prefixLength <= 32 else { - throw NetworkAddressError.invalidCIDR(cidr: cidr) - } - return (address, prefixLength) - } - - /// Adds a route to an interface. + /// Adds an IPv4 route to an interface. /// - Parameters: /// - interface: The name of the interface. - /// - destinationAddress: The destination address to route to. - /// - srcAddr: The source address to route from. + /// - dstIpv4Addr: The CIDRv4 address describing the gateway IP and subnet prefix length. + /// - srcIpv4Addr: The source IPv4 address to route from. public func routeAdd( interface: String, - destinationAddress: String, - srcAddr: String + dstIpv4Addr: CIDRv4, + srcIpv4Addr: IPv4Address ) throws { // ip route add [dest-cidr] dev [interface] src [src-addr] proto kernel - let parsed = try parseCIDR(cidr: destinationAddress) let interfaceIndex = try getInterfaceIndex(interface) - let dstAddrBytes = try IPv4Address(parsed.address).networkBytes + + let dstAddrBytes = dstIpv4Addr.address.bytes let dstAddrAttrSize = RTAttribute.size + dstAddrBytes.count - let srcAddrBytes = try IPv4Address(srcAddr).networkBytes + let srcAddrBytes = srcIpv4Addr.bytes let srcAddrAttrSize = RTAttribute.size + srcAddrBytes.count let interfaceAttrSize = RTAttribute.size + MemoryLayout.size let requestSize = @@ -301,7 +286,7 @@ public struct NetlinkSession { let requestInfo = RouteInfo( family: UInt8(AddressFamily.AF_INET), - dstLen: parsed.prefix, + dstLen: dstIpv4Addr.prefix.length, srcLen: 0, tos: 0, table: RouteTable.MAIN, @@ -345,16 +330,16 @@ public struct NetlinkSession { } } - /// Adds a default route to an interface. + /// Adds a default IPv4 route to an interface. /// - Parameters: /// - interface: The name of the interface. - /// - gateway: The gateway address. + /// - ipv4Gateway: The gateway address. public func routeAddDefault( interface: String, - gateway: String + ipv4Gateway: IPv4Address ) throws { // ip route add default via [dst-address] src [src-address] - let dstAddrBytes = try IPv4Address(gateway).networkBytes + let dstAddrBytes = ipv4Gateway.bytes let dstAddrAttrSize = RTAttribute.size + dstAddrBytes.count let interfaceAttrSize = RTAttribute.size + MemoryLayout.size diff --git a/Sources/cctl/RunCommand.swift b/Sources/cctl/RunCommand.swift index a704d417..47923816 100644 --- a/Sources/cctl/RunCommand.swift +++ b/Sources/cctl/RunCommand.swift @@ -17,6 +17,7 @@ import ArgumentParser import Containerization import ContainerizationError +import ContainerizationExtras import ContainerizationOCI import ContainerizationOS import Foundation @@ -124,7 +125,9 @@ extension Application { guard let gateway else { throw ContainerizationError(.invalidArgument, message: "gateway must be specified") } - config.interfaces.append(NATInterface(address: ip, gateway: gateway)) + let ipv4Address = try CIDRv4(ip) + let ipv4Gateway = try IPv4Address(gateway) + config.interfaces.append(NATInterface(ipv4Address: ipv4Address, ipv4Gateway: ipv4Gateway)) config.dns = .init(nameservers: [gateway]) if nameservers.count > 0 { config.dns = .init(nameservers: nameservers) diff --git a/Tests/ContainerizationExtrasTests/TestCIDR.swift b/Tests/ContainerizationExtrasTests/TestCIDR.swift new file mode 100644 index 00000000..086b90bf --- /dev/null +++ b/Tests/ContainerizationExtrasTests/TestCIDR.swift @@ -0,0 +1,311 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Testing + +@testable import ContainerizationExtras + +struct TestCIDR { + + // MARK: - Normalization Tests + + struct Normalization { + let input: String + let expectedNetwork: String + let expectedLength: UInt8 + } + + @Test(arguments: [ + Normalization( + input: "192.168.1.100/24", + expectedNetwork: "192.168.1.0", + expectedLength: 24 + ), + Normalization( + input: "10.1.2.3/16", + expectedNetwork: "10.1.0.0", + expectedLength: 16 + ), + Normalization( + input: "2001:db8::1234/64", + expectedNetwork: "2001:db8::", + expectedLength: 64 + ), + Normalization( + input: "172.16.0.1/12", + expectedNetwork: "172.16.0.0", + expectedLength: 12 + ), + ]) + func testNormalization(testCase: Normalization) throws { + let cidr = try CIDR(testCase.input) + #expect(cidr.lower.description == testCase.expectedNetwork) + #expect(cidr.prefix.length == testCase.expectedLength) + } + + struct ParsePreservation { + let input: String + let expectedIP: String + let expectedLength: UInt8 + } + + // MARK: - Bounds Tests + + struct Bounds { + let cidr: String + let lower: String + let upper: String + } + + @Test(arguments: [ + Bounds( + cidr: "192.168.1.0/24", + lower: "192.168.1.0", + upper: "192.168.1.255" + ), + Bounds( + cidr: "10.0.0.0/8", + lower: "10.0.0.0", + upper: "10.255.255.255" + ), + Bounds( + cidr: "2001:db8::/64", + lower: "2001:db8::", + upper: "2001:db8::ffff:ffff:ffff:ffff" + ), + Bounds( + cidr: "192.168.1.0/32", + lower: "192.168.1.0", + upper: "192.168.1.0" + ), + ]) + func testBounds(testCase: Bounds) throws { + let block = try CIDR(testCase.cidr) + #expect(block.lower.description == testCase.lower) + #expect(block.upper.description == testCase.upper) + } + + // MARK: - Containment Tests + + struct IPContainment { + let cidr: String + let ip: String + let shouldContain: Bool + } + + @Test(arguments: [ + IPContainment( + cidr: "192.168.1.0/24", + ip: "192.168.1.100", + shouldContain: true + ), + IPContainment( + cidr: "192.168.1.0/24", + ip: "192.168.2.1", + shouldContain: false + ), + IPContainment( + cidr: "10.0.0.0/8", + ip: "10.255.255.255", + shouldContain: true + ), + IPContainment( + cidr: "10.0.0.0/8", + ip: "11.0.0.1", + shouldContain: false + ), + IPContainment( + cidr: "2001:db8::/32", + ip: "2001:db8::1", + shouldContain: true + ), + ]) + func testContainsIP(testCase: IPContainment) throws { + let block = try CIDR(testCase.cidr) + let address = try IPAddress(testCase.ip) + #expect(block.contains(address) == testCase.shouldContain) + } + + @Test func testDoesNotContainDifferentIPv6Zone() throws { + let cidr = try CIDR("fe80::1/64") + let ip = try IPv6Address("fe80::2%eth1") + #expect(!cidr.contains(.v6(ip))) + } + + // MARK: - Range Constructor + + @Test func testRangeConstructorFindsSmallestBlock() throws { + let lower = try IPAddress("192.168.1.0") + let upper = try IPAddress("192.168.1.255") + let cidr = try CIDR(lower: lower, upper: upper) + #expect(cidr.prefix.length == 24) + #expect(cidr.address.description == "192.168.1.0") + } + + @Test func testRangeConstructorSingleIPv4() throws { + let ip = try IPAddress("192.168.1.100") + let cidr = try CIDR(lower: ip, upper: ip) + #expect(cidr.prefix.length == 32) + #expect(cidr.address.description == "192.168.1.100") + } + + @Test func testRangeConstructorIPv6() throws { + let lower = try IPAddress("2001:db8::") + let upper = try IPAddress("2001:db8::ffff:ffff:ffff:ffff") + let cidr = try CIDR(lower: lower, upper: upper) + #expect(cidr.prefix.length == 64) + #expect(cidr.address.description == "2001:db8::") + } + + @Test func testRangeConstructorSingleIPv6() throws { + let ip = try IPAddress("2001:db8::1") + let cidr = try CIDR(lower: ip, upper: ip) + #expect(cidr.prefix.length == 128) + #expect(cidr.address.description == "2001:db8::1") + } + + @Test func testRangeConstructorRejectsMixedVersions() throws { + #expect(throws: CIDR.Error.self) { + let v4 = try IPAddress("192.168.1.0") + let v6 = try IPAddress("2001:db8::1") + _ = try CIDR(lower: v4, upper: v6) + } + } + + @Test func testRangeConstructorRejectsDifferentZones() throws { + #expect(throws: CIDR.Error.self) { + let lower = IPAddress.v6(try IPv6Address("fe80::1%eth0")) + let upper = IPAddress.v6(try IPv6Address("fe80::2%eth1")) + _ = try CIDR(lower: lower, upper: upper) + } + } + + // MARK: - Validation Tests + + struct InvalidInput { + let input: String + } + + @Test(arguments: [ + InvalidInput(input: "192.168.1.0/33"), // IPv4 prefix too large + InvalidInput(input: "2001:db8::/129"), // IPv6 prefix too large + InvalidInput(input: "192.168.1.0"), // Missing prefix + InvalidInput(input: "192.168.1.0/"), // Empty prefix + InvalidInput(input: "192.168.1.0/abc"), // Invalid prefix + ]) + func testRejectsInvalidInput(testCase: InvalidInput) throws { + #expect(throws: CIDR.Error.self) { + try CIDR(testCase.input) + } + } + + @Test func testRejectsInvalidRangeOrder() throws { + #expect(throws: CIDR.Error.self) { + let lower = try IPAddress("192.168.1.255") + let upper = try IPAddress("192.168.1.0") + _ = try CIDR(lower: lower, upper: upper) + } + } + + // MARK: - Range Constructor Validation Tests + + @Test func testRangeConstructorValidatesContainment() throws { + // This should work: 192.168.1.64 to 192.168.1.127 -> /26 + let lower1 = try IPAddress("192.168.1.64") + let upper1 = try IPAddress("192.168.1.127") + let cidr1 = try CIDR(lower: lower1, upper: upper1) + #expect(cidr1.prefix.length == 26) + #expect(cidr1.contains(lower1)) + #expect(cidr1.contains(upper1)) + } + + @Test func testRangeConstructorValidatesIPv6Containment() throws { + // Test IPv6 range containment validation + let lower = try IPAddress("2001:db8::1000") + let upper = try IPAddress("2001:db8::1fff") + let cidr = try CIDR(lower: lower, upper: upper) + #expect(cidr.contains(lower)) + #expect(cidr.contains(upper)) + } + + // MARK: - Version-Specific Range Constructors + + @Test func testV4RangeConstructor() throws { + let lower = try IPAddress("192.168.1.0") + let upper = try IPAddress("192.168.1.255") + let cidr = try CIDR(lower: lower, upper: upper) + #expect(cidr.prefix.length == 24) + #expect(cidr.address.description == "192.168.1.0") + } + + @Test func testV4RangeSingleAddress() throws { + let addr = try IPAddress("192.168.1.100") + let cidr = try CIDR(lower: addr, upper: addr) + #expect(cidr.prefix.length == 32) + #expect(cidr.address.description == "192.168.1.100") + } + + @Test func testV6RangeConstructor() throws { + let lower = try IPAddress("2001:db8::") + let upper = try IPAddress("2001:db8::ffff:ffff:ffff:ffff") + let cidr = try CIDR(lower: lower, upper: upper) + #expect(cidr.prefix.length == 64) + #expect(cidr.address.description == "2001:db8::") + } + + @Test func testV6RangeSingleAddress() throws { + let addr = try IPAddress("2001:db8::1") + let cidr = try CIDR(lower: addr, upper: addr) + #expect(cidr.prefix.length == 128) + #expect(cidr.address.description == "2001:db8::1") + } + + @Test func testV4RangeRejectsInvalidOrder() throws { + let lower = try IPAddress("192.168.1.255") + let upper = try IPAddress("192.168.1.0") + #expect(throws: CIDR.Error.self) { + _ = try CIDR(lower: lower, upper: upper) + } + } + + @Test func testV6RangeRejectsInvalidOrder() throws { + let lower = try IPAddress("2001:db8::ffff") + let upper = try IPAddress("2001:db8::1") + #expect(throws: CIDR.Error.self) { + _ = try CIDR(lower: lower, upper: upper) + } + } + + @Test func testV6RangeRejectsDifferentZones() throws { + let lower = try IPAddress("fe80::1%eth0") + let upper = try IPAddress("fe80::2%eth1") + #expect(throws: CIDR.Error.self) { + _ = try CIDR(lower: lower, upper: upper) + } + } + + // MARK: - Description Tests + + @Test func testDescriptionFormat() throws { + let cidr = try CIDR("10.0.0.0/8") + #expect(cidr.description == "10.0.0.0/8") + } + + @Test func testPreservesAddress() throws { + let cidr = try CIDR("192.168.1.100/24") + #expect(cidr.description == "192.168.1.100/24") + } +} diff --git a/Tests/ContainerizationExtrasTests/TestCIDRAddress.swift b/Tests/ContainerizationExtrasTests/TestCIDRAddress.swift deleted file mode 100644 index d8d7c081..00000000 --- a/Tests/ContainerizationExtrasTests/TestCIDRAddress.swift +++ /dev/null @@ -1,211 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the Containerization project authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// https://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -//===----------------------------------------------------------------------===// - -// -// swiftlint:disable force_try -// - -import Foundation -import Testing - -@testable import ContainerizationExtras - -final class TestCIDRAddress { - @Test - func testMissingSplitError() { - let cidr = "192.168.64.0" - do { - _ = try CIDRAddress(cidr) - #expect(Bool(false), "Expected AddressError.invalidCIDR to be thrown") - } catch { - #expect(error as? NetworkAddressError == .invalidCIDR(cidr: cidr), "Unexpected error thrown: \(error)") - } - } - - @Test - func testInvalidIPError() { - let cidr = "192.168.256.1/24" - do { - _ = try CIDRAddress(cidr) - #expect(Bool(false), "Expected AddressError.invalidCIDR to be thrown") - } catch { - #expect( - error as? NetworkAddressError == .invalidStringAddress(address: "192.168.256.1"), - "Unexpected error thrown: \(error)") - } - } - - @Test - func testInvalidSubnetTypeError() { - let cidr = "192.168.64.0/foo" - do { - _ = try CIDRAddress(cidr) - #expect(Bool(false), "Expected AddressError.invalidCIDR to be thrown") - } catch { - #expect(error as? NetworkAddressError == .invalidCIDR(cidr: cidr), "Unexpected error thrown: \(error)") - } - } - - @Test - func testInvalidPrefixTooLargeError() { - let cidr = "192.168.64.0/33" - do { - _ = try CIDRAddress(cidr) - #expect(Bool(false), "Expected AddressError.invalidCIDR to be thrown") - } catch { - #expect(error as? NetworkAddressError == .invalidCIDR(cidr: cidr), "Unexpected error thrown: \(error)") - } - } - - @Test - func testInvalidPrefixTooSmallError() { - let cidr = "192.168.64.0/-1" - do { - _ = try CIDRAddress(cidr) - #expect(Bool(false), "Expected AddressError.invalidCIDR to be thrown") - } catch { - #expect(error as? NetworkAddressError == .invalidCIDR(cidr: cidr), "Unexpected error thrown: \(error)") - } - } - - @Test - func testComparison() async throws { - let cidr64_1 = try! CIDRAddress("192.168.64.1/24") - let cidr64_1a = try! CIDRAddress("192.168.64.1/24") - #expect(cidr64_1 == cidr64_1a) - #expect(cidr64_1.contains(cidr: cidr64_1a)) - #expect(cidr64_1a.contains(cidr: cidr64_1)) - #expect(cidr64_1.overlaps(cidr: cidr64_1a)) - #expect(cidr64_1a.overlaps(cidr: cidr64_1)) - - let cidr64_2 = try! CIDRAddress("192.168.64.2/24") - #expect(cidr64_1 != cidr64_2) - - let addr63_255 = try! IPv4Address("192.168.63.255") - #expect(!cidr64_1.contains(ipv4: addr63_255)) - - let addr64_0 = try! IPv4Address("192.168.64.0") - #expect(cidr64_1.contains(ipv4: addr64_0)) - - let addr64_255 = try! IPv4Address("192.168.64.255") - #expect(cidr64_1.contains(ipv4: addr64_255)) - - let addr65_0 = try! IPv4Address("192.168.65.0") - #expect(!cidr64_1.contains(ipv4: addr65_0)) - - let cidr64_prefix25 = try! CIDRAddress("192.168.64.0/25") - #expect(cidr64_1.contains(cidr: cidr64_prefix25)) - #expect(!cidr64_prefix25.contains(cidr: cidr64_1)) - #expect(cidr64_1.overlaps(cidr: cidr64_prefix25)) - #expect(cidr64_prefix25.overlaps(cidr: cidr64_1)) - - let cidr64_prefix25a = try! CIDRAddress("192.168.64.128/25") - #expect(cidr64_1.contains(cidr: cidr64_prefix25a)) - #expect(!cidr64_prefix25a.contains(cidr: cidr64_1)) - #expect(cidr64_1.overlaps(cidr: cidr64_prefix25a)) - #expect(cidr64_prefix25a.overlaps(cidr: cidr64_1)) - - let cidr63_prefix24 = try! CIDRAddress("192.168.63.0/24") - #expect(!cidr64_1.contains(cidr: cidr63_prefix24)) - #expect(!cidr63_prefix24.contains(cidr: cidr64_1)) - #expect(!cidr64_1.overlaps(cidr: cidr63_prefix24)) - #expect(!cidr63_prefix24.overlaps(cidr: cidr64_1)) - - let cidr65_prefix24 = try! CIDRAddress("192.168.65.0/24") - #expect(!cidr64_1.contains(cidr: cidr65_prefix24)) - #expect(!cidr65_prefix24.contains(cidr: cidr64_1)) - #expect(!cidr64_1.overlaps(cidr: cidr65_prefix24)) - #expect(!cidr65_prefix24.overlaps(cidr: cidr64_1)) - } - - @Test - func testBiggestSubnet() throws { - let cidr = "1.2.3.4/0" - let subnet = try CIDRAddress(cidr) - #expect(try! IPv4Address("0.0.0.0") == subnet.lower) - #expect(try! IPv4Address("1.2.3.4") == subnet.address) - #expect(try! IPv4Address("255.255.255.255") == subnet.upper) - #expect(0 == subnet.prefixLength) - #expect(cidr == subnet.description) - } - - @Test - func testSmallestSubnet() throws { - let cidr = "255.255.255.255/32" - let subnet = try CIDRAddress(cidr) - #expect(try! IPv4Address("255.255.255.255") == subnet.lower) - #expect(try! IPv4Address("255.255.255.255") == subnet.address) - #expect(try! IPv4Address("255.255.255.255") == subnet.upper) - #expect(32 == subnet.prefixLength) - #expect(cidr == subnet.description) - } - - @Test - func testJustRightSubnet() throws { - let cidr = "192.168.64.10/24" - let subnet = try CIDRAddress(cidr) - #expect(try! IPv4Address("192.168.64.0") == subnet.lower) - #expect(try! IPv4Address("192.168.64.10") == subnet.address) - #expect(try! IPv4Address("192.168.64.255") == subnet.upper) - #expect(24 == subnet.prefixLength) - #expect(cidr == subnet.description) - } - - @Test - func testBiggestRangedSubnet() throws { - let lower = try IPv4Address("127.255.255.255") - let upper = try IPv4Address("128.0.0.0") - let subnet = try CIDRAddress(lower: lower, upper: upper) - #expect(try! IPv4Address("0.0.0.0") == subnet.lower) - #expect(try! IPv4Address("127.255.255.255") == subnet.address) - #expect(try! IPv4Address("255.255.255.255") == subnet.upper) - #expect(0 == subnet.prefixLength) - #expect("\(lower)/0" == subnet.description) - } - - @Test - func testSmallestRangedSubnet() throws { - let lower = try IPv4Address("255.255.255.255") - let subnet = try CIDRAddress(lower: lower, upper: lower) - #expect(lower == subnet.lower) - #expect(lower == subnet.address) - #expect(lower == subnet.upper) - #expect(32 == subnet.prefixLength) - #expect("\(lower)/32" == subnet.description) - } - - @Test - func testJustRightRangedSubnet() throws { - let lower = try IPv4Address("192.168.64.10") - let upper = try IPv4Address("192.168.64.254") - let subnet = try CIDRAddress(lower: lower, upper: upper) - #expect(try! IPv4Address("192.168.64.0") == subnet.lower) - #expect(try! IPv4Address("192.168.64.10") == subnet.address) - #expect(try! IPv4Address("192.168.64.255") == subnet.upper) - #expect(24 == subnet.prefixLength) - #expect("\(lower)/24" == subnet.description) - _ = 16 >> PrefixLength(2) - } - - @Test - func testCoding() async throws { - let text = "200.100.50.25/12" - let expected: CIDRAddress = try CIDRAddress(text) - let data = try JSONEncoder().encode(expected) - let actual = try JSONDecoder().decode(CIDRAddress.self, from: data) - #expect(expected == actual) - } -} diff --git a/Tests/ContainerizationExtrasTests/TestIPAddress.swift b/Tests/ContainerizationExtrasTests/TestIPAddress.swift index bac9fac0..3cb15d6d 100644 --- a/Tests/ContainerizationExtrasTests/TestIPAddress.swift +++ b/Tests/ContainerizationExtrasTests/TestIPAddress.swift @@ -14,77 +14,162 @@ // limitations under the License. //===----------------------------------------------------------------------===// -// - import Foundation import Testing @testable import ContainerizationExtras -final class TestIPv4Address { - @Test - func testUintMask() { - #expect(0xffff_ffff == PrefixLength(0).suffixMask32) - #expect(0x0000_7fff == PrefixLength(17).suffixMask32) - #expect(0x0000_0000 == PrefixLength(32).suffixMask32) - #expect(0x0000_0000 == PrefixLength(33).suffixMask32) - #expect(0x0000_0000 == PrefixLength(0).prefixMask32) - #expect(0xfffe_0000 == PrefixLength(15).prefixMask32) - #expect(0xffff_ffff == PrefixLength(32).prefixMask32) - #expect(0xffff_ffff == PrefixLength(33).prefixMask32) +@Suite("Unified IPAddress Tests") +struct IPAddressTests { + + @Test( + "Parse IPv4 addresses", + arguments: [ + "192.168.1.1", + "127.0.0.1", + "0.0.0.0", + "255.255.255.255", + ] + ) + func testParseIPv4(input: String) throws { + let ip = try IPAddress(input) + #expect(ip.isV4, "Should be IPv4: \(input)") + #expect(!ip.isV6, "Should not be IPv6: \(input)") + #expect(ip.ipv4 != nil, "Should have IPv4 value: \(input)") + #expect(ip.ipv6 == nil, "Should not have IPv6 value: \(input)") } - @Test - func testOctetCountError() { - let ipAddressValue = "192.168.64" - do { - _ = try IPv4Address(ipAddressValue) - #expect(Bool(false), "Expected AddressError.invalidStringAddress to be thrown") - } catch { - #expect( - error as? NetworkAddressError == .invalidStringAddress(address: ipAddressValue), - "Unexpected error thrown: \(error)") + @Test( + "Parse IPv6 addresses", + arguments: [ + "2001:db8::1", + "::1", + "::", + "fe80::1", + "2001:db8:0:0:0:0:0:1", + ] + ) + func testParseIPv6(input: String) throws { + let ip = try IPAddress(input) + #expect(ip.isV6, "Should be IPv6: \(input)") + #expect(!ip.isV4, "Should not be IPv4: \(input)") + #expect(ip.ipv6 != nil, "Should have IPv6 value: \(input)") + #expect(ip.ipv4 == nil, "Should not have IPv4 value: \(input)") + } + + @Test( + "Loopback detection", + arguments: [ + // Loopback addresses + ("127.0.0.1", true, "IPv4 loopback"), + ("127.0.0.255", true, "IPv4 loopback variant"), + ("127.255.255.255", true, "Any 127.x.x.x"), + ("::1", true, "IPv6 loopback"), + // Non-loopback addresses + ("192.168.1.1", false, "Private IPv4"), + ("2001:db8::1", false, "IPv6 documentation"), + ("0.0.0.0", false, "IPv4 unspecified"), + ("::", false, "IPv6 unspecified"), + ] + ) + func testLoopback(input: String, expected: Bool, description: String) throws { + let ip = try IPAddress(input) + #expect(ip.isLoopback == expected, "\(description): \(input) should\(expected ? "" : " not") be loopback") + } + + @Test( + "Multicast detection", + arguments: [ + // Multicast addresses + ("224.0.0.1", true, "IPv4 multicast start"), + ("239.255.255.255", true, "IPv4 multicast end (224.0.0.0/4)"), + ("ff02::1", true, "IPv6 link-local multicast"), + ("ff00::1", true, "IPv6 multicast"), + // Non-multicast addresses + ("192.168.1.1", false, "Private IPv4"), + ("2001:db8::1", false, "IPv6 documentation"), + ("223.255.255.255", false, "Just before multicast range"), + ] + ) + func testMulticast(input: String, expected: Bool, description: String) throws { + let ip = try IPAddress(input) + #expect(ip.isMulticast == expected, "\(description): \(input) should\(expected ? "" : " not") be multicast") + } + + @Test( + "Unspecified detection", + arguments: [ + // Unspecified addresses + ("0.0.0.0", true, "IPv4 unspecified"), + ("::", true, "IPv6 unspecified"), + // Specified addresses + ("0.0.0.1", false, "Not unspecified IPv4"), + ("192.168.1.1", false, "Private IPv4"), + ("::1", false, "IPv6 loopback"), + ("2001:db8::1", false, "IPv6 documentation"), + ] + ) + func testUnspecified(input: String, expected: Bool, description: String) throws { + let ip = try IPAddress(input) + #expect(ip.isUnspecified == expected, "\(description): \(input) should\(expected ? "" : " not") be unspecified") + } + + @Test("Comparable - IPv4 ordering") + func testIPv4Ordering() throws { + let ip1 = try IPv4Address("192.168.1.1") + let ip2 = try IPv4Address("192.168.1.2") + let ip3 = try IPv4Address("192.168.2.1") + + #expect(ip1 < ip2) + #expect(ip2 < ip3) + #expect(ip1 < ip3) + #expect(!(ip2 < ip1)) + } + + @Test("Comparable - IPv6 ordering") + func testIPv6Ordering() throws { + let ip1 = try IPv6Address("2001:db8::1") + let ip2 = try IPv6Address("2001:db8::2") + let ip3 = try IPv6Address("2001:db9::1") + + #expect(ip1 < ip2) + #expect(ip2 < ip3) + #expect(ip1 < ip3) + #expect(!(ip2 < ip1)) + } + + @Test( + "Equality", + arguments: [ + ("192.168.1.1", "192.168.1.1", true, "Same IPv4"), + ("192.168.1.1", "192.168.1.2", false, "Different IPv4"), + ("2001:db8::1", "2001:0db8:0000:0000:0000:0000:0000:0001", true, "Same IPv6, different format"), + ("2001:db8::1", "2001:db8::2", false, "Different IPv6"), + ] + ) + func testEquality(addr1: String, addr2: String, shouldBeEqual: Bool, description: String) throws { + let ip1 = try IPAddress(addr1) + let ip2 = try IPAddress(addr2) + + if shouldBeEqual { + #expect(ip1 == ip2, "\(description): \(addr1) should equal \(addr2)") + } else { + #expect(ip1 != ip2, "\(description): \(addr1) should not equal \(addr2)") } } - @Test - func testOctetInvalidError() { - let ipAddressValue = "192.168.256.255" - do { - _ = try IPv4Address(ipAddressValue) - #expect(Bool(false), "Expected AddressError.invalidStringAddress to be thrown") - } catch { - #expect( - error as? NetworkAddressError == .invalidStringAddress(address: ipAddressValue), - "Unexpected error thrown: \(error)") - } - } + @Test("Hashable") + func testHashable() throws { + var dict: [IPAddress: String] = [:] - @Test - func testAddressFromString() throws { - let ipAddressValue = "192.168.64.25" - let ipAddress = try IPv4Address(ipAddressValue) - #expect(ipAddressValue == ipAddress.description) - #expect(UInt32(0xc0a8_4019) == ipAddress.value) - #expect([192, 168, 64, 25] == ipAddress.networkBytes) - } + let ip1 = try IPAddress("192.168.1.1") + let ip2 = try IPAddress("2001:db8::1") - @Test - func testAddressPrefix() throws { - let ipAddressValue = "172.18.204.85" - let ipAddress = try IPv4Address(ipAddressValue) - #expect(0x0000_0000 == ipAddress.prefix(prefixLength: 0).value) - #expect(0xac12_cc55 == ipAddress.prefix(prefixLength: 32).value) - #expect(0xac12_cc55 == ipAddress.prefix(prefixLength: 33).value) - #expect(0xac10_0000 == ipAddress.prefix(prefixLength: 14).value) - } + dict[ip1] = "IPv4" + dict[ip2] = "IPv6" - @Test - func testCoding() async throws { - let text = "200.100.50.25" - let expected = try IPv4Address(text) - let data = try JSONEncoder().encode(expected) - let actual = try JSONDecoder().decode(IPv4Address.self, from: data) - #expect(expected == actual) + #expect(dict[ip1] == "IPv4") + #expect(dict[ip2] == "IPv6") + #expect(dict.count == 2) } } diff --git a/Tests/ContainerizationExtrasTests/TestIPv4Address.swift b/Tests/ContainerizationExtrasTests/TestIPv4Address.swift new file mode 100644 index 00000000..f8de5020 --- /dev/null +++ b/Tests/ContainerizationExtrasTests/TestIPv4Address.swift @@ -0,0 +1,431 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +@testable import ContainerizationExtras + +@Suite("IPv4Address Tests") +struct IPv4AddressTests { + + // MARK: - Initializer Tests + + @Suite("Initializers") + struct InitializerTests { + + @Test( + "UInt32 initializer", + arguments: [ + (0x7F00_0001, "127.0.0.1"), // localhost + (0x0000_0000, "0.0.0.0"), // zero address + (0xFFFF_FFFF, "255.255.255.255"), // max address + (0xC0A8_0101, "192.168.1.1"), // private network + (0x0808_0808, "8.8.8.8"), // Google DNS + ] + ) + func testUInt32Initializer(inputValue: UInt32, description: String) { + let address = IPv4Address(inputValue) + #expect(address.value == inputValue) + } + + @Test( + "String initializer - valid addresses", + arguments: [ + ("127.0.0.1", 0x7F00_0001), // localhost + ("0.0.0.0", 0x0000_0000), // zero address + ("255.255.255.255", 0xFFFF_FFFF), // broadcast + ("10.0.0.1", 0x0A00_0001), // private network 10.x + ("192.168.1.1", 0xC0A8_0101), // private network 192.168.x + ("172.16.0.1", 0xAC10_0001), // private network 172.16.x + ("1.2.3.4", 0x0102_0304), // single digits + ("192.168.100.254", 0xC0A8_64FE), // mixed digits + ] + ) + func testStringInitializerValid(addressString: String, expectedValue: UInt32) throws { + let address = try IPv4Address(addressString) + #expect(address.value == expectedValue) + } + + @Test( + "String initializer - invalid addresses", + arguments: [ + "", // empty string + "1.2.3", // too short + "1.2.3.4.5", // too many octets + "192.168.1.256", // octet out of range + "192.168.001.1", // leading zeros + "01.2.3.4", // leading zero first octet + " 192.168.1.1", // leading whitespace + "192.168.1.1 ", // trailing whitespace + "192. 168.1.1", // internal whitespace + "192.168.1.a", // invalid character + "192.168.1.-1", // negative number + "192..1.1", // missing octet + ".168.1.1", // missing first octet + "192.168.1.", // missing last octet + "192.168.1.1.extra", // too long + ] + ) + func testStringInitializerInvalid(invalidAddress: String) { + #expect(throws: IPAddressError.self) { + try IPv4Address(invalidAddress) + } + } + } + + // MARK: - Property Tests + + @Suite("Properties") + struct PropertyTests { + + @Test( + "bytes property", + arguments: [ + (UInt32(0x7F00_0001), [UInt8(127), UInt8(0), UInt8(0), UInt8(1)]), // localhost + (UInt32(0x0000_0000), [UInt8(0), UInt8(0), UInt8(0), UInt8(0)]), // zero + (UInt32(0xFFFF_FFFF), [UInt8(255), UInt8(255), UInt8(255), UInt8(255)]), // broadcast + (UInt32(0xC0A8_0101), [UInt8(192), UInt8(168), UInt8(1), UInt8(1)]), // private network + (UInt32(0x1234_5678), [UInt8(0x12), UInt8(0x34), UInt8(0x56), UInt8(0x78)]), // byte order test + ] + ) + func testBytesProperty(inputValue: UInt32, expectedBytes: [UInt8]) { + let address = IPv4Address(inputValue) + #expect(address.bytes == expectedBytes) + } + + @Test( + "description property", + arguments: [ + (0x7F00_0001, "127.0.0.1"), // localhost + (0x0000_0000, "0.0.0.0"), // zero + (0xFFFF_FFFF, "255.255.255.255"), // broadcast + (0xC0A8_0101, "192.168.1.1"), // private network + (0x0102_0304, "1.2.3.4"), // single digits + ] + ) + func testDescriptionProperty(inputValue: UInt32, expectedDescription: String) { + let address = IPv4Address(inputValue) + #expect(address.description == expectedDescription) + } + + @Test( + "round-trip string conversion", + arguments: [ + "0.0.0.0", + "127.0.0.1", + "192.168.1.1", + "10.0.0.1", + "172.16.0.1", + "255.255.255.255", + "1.2.3.4", + "8.8.8.8", + "1.1.1.1", + ] + ) + func testRoundTripStringConversion(addressString: String) throws { + let address = try IPv4Address(addressString) + #expect(address.description == addressString) + } + } + + // MARK: - Protocol Conformance Tests + + @Suite("Protocol Conformances") + struct ProtocolConformanceTests { + + @Test("Equatable conformance") + func testEquatableConformance() { + let addr1 = IPv4Address(0x7F00_0001) + let addr2 = IPv4Address(0x7F00_0001) + let addr3 = IPv4Address(0xC0A8_0101) + + #expect(addr1 == addr2) + #expect(addr1 != addr3) + #expect(addr2 != addr3) + } + + @Test("Hashable conformance") + func testHashableConformance() { + let addr1 = IPv4Address(0x7F00_0001) + let addr2 = IPv4Address(0x7F00_0001) + let addr3 = IPv4Address(0xC0A8_0101) + + // Equal objects should have equal hash values + #expect(addr1.hashValue == addr2.hashValue) + + // Different objects should ideally have different hash values + // (though this is not guaranteed, it's very likely for these values) + #expect(addr1.hashValue != addr3.hashValue) + + // Test that addresses can be used in Sets and Dictionaries + let addressSet: Set = [addr1, addr2, addr3] + #expect(addressSet.count == 2) // addr1 and addr2 are equal + + let addressDict = [addr1: "localhost", addr3: "private"] + #expect(addressDict[addr2] == "localhost") // addr2 equals addr1 + } + + @Test("CustomStringConvertible conformance") + func testCustomStringConvertibleConformance() { + let address = IPv4Address(0x7F00_0001) + let stringRepresentation = String(describing: address) + #expect(stringRepresentation == "127.0.0.1") + } + + @Test("Sendable conformance") + func testSendableConformance() { + // This test verifies that IPv4Address can be safely passed across concurrency boundaries + let address = IPv4Address(0x7F00_0001) + + Task { + let taskAddress = address + #expect(taskAddress.value == 0x7F00_0001) + } + } + } + + // MARK: - Edge Cases and Error Conditions + + @Suite("Edge Cases") + struct EdgeCaseTests { + + @Test( + "boundary values", + arguments: [ + ("0.0.0.0", 0x0000_0000), // minimum + ("255.255.255.255", 0xFFFF_FFFF), // maximum + ("255.0.0.0", 0xFF00_0000), // max first octet + ("0.255.0.0", 0x00FF_0000), // max second octet + ("0.0.255.0", 0x0000_FF00), // max third octet + ("0.0.0.255", 0x0000_00FF), // max fourth octet + ] + ) + func testBoundaryValues(addressString: String, expectedValue: UInt32) throws { + let address = try IPv4Address(addressString) + #expect(address.value == expectedValue) + } + + @Test( + "special addresses", + arguments: [ + "127.0.0.1", // loopback + "255.255.255.255", // broadcast + "0.0.0.0", // network address + "8.8.8.8", // Google DNS + "1.1.1.1", // Cloudflare DNS + ] + ) + func testSpecialAddresses(addressString: String) throws { + let address = try IPv4Address(addressString) + #expect(address.description == addressString) + } + + @Test( + "leading zero validation - invalid", + arguments: [ + "01.0.0.0", + "0.01.0.0", + "0.0.01.0", + "0.0.0.01", + "192.168.001.1", + "010.0.0.1", + "00.0.0.1", + ] + ) + func testLeadingZeroValidationInvalid(invalidAddress: String) { + #expect(throws: IPAddressError.self) { + try IPv4Address(invalidAddress) + } + } + + @Test("leading zero validation - valid single zeros") + func testLeadingZeroValidationValid() { + // Single "0" should be valid + #expect(throws: Never.self) { + try IPv4Address("0.0.0.0") + } + } + + @Test( + "string length validation - too short", + arguments: [ + "", "1", "1.2", "1.2.3", "1.2.3.", + ] + ) + func testStringLengthValidationTooShort(shortString: String) { + #expect(throws: IPAddressError.self) { + try IPv4Address(shortString) + } + } + + @Test( + "string length validation - too long", + arguments: [ + "255.255.255.255.1", + "1234.168.1.1", + "192.1234.1.1", + "192.168.1234.1", + "192.168.1.1234", + ] + ) + func testStringLengthValidationTooLong(longString: String) { + #expect(throws: IPAddressError.self) { + try IPv4Address(longString) + } + } + } + + // MARK: - Performance Tests + + @Suite("Performance") + struct PerformanceTests { + + @Test("parsing performance") + func testParsingPerformance() throws { + let testAddresses = [ + "192.168.1.1", + "10.0.0.1", + "172.16.0.1", + "127.0.0.1", + "8.8.8.8", + "1.1.1.1", + "255.255.255.255", + "0.0.0.0", + ] + + // Warm up + for _ in 0..<100 { + for address in testAddresses { + _ = try IPv4Address(address) + } + } + + // Measure performance + let iterations = 10000 + let startTime = Date() + + for _ in 0.. String + let addressFromUInt32 = IPv4Address(expectedValue) + #expect(addressFromUInt32.description == expectedString) + + // Test String -> UInt32 + let addressFromString = try IPv4Address(expectedString) + #expect(addressFromString.value == expectedValue) + + // Test equality + #expect(addressFromUInt32 == addressFromString) + } + + @Test( + "error message consistency", + arguments: [ + "", + "256.1.1.1", + "1.2.3", + "1.2.3.4.5", + "192.168.001.1", + " 192.168.1.1", + "192.168.1.1 ", + "192.168.1.a", + ] + ) + func testErrorMessageConsistency(invalidInput: String) { + do { + _ = try IPv4Address(invalidInput) + #expect(Bool(false), "Should have thrown for input: \(invalidInput)") + } catch let error as IPAddressError { + #expect(error == IPAddressError.unableToParse) + } catch { + #expect(Bool(false), "Should have thrown IPAddressError, got: \(error)") + } + } + } +} diff --git a/Tests/ContainerizationExtrasTests/TestIPv6Address+Parse.swift b/Tests/ContainerizationExtrasTests/TestIPv6Address+Parse.swift new file mode 100644 index 00000000..05191aef --- /dev/null +++ b/Tests/ContainerizationExtrasTests/TestIPv6Address+Parse.swift @@ -0,0 +1,734 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +@testable import ContainerizationExtras + +@Suite("IPv6Address Parsing Tests") +struct IPv6AddressParseTests { + + // MARK: - Valid Hexadecimal Group Tests + + @Test( + "Parsing valid hexadecimal groups", + arguments: [ + ("0", 0x0), + ("1", 0x1), + ("F", 0xF), + ("FF", 0xFF), + ("FFF", 0xFFF), + ("FFFF", 0xFFFF), + ("1234", 0x1234), + ("abcd", 0xABCD), + ("ABCD", 0xABCD), + ("0000", 0x0000), + ] + ) + func testParseValidHexadecimalGroups(input: String, expectedValue: UInt16) throws { + let utf8 = input.utf8 + let (parsedValue, nextIndex) = try IPv6Address.parseHexadecimal( + from: utf8, + startingAt: utf8.startIndex + ) + + #expect( + parsedValue == expectedValue, + "For input '\(input)': expected \(expectedValue) but got \(parsedValue)" + ) + #expect(nextIndex == input.endIndex, "Parser should consume entire input") + } + + @Test( + "Parsing hexadecimal groups with trailing characters", + arguments: [ + ("FF:1234", 0xFF, ":1234"), + ("1234G", 0x1234, "G"), + ("AB::CD", 0xAB, "::CD"), + ("0Z", 0x0, "Z"), + ] + ) + func testParseHexadecimalGroupWithTrailingCharacters( + input: String, + expectedValue: UInt16, + expectedRemainder: String + ) throws { + let utf8 = input.utf8 + let (parsedValue, nextIndex) = try IPv6Address.parseHexadecimal( + from: utf8, + startingAt: utf8.startIndex + ) + + let remainder = String(input[String.Index(nextIndex, within: input)!...]) + + #expect( + parsedValue == expectedValue, + "For input '\(input)': expected \(expectedValue) but got \(parsedValue)" + ) + #expect( + remainder == expectedRemainder, + "For input '\(input)': expected remainder '\(expectedRemainder)' but got '\(remainder)'" + ) + } + + // MARK: - Error Handling Tests + + @Test( + "Parsing invalid hexadecimal groups should throw", + arguments: [ + "", // Empty string - no hex digits found + "G", // Invalid hex character - no hex digits found + "GGGG", // All invalid hex characters - no hex digits found + ] + ) + func testParseInvalidHexadecimalGroup(invalidInput: String) { + #expect(throws: IPAddressError.self) { + let utf8 = invalidInput.utf8 + _ = try IPv6Address.parseHexadecimal( + from: utf8, + startingAt: utf8.startIndex + ) + } + } + + @Test( + "Parsing hexadecimal groups with overflow behavior", + arguments: [ + ("12345", 0x1234, "5"), // 5 digits -> takes first 4 + ("10000", 0x1000, "0"), // 5 digits -> takes first 4 + ("FFFFF", 0xFFFF, "F"), // 5 digits -> takes first 4 + ("123456789", 0x1234, "56789"), // Many digits -> takes first 4 + ] + ) + func testParseHexadecimalGroupOverflow( + input: String, + expectedValue: UInt16, + expectedRemainder: String + ) throws { + let utf8 = input.utf8 + let (parsedValue, nextIndex) = try IPv6Address.parseHexadecimal( + from: utf8, + startingAt: utf8.startIndex + ) + + let remainder = String(input[String.Index(nextIndex, within: input)!...]) + + #expect( + parsedValue == expectedValue, + "For input '\(input)': expected \(expectedValue) but got \(parsedValue)" + ) + #expect( + remainder == expectedRemainder, + "For input '\(input)': expected remainder '\(expectedRemainder)' but got '\(remainder)'" + ) + } + + @Test("Parsing from middle of string") + func testParseHexadecimalGroupFromMiddle() throws { + let input = "prefix1234suffix" + let utf8 = input.utf8 + let startIndex = utf8.index(utf8.startIndex, offsetBy: 6) // Start at "1234" + + let (parsedValue, nextIndex) = try IPv6Address.parseHexadecimal( + from: utf8, + startingAt: startIndex + ) + + #expect(parsedValue == 0x1234) + + let remainder = String(input[String.Index(nextIndex, within: input)!...]) + #expect(remainder == "suffix") + } + + // MARK: - Performance Tests + + @Test("Performance with maximum length hex groups") + func testParsePerformance() throws { + let testInput = "FFFF" + + // Measure performance of parsing operation + let startTime = CFAbsoluteTimeGetCurrent() + let count = 10000 + for _ in 0.. CD3"), + ("2001:0DB8::", "2001:DB8::", "Can drop leading zeros 0DB8 -> DB8"), + ("0001:0002:0003::", "1:2:3::", "Can drop leading zeros in multiple groups"), + ] + ) + func testRFC4291Section23LeadingZeroRules(form1: String, form2: String, description: String) throws { + let bytes1 = try IPv6Address.parse(form1).bytes + let bytes2 = try IPv6Address.parse(form2).bytes + #expect(bytes1 == bytes2, "\(description)") + } + + @Test( + "RFC 4291 Section 2.3 - cannot drop trailing zeros", + arguments: [ + ("2001:DB8:0:CD30::", "2001:DB8:0:CD3::", "Cannot drop trailing zeros in CD30 -> CD3"), + ("2001:DB80::", "2001:DB8::", "Cannot drop trailing zero DB80 -> DB8"), + ("ABCD:EF00::", "ABCD:EF::", "Cannot drop trailing zeros EF00 -> EF"), + ] + ) + func testRFC4291Section23CannotDropTrailingZeros(full: String, truncated: String, description: String) throws { + let fullBytes = try IPv6Address.parse(full).bytes + let truncatedBytes = try IPv6Address.parse(truncated).bytes + #expect(fullBytes != truncatedBytes, "\(description)") + } + + @Test("RFC 4291 Section 2.3 - Node address and subnet prefix combination") + func testRFC4291Section23NodeAddressSubnetCombination() throws { + // RFC example: node address 2001:0DB8:0:CD30:123:4567:89AB:CDEF + // and its subnet number 2001:0DB8:0:CD30::/60 + // can be abbreviated as 2001:0DB8:0:CD30:123:4567:89AB:CDEF/60 + + let nodeAddress = "2001:0DB8:0:CD30:123:4567:89AB:CDEF" + let subnetPrefix = "2001:0DB8:0:CD30::" + + // Both should parse successfully + #expect(throws: Never.self, "Node address should parse successfully") { + _ = try IPv6Address.parse(nodeAddress) + } + + #expect(throws: Never.self, "Subnet prefix should parse successfully") { + _ = try IPv6Address.parse(subnetPrefix) + } + + // Verify that the subnet prefix is indeed a prefix of the node address + let nodeBytes = try IPv6Address.parse(nodeAddress).bytes + let subnetBytes = try IPv6Address.parse(subnetPrefix).bytes + + // Validate that node address has the subnet as a prefix (60-bit prefix = 7.5 bytes) + let prefixLength = 60 + let hasValidPrefix = nodeBytes.hasPrefix(subnetBytes, upToBits: prefixLength) + + #expect( + hasValidPrefix, + "Node address should have subnet as a \(prefixLength)-bit prefix" + ) + } + + // MARK: - RFC 4291 Section 2.2 Comprehensive Text Representation Tests + + @Test( + "RFC 4291 Section 2.2 - Preferred form with all groups", + arguments: [ + "2001:0db8:0000:0042:0000:8a2e:0370:7334", + "ABCD:EF01:2345:6789:ABCD:EF01:2345:6789", + "0000:0000:0000:0000:0000:0000:0000:0000", + "FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF", + ] + ) + func testRFC4291Section22PreferredForm(testCase: String) throws { + #expect(throws: Never.self, "Should parse preferred form: \(testCase)") { + _ = try IPv6Address.parse(testCase) + } + } + + @Test( + "RFC 4291 Section 2.2 - Leading zero omission in all positions", + arguments: [ + ("2001:0db8:0000:0042:0000:8a2e:0370:7334", "2001:db8:0:42:0:8a2e:370:7334"), + ("0001:0002:0003:0004:0005:0006:0007:0008", "1:2:3:4:5:6:7:8"), + ("0000:0001:0002:0003:0004:0005:0006:0007", "0:1:2:3:4:5:6:7"), + ("1000:0100:0010:0001:1000:0100:0010:0001", "1000:100:10:1:1000:100:10:1"), + ] + ) + func testRFC4291Section22LeadingZeroOmission(full: String, compressed: String) throws { + let fullBytes = try IPv6Address.parse(full).bytes + let compressedBytes = try IPv6Address.parse(compressed).bytes + #expect(fullBytes == compressedBytes, "'\(full)' should equal '\(compressed)'") + } + + @Test( + "RFC 4291 Section 2.2 - Zero compression at beginning", + arguments: [ + ("::", [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + ("::1", [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]), + ("::8a2e:370:7334", [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x8a, 0x2e, 0x03, 0x70, 0x73, 0x34]), + ] + ) + func testRFC4291Section22ZeroCompressionAtBeginning(compressed: String, expected: [UInt8]) throws { + let parsed = try IPv6Address.parse(compressed) + #expect(parsed.bytes == expected, "'\(compressed)' should parse correctly") + } + + @Test( + "RFC 4291 Section 2.2 - Zero compression in middle", + arguments: [ + ("2001:db8::8a2e:370:7334", "2001:db8:0:0:0:8a2e:370:7334"), + ("2001:db8::1", "2001:db8:0:0:0:0:0:1"), + ("fe80::1", "fe80:0:0:0:0:0:0:1"), + ("2001:0db8:0:0::1", "2001:db8:0:0:0:0:0:1"), + ] + ) + func testRFC4291Section22ZeroCompressionInMiddle(compressed: String, full: String) throws { + let compressedBytes = try IPv6Address.parse(compressed).bytes + let fullBytes = try IPv6Address.parse(full).bytes + #expect(compressedBytes == fullBytes, "'\(compressed)' should equal '\(full)'") + } + + @Test( + "RFC 4291 Section 2.2 - Zero compression at end", + arguments: [ + ("2001:db8::", "2001:db8:0:0:0:0:0:0"), + ("2001:db8:0:0:1::", "2001:db8:0:0:1:0:0:0"), + ("fe80::", "fe80:0:0:0:0:0:0:0"), + ("1::", "1:0:0:0:0:0:0:0"), + ] + ) + func testRFC4291Section22ZeroCompressionAtEnd(compressed: String, full: String) throws { + let compressedBytes = try IPv6Address.parse(compressed).bytes + let fullBytes = try IPv6Address.parse(full).bytes + #expect(compressedBytes == fullBytes, "'\(compressed)' should equal '\(full)'") + } + + @Test( + "RFC 4291 Section 2.2 - Multiple :: should fail", + arguments: [ + "2001::db8::1", + "::1::2", + "fe80::1::2::3", + "::1::", + ] + ) + func testRFC4291Section22MultipleDoubleColonsShouldFail(invalid: String) { + #expect(throws: IPAddressError.self, "Multiple '::' should fail: \(invalid)") { + _ = try IPv6Address.parse(invalid) + } + } + + @Test( + "RFC 4291 Section 2.2 - Case insensitivity", + arguments: [ + ("2001:db8::1", "2001:DB8::1", "2001:Db8::1"), + ("dead:beef::cafe", "DEAD:BEEF::CAFE", "DeAd:BeEf::CaFe"), + ("fe80::1", "FE80::1", "Fe80::1"), + ("abcd:ef01:2345:6789::1", "ABCD:EF01:2345:6789::1", "AbCd:Ef01:2345:6789::1"), + ] + ) + func testRFC4291Section22CaseInsensitivity(lower: String, upper: String, mixed: String) throws { + let lowerBytes = try IPv6Address.parse(lower).bytes + let upperBytes = try IPv6Address.parse(upper).bytes + let mixedBytes = try IPv6Address.parse(mixed).bytes + + #expect(lowerBytes == upperBytes, "Case should not matter: '\(lower)' vs '\(upper)'") + #expect(lowerBytes == mixedBytes, "Case should not matter: '\(lower)' vs '\(mixed)'") + } + + @Test("RFC 4291 Section 2.2 - Special addresses") + func testRFC4291Section22SpecialAddresses() throws { + // Unspecified address + let unspecified = try IPv6Address.parse("::") + #expect(unspecified.bytes.allSatisfy { $0 == 0 }, ":: should be all zeros") + + // Loopback address + let loopback = try IPv6Address.parse("::1") + let expectedLoopback: [UInt8] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1] + #expect(loopback.bytes == expectedLoopback, "::1 should be loopback address") + + // IPv4-compatible (deprecated but valid syntax) + let ipv4Compat = try IPv6Address.parse("::c000:0201") + let expectedCompat: [UInt8] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xc0, 0x00, 0x02, 0x01] + #expect(ipv4Compat.bytes == expectedCompat, "::c000:0201 should parse correctly") + } + + @Test( + "RFC 4291 Section 2.2 - Invalid formats should fail", + arguments: [ + "2001:db8", // Too few groups without :: + "2001:db8:1:2:3:4:5:6:7", // Too many groups + "2001:db8:1:2:3:4:5:6:7:8:9", // Too many groups + "2001:db8:::1", // Triple colon + "2001:db8::1::2", // Multiple :: + "gggg::1", // Invalid hex character + "2001:db8:xyz::1", // Invalid hex character + "::ffff:", // Trailing colon + ":2001:db8::1", // Leading single colon + "2001:db8::1:", // Trailing single colon + ] + ) + func testRFC4291Section22InvalidFormatsShouldFail(invalid: String) { + #expect(throws: IPAddressError.self, "Invalid format should fail: \(invalid)") { + _ = try IPv6Address.parse(invalid) + } + } + + @Test("RFC 4291 Section 2.2 - Maximum values") + func testRFC4291Section22MaximumValues() throws { + // All FFs - maximum value + let maxAddress = try IPv6Address.parse("ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff") + #expect(maxAddress.bytes.allSatisfy { $0 == 0xFF }, "All groups should be 0xFFFF") + + // Mix of max and min values + let mixedMax = try IPv6Address.parse("ffff:0:ffff:0:ffff:0:ffff:0") + let expectedMixed: [UInt8] = [0xff, 0xff, 0, 0, 0xff, 0xff, 0, 0, 0xff, 0xff, 0, 0, 0xff, 0xff, 0, 0] + #expect(mixedMax.bytes == expectedMixed, "Should alternate between max and zero") + } + + @Test( + "RFC 4291 Section 2.2 - Single zero groups", + arguments: [ + ("2001:db8:0:1:2:3:4:5", "2001:db8:0:1:2:3:4:5"), // Single 0, no compression needed + ("2001:db8:0:0:1:2:3:4", "2001:db8::1:2:3:4"), // Two zeros can be compressed + ("0:0:0:0:0:0:0:1", "::1"), // All zeros except last + ] + ) + func testRFC4291Section22SingleZeroGroups(withZero: String, withCompression: String) throws { + let zeroBytes = try IPv6Address.parse(withZero).bytes + let compressedBytes = try IPv6Address.parse(withCompression).bytes + #expect(zeroBytes == compressedBytes, "'\(withZero)' should equal '\(withCompression)'") + } + + @Test("RFC 4291 Section 2.2 - Boundary conditions") + func testRFC4291Section22BoundaryConditions() throws { + // Single non-zero value in each position + for position in 0..<8 { + var groups = [String](repeating: "0", count: 8) + groups[position] = "1" + let address = groups.joined(separator: ":") + + #expect(throws: Never.self, "Single non-zero at position \(position) should parse") { + _ = try IPv6Address.parse(address) + } + } + + // Verify the bytes are correct + let firstPosition = try IPv6Address.parse("1:0:0:0:0:0:0:0") + #expect(firstPosition.bytes[0] == 0 && firstPosition.bytes[1] == 1, "First group should be 0x0001") + + let lastPosition = try IPv6Address.parse("0:0:0:0:0:0:0:1") + #expect(lastPosition.bytes[14] == 0 && lastPosition.bytes[15] == 1, "Last group should be 0x0001") + } + + @Test( + "RFC 4291 Section 2.2 - Hex digit limits", + arguments: [ + ("1:2:3:4:5:6:7:8", "1-digit groups"), + ("12:34:56:78:9a:bc:de:f0", "2-digit groups"), + ("123:456:789:abc:def:123:456:789", "3-digit groups"), + ("1234:5678:9abc:def0:1234:5678:9abc:def0", "4-digit groups"), + ("1:12:123:1234:1:12:123:1234", "Mixed digit counts"), + ] + ) + func testRFC4291Section22HexDigitLimits(address: String, description: String) throws { + #expect(throws: Never.self, "Should parse \(description): \(address)") { + _ = try IPv6Address.parse(address) + } + } + + @Test("RFC 4291 Section 2.2 - Equivalence of different representations") + func testRFC4291Section22EquivalenceOfRepresentations() throws { + let equivalentGroups: [[String]] = [ + // Same address, different representations + [ + "2001:0db8:0000:0000:0000:0000:0000:0001", + "2001:db8:0:0:0:0:0:1", + "2001:db8::1", + "2001:0DB8::1", + "2001:0DB8:0000:0000:0000:0000:0000:0001", + ], + [ + "fe80:0000:0000:0000:0000:0000:0000:0001", + "fe80::1", + "FE80::1", + "fe80:0:0:0:0:0:0:1", + ], + [ + "0000:0000:0000:0000:0000:0000:0000:0000", + "::", + "0:0:0:0:0:0:0:0", + ], + ] + + for group in equivalentGroups { + let bytesArray = try group.map { try IPv6Address.parse($0).bytes } + let firstBytes = bytesArray[0] + + for (index, bytes) in bytesArray.enumerated() { + #expect(bytes == firstBytes, "All forms should be equivalent: \(group[index])") + } + } + } + + @Test( + "RFC 4291 Section 2.2 - Zero compression selection (longest run)", + arguments: [ + ("2001:db8:0:0:1:0:0:1", "Two runs of 2 zeros each"), + ("2001:0:0:0:db8:0:0:1", "Run of 3 and run of 2"), + ("2001:db8:0:0:0:0:1:1", "Run of 4 zeros in middle"), + ] + ) + func testRFC4291Section22ZeroCompressionLongestRun(address: String, description: String) throws { + #expect(throws: Never.self, "Should parse \(description): \(address)") { + _ = try IPv6Address.parse(address) + } + } + + @Test( + "RFC 4291 Section 2.2 - Edge case with :: at different positions", + arguments: [ + ("::1", "0:0:0:0:0:0:0:1"), + ("1::", "1:0:0:0:0:0:0:0"), + ("1::1", "1:0:0:0:0:0:0:1"), + ("1:2::1", "1:2:0:0:0:0:0:1"), + ("1::2:3", "1:0:0:0:0:0:2:3"), + ("1:2:3::4:5:6", "1:2:3:0:0:4:5:6"), + ] + ) + func testRFC4291Section22DoubleColonAtDifferentPositions(compressed: String, expanded: String) throws { + let compressedBytes = try IPv6Address.parse(compressed).bytes + let expandedBytes = try IPv6Address.parse(expanded).bytes + #expect(compressedBytes == expandedBytes, "'\(compressed)' should equal '\(expanded)'") + } + + // MARK: - RFC 4291 Section 2.2 IPv4 Mixed Notation Tests + + @Test( + "RFC 4291 Section 2.2 - IPv4 mixed notation basic formats", + arguments: [ + // RFC 4291 examples + ("0:0:0:0:0:0:13.1.68.3", [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13, 1, 68, 3]), + ("::13.1.68.3", [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13, 1, 68, 3]), + + // IPv4-mapped IPv6 address (::ffff:x.x.x.x) + ("::ffff:129.144.52.38", [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 129, 144, 52, 38]), + ("0:0:0:0:0:ffff:129.144.52.38", [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 129, 144, 52, 38]), + + // IPv4-compatible IPv6 address (deprecated but valid syntax) + ("::192.168.1.1", [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 192, 168, 1, 1]), + ("::0.0.0.1", [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]), + + // Various valid positions + ("2001:db8::192.0.2.1", [0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 192, 0, 2, 1]), + ("fe80::192.168.1.1", [0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 192, 168, 1, 1]), + ] + ) + func testRFC4291Section22IPv4MixedNotation(address: String, expected: [UInt8]) throws { + let parsed = try IPv6Address.parse(address) + #expect(parsed.bytes == expected, "IPv4 mixed notation '\(address)' should parse correctly") + } + + @Test( + "RFC 4291 Section 2.2 - IPv4 mixed notation with full IPv6 prefix", + arguments: [ + ("0:0:0:0:0:0:192.168.1.1", [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 192, 168, 1, 1]), + ("2001:db8:0:0:0:0:192.0.2.1", [0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 192, 0, 2, 1]), + ("64:ff9b::192.0.2.33", [0, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0, 192, 0, 2, 33]), + ] + ) + func testRFC4291Section22IPv4MixedNotationFullPrefix(address: String, expected: [UInt8]) throws { + let parsed = try IPv6Address.parse(address) + #expect(parsed.bytes == expected, "Full prefix with IPv4 '\(address)' should parse correctly") + } + + @Test("RFC 4291 Section 2.2 - IPv4 mixed notation edge cases") + func testRFC4291Section22IPv4MixedNotationEdgeCases() throws { + // Maximum values + let maxIPv4 = try IPv6Address.parse("::255.255.255.255") + let expectedMax: [UInt8] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255] + #expect(maxIPv4.bytes == expectedMax, "Maximum IPv4 values should work") + + // Minimum values + let minIPv4 = try IPv6Address.parse("::0.0.0.0") + let expectedMin: [UInt8] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + #expect(minIPv4.bytes == expectedMin, "Minimum IPv4 values should work") + + // With non-zero prefix + let withPrefix = try IPv6Address.parse("2001:db8:85a3::8a2e:255.255.255.255") + let expectedPrefix: [UInt8] = [0x20, 0x01, 0x0d, 0xb8, 0x85, 0xa3, 0, 0, 0, 0, 0x8a, 0x2e, 255, 255, 255, 255] + #expect(withPrefix.bytes == expectedPrefix, "IPv4 with hex prefix should work") + } + + @Test( + "RFC 4291 Section 2.2 - IPv4 mixed notation invalid formats", + arguments: [ + "::192.168.1", // Incomplete IPv4 + "::192.168.1.1.1", // Too many IPv4 octets + "::256.1.1.1", // IPv4 octet out of range + "::192.168.001.1", // Leading zeros in IPv4 + "::192.168.-1.1", // Negative in IPv4 + "::192.168.1.a", // Non-numeric in IPv4 + "2001:db8:1:2:3:4:5:192.168.1.1", // Too many hex groups before IPv4 + "::192.168.1.1:1234", // Extra hex after IPv4 + ] + ) + func testRFC4291Section22IPv4MixedNotationInvalid(invalid: String) { + #expect(throws: IPAddressError.self, "Invalid IPv4 mixed notation should fail: \(invalid)") { + _ = try IPv6Address.parse(invalid) + } + } + + @Test("RFC 4291 Section 2.2 - IPv4 mixed notation equivalence") + func testRFC4291Section22IPv4MixedNotationEquivalence() throws { + // These should all represent the same address + let equivalentForms = [ + "0:0:0:0:0:0:192.0.2.1", + "::192.0.2.1", + "::c000:201", // Same as 192.0.2.1 in hex + ] + + let bytesArray = try equivalentForms.map { try IPv6Address.parse($0).bytes } + let firstBytes = bytesArray[0] + + for (index, bytes) in bytesArray.enumerated() { + #expect(bytes == firstBytes, "All forms should be equivalent: \(equivalentForms[index])") + } + } + + @Test( + "RFC 4291 Section 2.2 - IPv4-mapped IPv6 addresses", + arguments: [ + ("127.0.0.1", "::ffff:127.0.0.1"), + ("192.168.1.1", "::ffff:192.168.1.1"), + ("8.8.8.8", "::ffff:8.8.8.8"), + ("0.0.0.0", "::ffff:0.0.0.0"), + ("255.255.255.255", "::ffff:255.255.255.255"), + ] + ) + func testRFC4291Section22IPv4MappedAddresses(ipv4: String, ipv6: String) throws { + let parsed = try IPv6Address.parse(ipv6) + + // First 10 bytes should be 0 + #expect(parsed.bytes[0..<10].allSatisfy { $0 == 0 }, "First 10 bytes should be zero") + + // Next 2 bytes should be 0xff + #expect(parsed.bytes[10] == 0xff && parsed.bytes[11] == 0xff, "Bytes 10-11 should be 0xffff") + + // Last 4 bytes should match IPv4 address + let ipv4Parsed = try IPv4Address.parse(ipv4) + let ipv4Bytes = [ + UInt8((ipv4Parsed >> 24) & 0xFF), + UInt8((ipv4Parsed >> 16) & 0xFF), + UInt8((ipv4Parsed >> 8) & 0xFF), + UInt8(ipv4Parsed & 0xFF), + ] + #expect(Array(parsed.bytes[12..<16]) == ipv4Bytes, "Last 4 bytes should match IPv4") + } + + @Test( + "RFC 4291 Section 2.2 - IPv4 mixed notation with zone identifier", + arguments: [ + "::ffff:192.168.1.1%eth0", + "fe80::192.168.1.1%lo0", + ] + ) + func testRFC4291Section22IPv4MixedNotationWithZone(testCase: String) throws { + #expect(throws: Never.self, "IPv4 mixed notation with zone should parse: \(testCase)") { + let parsed = try IPv6Address.parse(testCase) + #expect(parsed.zone != nil, "Zone should be preserved") + } + } +} + +// MARK: - Array Extension for Prefix Validation + +extension Array where Element == UInt8 { + /// Checks if this byte array has another array as a prefix up to the specified number of bits + /// - Parameters: + /// - prefix: The potential prefix array + /// - bits: Number of bits to compare (0-128 for IPv6) + /// - Returns: true if the prefix matches for the specified number of bits + func hasPrefix(_ prefix: [UInt8], upToBits bits: Int) -> Bool { + guard self.count >= 16 && prefix.count >= 16 else { return false } + guard bits >= 0 && bits <= 128 else { return false } + + let fullBytes = bits / 8 + let remainingBits = bits % 8 + + // Compare full bytes + for i in 0.. 0 && fullBytes < 16 { + let shiftAmount = 8 - remainingBits + let mask: UInt8 = shiftAmount >= 8 ? 0 : (0xFF << shiftAmount) + return (self[fullBytes] & mask) == (prefix[fullBytes] & mask) + } + + return true + } +} diff --git a/Tests/ContainerizationExtrasTests/TestIPv6Address.swift b/Tests/ContainerizationExtrasTests/TestIPv6Address.swift new file mode 100644 index 00000000..bd25371c --- /dev/null +++ b/Tests/ContainerizationExtrasTests/TestIPv6Address.swift @@ -0,0 +1,243 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +@testable import ContainerizationExtras + +@Suite("IPv6 Address Tests") +struct IPv6AddressTests { + + // MARK: - String Representation Tests (RFC 5952) + + @Test( + "IPv6 address string representation - RFC 5952", + arguments: [ + // Zero compression algorithm tests + ("0:0:0:0:0:0:0:0", "::", "all zeros - unspecified address"), + ("0:0:0:0:0:0:0:1", "::1", "leading zeros - loopback"), + ("2001:0db8:0:0:0:0:0:0", "2001:db8::", "trailing zeros"), + ("2001:0:0:0:0:0:0db8:1", "2001::db8:1", "middle zeros - longest run"), + ("2001:0:0:0:0:0db8:0:1", "2001::db8:0:1", "multiple runs - prefer longest"), + ("2001:0:0db8:0:1:0:0:2", "2001:0:db8:0:1::2", "tie-breaking - first occurrence wins"), + ("2001:0:0db8:1:2:3:4:5", "2001:0:db8:1:2:3:4:5", "single zero - no compression (min 2 required)"), + ("2001:0db8:0:0:1:2:3:4", "2001:db8::1:2:3:4", "exactly 2 zeros - minimum for compression"), + ("0:0:0:0:1234:0:0:0", "::1234:0:0:0", "tie-breaking - first run wins (4 vs 3 zeros)"), + + // RFC 5952 formatting rules + ( + "ABCD:EF01:2345:6789:9ABC:DEF0:1122:3344", "abcd:ef01:2345:6789:9abc:def0:1122:3344", + "lowercase hex (Section 4.3)" + ), + ("0001:0002:0003:0004:0005:0006:0007:0008", "1:2:3:4:5:6:7:8", "no leading zeros (Section 4.1)"), + + // Edge cases + ("2001:a:a:a:0:0db8:0:1", "2001:a:a:a:0:db8:0:1", "only single zeros scattered - no compression"), + ] + ) + func testIPv6StringRepresentation(input: String, expected: String, description: String) throws { + let addr = try IPv6Address.parse(input) + #expect( + addr.description == expected, + "Expected '\(expected)' but got '\(addr.description)' for input: '\(input)' (\(description))" + ) + } + + // MARK: - isUnspecified Tests + + @Test( + "isUnspecified - RFC 4291 Section 2.5.2", + arguments: [ + ("::", true, "unspecified address (short form)"), + ("0:0:0:0:0:0:0:0", true, "unspecified address (full form)"), + (IPv6Address.unspecified.description, true, "unspecified"), + ("::1", false, "loopback"), + ("0:0:0:0:0:0:0:1", false, "loopback (full form)"), + ("fe80::1", false, "link-local"), + ("2001:db8::1", false, "global unicast"), + ] + ) + func testIsUnspecified(address: String, expected: Bool, description: String) throws { + let addr = try IPv6Address.parse(address) + #expect( + addr.isUnspecified == expected, + "Address \(address) (\(description)) should\(expected ? "" : " not") be unspecified" + ) + } + + // MARK: - isLoopback Tests + + @Test( + "isLoopback - RFC 4291 Section 2.5.3", + arguments: [ + ("::1", true, "loopback (short form)"), + ("0:0:0:0:0:0:0:1", true, "loopback (full form)"), + (IPv6Address.loopback.description, true, "loopback var"), + ("::", false, "unspecified"), + ("::2", false, "not loopback"), + ("0:0:0:0:0:0:0:2", false, "not loopback (full form)"), + ("fe80::1", false, "link-local"), + ("2001:db8::1", false, "global unicast"), + ] + ) + func testIsLoopback(addressString: String, expected: Bool, description: String) throws { + let addr = try IPv6Address.parse(addressString) + #expect( + addr.isLoopback == expected, + "Address \(addressString) (\(description)) should\(expected ? "" : " not") be loopback" + ) + } + + // MARK: - isMulticast Tests + + @Test( + "isMulticast - RFC 4291 Section 2.7", + arguments: [ + // Positive cases - all multicast addresses start with ff + ("ff00::1", true, "Reserved multicast"), + ("ff01::1", true, "Interface-local multicast"), + ("ff02::1", true, "Link-local multicast (all nodes)"), + ("ff02::2", true, "Link-local multicast (all routers)"), + ("ff05::1", true, "Site-local multicast"), + ("ff0e::1", true, "Global multicast"), + ("ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", true, "Max multicast"), + // Negative cases + ("::", false, "unspecified"), + ("::1", false, "loopback"), + ("fe80::1", false, "link-local unicast"), + ("2001:db8::1", false, "global unicast"), + ("fd00::1", false, "unique local"), + ] + ) + func testIsMulticast(addressString: String, expected: Bool, description: String) throws { + let addr = try IPv6Address.parse(addressString) + #expect( + addr.isMulticast == expected, + "Address \(addressString) (\(description)) should\(expected ? "" : " not") be multicast" + ) + } + + // MARK: - isLinkLocal Tests + + @Test( + "isLinkLocal - RFC 4291 Section 2.5.6", + arguments: [ + // Positive cases - fe80::/10 + ("fe80::1", true, "basic link-local"), + ("fe80::dead:beef", true, "link-local with hex"), + ("fe80:0:0:0:0:0:0:1", true, "link-local (full form)"), + ("fe80::1234:5678:90ab:cdef", true, "link-local with interface ID"), + ("febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff", true, "Last address in fe80::/10"), + // Negative cases + ("::", false, "unspecified"), + ("::1", false, "loopback"), + ("fec0::1", false, "site-local (deprecated)"), + ("ff02::1", false, "multicast"), + ("2001:db8::1", false, "global unicast"), + ("fd00::1", false, "unique local"), + ] + ) + func testIsLinkLocal(addressString: String, expected: Bool, description: String) throws { + let addr = try IPv6Address.parse(addressString) + #expect( + addr.isLinkLocal == expected, + "Address \(addressString) (\(description)) should\(expected ? "" : " not") be link-local" + ) + } + + // MARK: - isUniqueLocal Tests + + @Test( + "isUniqueLocal - RFC 4193", + arguments: [ + // Positive cases - fc00::/7 (fc00::/8 and fd00::/8) + ("fc00::1", true, "fc00 unique local"), + ("fc00:dead:beef::1", true, "fc00 with hex"), + ("fd00::1", true, "fd00 unique local"), + ("fd12:3456:789a::1", true, "fd00 with prefix"), + ("fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", true, "max unique local"), + // Negative cases + ("::", false, "unspecified"), + ("::1", false, "loopback"), + ("fe80::1", false, "link-local"), + ("ff02::1", false, "multicast"), + ("2001:db8::1", false, "global unicast"), + ] + ) + func testIsUniqueLocal(addressString: String, expected: Bool, description: String) throws { + let addr = try IPv6Address.parse(addressString) + #expect( + addr.isUniqueLocal == expected, + "Address \(addressString) (\(description)) should\(expected ? "" : " not") be unique local" + ) + } + + // MARK: - isGlobalUnicast Tests + + @Test( + "isGlobalUnicast - RFC 4291 Section 2.5.4", + arguments: [ + // Positive cases - routable on the global internet + ("2001:db8::1", true, "Documentation (but still global unicast format)"), + ("2001:4860:4860::8888", true, "Google DNS"), + ("2606:4700:4700::1111", true, "Cloudflare DNS"), + ("2001:500::1", true, "Root DNS server"), + ("2a00:1450:4001::1", true, "Google"), + // Negative cases - special addresses + ("::", false, "unspecified"), + ("::1", false, "loopback"), + ("fe80::1", false, "link-local"), + ("ff02::1", false, "multicast"), + ("fc00::1", false, "unique local"), + ("fd00::1", false, "unique local"), + ] + ) + func testIsGlobalUnicast(addressString: String, expected: Bool, description: String) throws { + let addr = try IPv6Address.parse(addressString) + #expect( + addr.isGlobalUnicast == expected, + "Address \(addressString) (\(description)) should\(expected ? "" : " not") be global unicast" + ) + } + + // MARK: - isDocumentation Tests + + @Test( + "isDocumentation - RFC 3849", + arguments: [ + // Positive cases - 2001:db8::/32 + ("2001:db8::1", true, "basic documentation address"), + ("2001:db8::", true, "documentation prefix"), + ("2001:db8:0:0:0:0:0:1", true, "documentation (full form)"), + ("2001:db8:1234:5678:90ab:cdef:1234:5678", true, "documentation with all fields"), + ("2001:db8:ffff:ffff:ffff:ffff:ffff:ffff", true, "max documentation address"), + // Negative cases + ("2001:db7::1", false, "Just before documentation range"), + ("2001:db9::1", false, "Just after documentation range"), + ("2001:4860:4860::8888", false, "Google DNS"), + ("::", false, "unspecified"), + ("::1", false, "loopback"), + ] + ) + func testIsDocumentation(addressString: String, expected: Bool, description: String) throws { + let addr = try IPv6Address.parse(addressString) + #expect( + addr.isDocumentation == expected, + "Address \(addressString) (\(description)) should\(expected ? "" : " not") be documentation" + ) + } +} diff --git a/Tests/ContainerizationExtrasTests/TestIPv6IPv4Parsing.swift b/Tests/ContainerizationExtrasTests/TestIPv6IPv4Parsing.swift new file mode 100644 index 00000000..38ad4b6e --- /dev/null +++ b/Tests/ContainerizationExtrasTests/TestIPv6IPv4Parsing.swift @@ -0,0 +1,131 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +@testable import ContainerizationExtras + +@Suite("IPv6 Mixed IPv4 Notation Tests") +struct IPv6IPv4ParsingTests { + + @Test( + "Extract IPv4 suffix from various IPv6 formats", + arguments: [ + ("::192.168.1.1", "::", [UInt8(192), UInt8(168), UInt8(1), UInt8(1)]), + ("::ffff:192.0.2.1", "::ffff", [UInt8(192), UInt8(0), UInt8(2), UInt8(1)]), + ("fe80::192.168.1.1", "fe80::", [UInt8(192), UInt8(168), UInt8(1), UInt8(1)]), + ("2001:db8::192.0.2.1", "2001:db8::", [UInt8(192), UInt8(0), UInt8(2), UInt8(1)]), + ("0:0:0:0:0:0:192.168.1.1", "0:0:0:0:0:0", [UInt8(192), UInt8(168), UInt8(1), UInt8(1)]), + ] + ) + func testIPv4SuffixExtraction(input: String, expectedIPv6: String, expectedIPv4: [UInt8]) throws { + let result = try #require(try IPv6Address.extractIPv4Suffix(from: input)) + #expect(result.0 == expectedIPv6) + #expect(result.1 == expectedIPv4) + } + + @Test( + "No IPv4 suffix for pure IPv6 addresses", + arguments: [ + "2001:db8::1", + "fe80::1", + "::", + "::1", + ] + ) + func testPureIPv6ReturnsNil(address: String) throws { + #expect(try IPv6Address.extractIPv4Suffix(from: address) == nil) + } + + @Test( + "Invalid IPv4 suffix throws error", + arguments: [ + "::256.1.1.1", + "::192.168.1", + "::192.168.001.1", + ] + ) + func testInvalidIPv4Throws(invalid: String) { + #expect(throws: IPAddressError.self) { + _ = try IPv6Address.extractIPv4Suffix(from: invalid) + } + } + + @Test( + "IPv4 bytes always at positions 12-15", + arguments: [ + "::192.168.1.1", + "::ffff:127.0.0.1", + "fe80::10.0.0.1", + ] + ) + func testIPv4BytePlacement(address: String) throws { + let parsed = try IPv6Address.parse(address) + let ipv4String = String(address.split(separator: ":").last!) + let ipv4 = try IPv4Address.parse(ipv4String) + + #expect(parsed.bytes[12] == UInt8((ipv4 >> 24) & 0xFF)) + #expect(parsed.bytes[13] == UInt8((ipv4 >> 16) & 0xFF)) + #expect(parsed.bytes[14] == UInt8((ipv4 >> 8) & 0xFF)) + #expect(parsed.bytes[15] == UInt8(ipv4 & 0xFF)) + } + + @Test( + "Unspecified address with IPv4 suffix", + arguments: [ + ("::192.168.1.1", [UInt8(192), UInt8(168), UInt8(1), UInt8(1)]), + ("::0.0.0.1", [UInt8(0), UInt8(0), UInt8(0), UInt8(1)]), + ("::255.255.255.255", [UInt8(255), UInt8(255), UInt8(255), UInt8(255)]), + ] + ) + func testUnspecifiedWithIPv4(address: String, ipv4: [UInt8]) throws { + let parsed = try IPv6Address.parse(address) + #expect(parsed.bytes[0..<12].allSatisfy { $0 == 0 }) + #expect(Array(parsed.bytes[12..<16]) == ipv4) + } + + @Test("IPv4 with zone identifier") + func testIPv4WithZone() throws { + let parsed = try IPv6Address.parse("::192.168.1.1%lo0") + + #expect(parsed.zone == "lo0") + #expect(Array(parsed.bytes[12..<16]) == [192, 168, 1, 1]) + } + + @Test( + "IPv4-mapped addresses (::ffff:x.x.x.x)", + arguments: [ + "::ffff:127.0.0.1", + "::ffff:192.168.1.1", + ] + ) + func testIPv4MappedAddresses(address: String) throws { + let parsed = try IPv6Address.parse(address) + + #expect(parsed.bytes[0..<10].allSatisfy { $0 == 0 }) + #expect(parsed.bytes[10] == 0xff && parsed.bytes[11] == 0xff) + } + + @Test("Complex ellipsis with IPv4") + func testComplexEllipsisWithIPv4() throws { + let address = "2001:db8:85a3::8a2e:192.168.1.1" + let parsed = try IPv6Address.parse(address) + + #expect(parsed.bytes[10] == 0x8a && parsed.bytes[11] == 0x2e) + #expect(Array(parsed.bytes[12..<16]) == [192, 168, 1, 1]) + } +} diff --git a/Tests/ContainerizationExtrasTests/TestNetworkAddress+Allocator.swift b/Tests/ContainerizationExtrasTests/TestNetworkAddress+Allocator.swift index 5d8c09c9..e9025b31 100644 --- a/Tests/ContainerizationExtrasTests/TestNetworkAddress+Allocator.swift +++ b/Tests/ContainerizationExtrasTests/TestNetworkAddress+Allocator.swift @@ -94,7 +94,7 @@ final class TestAddressAllocators { @Test func testipv4AddressAllocator() throws { var allocations = Set() - let lower = try IPv4Address("192.168.64.1").prefix(prefixLength: 24).value + let lower = try IPv4Address("192.168.64.1").value & Prefix(length: 24)!.prefixMask32 let allocator = try IPv4Address.allocator( lower: lower, size: 3) allocations.insert(try allocator.allocate().value) @@ -117,7 +117,7 @@ final class TestAddressAllocators { @Test func testHighestIPv4AddressAllocator() throws { var allocations = Set() - let lower = try IPv4Address("255.255.255.255").prefix(prefixLength: 32).value + let lower = try IPv4Address("255.255.255.255").value & Prefix(length: 32)!.prefixMask32 let allocator = try IPv4Address.allocator( lower: lower, size: 1) allocations.insert(try allocator.allocate().value) diff --git a/Tests/ContainerizationExtrasTests/TestPrefix.swift b/Tests/ContainerizationExtrasTests/TestPrefix.swift new file mode 100644 index 00000000..ac256d58 --- /dev/null +++ b/Tests/ContainerizationExtrasTests/TestPrefix.swift @@ -0,0 +1,155 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Testing + +@testable import ContainerizationExtras + +struct TestPrefix { + + // MARK: - IPv4 Mask Tests + + struct IPv4Mask { + let length: UInt8 + let expectedPrefix: UInt32 + let expectedSuffix: UInt32 + } + + @Test(arguments: [ + IPv4Mask( + length: 0, + expectedPrefix: 0x0000_0000, + expectedSuffix: 0xFFFF_FFFF + ), + IPv4Mask( + length: 8, + expectedPrefix: 0xFF00_0000, + expectedSuffix: 0x00FF_FFFF + ), + IPv4Mask( + length: 16, + expectedPrefix: 0xFFFF_0000, + expectedSuffix: 0x0000_FFFF + ), + IPv4Mask( + length: 24, + expectedPrefix: 0xFFFF_FF00, + expectedSuffix: 0x0000_00FF + ), + IPv4Mask( + length: 32, + expectedPrefix: 0xFFFF_FFFF, + expectedSuffix: 0x0000_0000 + ), + ]) + func testIPv4Masks(testCase: IPv4Mask) { + let prefix = Prefix(length: testCase.length)! + #expect(prefix.prefixMask32 == testCase.expectedPrefix) + #expect(prefix.suffixMask32 == testCase.expectedSuffix) + } + + @Test func testMasksAreInverses32() { + for length in 0...32 { + let prefix = Prefix(length: UInt8(length))! + #expect(~prefix.prefixMask32 == prefix.suffixMask32) + } + } + + // MARK: - IPv6 Mask Tests + + struct IPv6Mask { + let length: UInt8 + let expectedPrefix: UInt128 + let expectedSuffix: UInt128 + } + + @Test func testIPv6Masks() { + let cases = [ + IPv6Mask( + length: 0, + expectedPrefix: UInt128(0), + expectedSuffix: UInt128.max + ), + IPv6Mask( + length: 64, + expectedPrefix: 0xFFFF_FFFF_FFFF_FFFF_0000_0000_0000_0000, + expectedSuffix: 0x0000_0000_0000_0000_FFFF_FFFF_FFFF_FFFF + ), + IPv6Mask( + length: 128, + expectedPrefix: UInt128.max, + expectedSuffix: UInt128(0) + ), + ] + for testCase in cases { + let prefix = Prefix(length: testCase.length)! + #expect(prefix.prefixMask128 == testCase.expectedPrefix) + #expect(prefix.suffixMask128 == testCase.expectedSuffix) + } + } + + @Test func testMasksAreInverses128() { + for length in stride(from: 0, through: 128, by: 8) { + let prefix = Prefix(length: UInt8(length))! + #expect(~prefix.prefixMask128 == prefix.suffixMask128) + } + } + + // MARK: - Description Tests + + @Test func testDescription() { + #expect(Prefix(length: 24)!.description == "24") + #expect(Prefix(length: 0)!.description == "0") + #expect(Prefix(length: 128)!.description == "128") + } + + // MARK: - Validation Tests + + @Test func testValidationRejectsInvalidLengths() { + // Valid ranges + #expect(Prefix(length: 0) != nil) + #expect(Prefix(length: 32) != nil) + #expect(Prefix(length: 128) != nil) + + // Invalid ranges + #expect(Prefix(length: 129) == nil) + #expect(Prefix(length: 200) == nil) + #expect(Prefix(length: 255) == nil) + } + + @Test func testIPv4SpecificValidation() { + // Valid IPv4 prefixes + #expect(Prefix.ipv4(0) != nil) + #expect(Prefix.ipv4(16) != nil) + #expect(Prefix.ipv4(32) != nil) + + // Invalid IPv4 prefixes + #expect(Prefix.ipv4(33) == nil) + #expect(Prefix.ipv4(64) == nil) + #expect(Prefix.ipv4(128) == nil) + } + + @Test func testIPv6SpecificValidation() { + // Valid IPv6 prefixes + #expect(Prefix.ipv6(0) != nil) + #expect(Prefix.ipv6(64) != nil) + #expect(Prefix.ipv6(128) != nil) + + // Invalid IPv6 prefixes + #expect(Prefix.ipv6(129) == nil) + #expect(Prefix.ipv6(255) == nil) + } +} diff --git a/Tests/ContainerizationNetlinkTests/NetlinkSessionTest.swift b/Tests/ContainerizationNetlinkTests/NetlinkSessionTest.swift index 6ad51695..616b929b 100644 --- a/Tests/ContainerizationNetlinkTests/NetlinkSessionTest.swift +++ b/Tests/ContainerizationNetlinkTests/NetlinkSessionTest.swift @@ -14,6 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerizationExtras import ContainerizationOS import Testing @@ -274,7 +275,7 @@ struct NetlinkSessionTest { ) let session = NetlinkSession(socket: mockSocket) - try session.addressAdd(interface: "eth0", address: "192.168.64.250/24") + try session.addressAdd(interface: "eth0", ipv4Address: try CIDRv4("192.168.64.250/24")) #expect(mockSocket.requests.count == 2) #expect(mockSocket.responseIndex == 2) @@ -321,8 +322,8 @@ struct NetlinkSessionTest { let session = NetlinkSession(socket: mockSocket) try session.routeAdd( interface: "eth0", - destinationAddress: "192.168.64.0/24", - srcAddr: "192.168.64.3" + dstIpv4Addr: try CIDRv4("192.168.64.0/24"), + srcIpv4Addr: try IPv4Address("192.168.64.3") ) #expect(mockSocket.requests.count == 2) diff --git a/vminitd/Makefile b/vminitd/Makefile index 3891b3ff..a755b92a 100644 --- a/vminitd/Makefile +++ b/vminitd/Makefile @@ -17,8 +17,8 @@ WARNINGS_AS_ERRORS ?= true SWIFT_WARNING_CONFIG := $(if $(filter-out false,$(WARNINGS_AS_ERRORS)),-Xswiftc -warnings-as-errors) SWIFT_CONFIGURATION := --swift-sdk aarch64-swift-linux-musl $(SWIFT_WARNING_CONFIG) -SWIFT_VERSION := 6.2 -SWIFT_SDK_URL := https://download.swift.org/swift-6.2-release/static-sdk/swift-6.2-RELEASE/swift-6.2-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz +SWIFT_VERSION := 6.2.3 +SWIFT_SDK_URL := https://download.swift.org/swift-6.2.3-release/static-sdk/swift-6.2.3-RELEASE/swift-6.2.3-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz SWIFT_SDK_PATH := /tmp/$(notdir $(SWIFT_SDK_URL)) SWIFTLY_URL := https://download.swift.org/swiftly/darwin/swiftly.pkg diff --git a/vminitd/Sources/vminitd/Server+GRPC.swift b/vminitd/Sources/vminitd/Server+GRPC.swift index f84f44bd..338c3afd 100644 --- a/vminitd/Sources/vminitd/Server+GRPC.swift +++ b/vminitd/Sources/vminitd/Server+GRPC.swift @@ -17,6 +17,7 @@ import Cgroup import Containerization import ContainerizationError +import ContainerizationExtras import ContainerizationNetlink import ContainerizationOCI import ContainerizationOS @@ -774,13 +775,14 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvid "ipAddrAdd", metadata: [ "interface": "\(request.interface)", - "address": "\(request.address)", + "ipv4Address": "\(request.ipv4Address)", ]) do { let socket = try DefaultNetlinkSocket() let session = NetlinkSession(socket: socket, log: log) - try session.addressAdd(interface: request.interface, address: request.address) + let ipv4Address = try CIDRv4(request.ipv4Address) + try session.addressAdd(interface: request.interface, ipv4Address: ipv4Address) } catch { log.error( "ipAddrAdd", @@ -800,17 +802,19 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvid "ipRouteAddLink", metadata: [ "interface": "\(request.interface)", - "address": "\(request.address)", - "srcAddr": "\(request.srcAddr)", + "dstIpv4Addr": "\(request.dstIpv4Addr)", + "srcIpv4Addr": "\(request.srcIpv4Addr)", ]) do { let socket = try DefaultNetlinkSocket() let session = NetlinkSession(socket: socket, log: log) + let dstIpv4Addr = try CIDRv4(request.dstIpv4Addr) + let srcIpv4Addr = try IPv4Address(request.srcIpv4Addr) try session.routeAdd( interface: request.interface, - destinationAddress: request.address, - srcAddr: request.srcAddr + dstIpv4Addr: dstIpv4Addr, + srcIpv4Addr: srcIpv4Addr ) } catch { log.error( @@ -832,13 +836,14 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvid "ipRouteAddDefault", metadata: [ "interface": "\(request.interface)", - "gateway": "\(request.gateway)", + "ipv4Gateway": "\(request.ipv4Gateway)", ]) do { let socket = try DefaultNetlinkSocket() let session = NetlinkSession(socket: socket, log: log) - try session.routeAddDefault(interface: request.interface, gateway: request.gateway) + let ipv4Gateway = try IPv4Address(request.ipv4Gateway) + try session.routeAddDefault(interface: request.interface, ipv4Gateway: ipv4Gateway) } catch { log.error( "ipRouteAddDefault",