diff --git a/Sources/Containerization/ContainerManager.swift b/Sources/Containerization/ContainerManager.swift index 8ac38413..f33149f5 100644 --- a/Sources/Containerization/ContainerManager.swift +++ b/Sources/Containerization/ContainerManager.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the Containerization project authors. +// Copyright © 2025-2026 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. @@ -93,7 +93,7 @@ public struct ContainerManager: Sendable { public struct Interface: Containerization.Interface, VZInterface, Sendable { public let ipv4Address: CIDRv4 public let ipv4Gateway: IPv4Address? - public let macAddress: String? + public let macAddress: MACAddress? // `reference` isn't used concurrently. nonisolated(unsafe) private let reference: vmnet_network_ref @@ -102,7 +102,7 @@ public struct ContainerManager: Sendable { reference: vmnet_network_ref, ipv4Address: CIDRv4, ipv4Gateway: IPv4Address, - macAddress: String? = nil + macAddress: MACAddress? = nil ) { self.ipv4Address = ipv4Address self.ipv4Gateway = ipv4Gateway @@ -114,7 +114,7 @@ public struct ContainerManager: Sendable { public func device() throws -> VZVirtioNetworkDeviceConfiguration { let config = VZVirtioNetworkDeviceConfiguration() if let macAddress = self.macAddress { - guard let mac = VZMACAddress(string: macAddress) else { + guard let mac = VZMACAddress(string: macAddress.description) else { throw ContainerizationError(.invalidArgument, message: "invalid mac address \(macAddress)") } config.macAddress = mac diff --git a/Sources/Containerization/Interface.swift b/Sources/Containerization/Interface.swift index 6211728c..4f730a59 100644 --- a/Sources/Containerization/Interface.swift +++ b/Sources/Containerization/Interface.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the Containerization project authors. +// Copyright © 2025-2026 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. @@ -26,5 +26,5 @@ public protocol Interface: Sendable { var ipv4Gateway: IPv4Address? { get } /// The interface MAC address, or nil to auto-configure the address. - var macAddress: String? { get } + var macAddress: MACAddress? { get } } diff --git a/Sources/Containerization/NATInterface.swift b/Sources/Containerization/NATInterface.swift index 01d4fd2f..6bf7a814 100644 --- a/Sources/Containerization/NATInterface.swift +++ b/Sources/Containerization/NATInterface.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the Containerization project authors. +// Copyright © 2025-2026 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. @@ -19,9 +19,9 @@ import ContainerizationExtras public struct NATInterface: Interface { public var ipv4Address: CIDRv4 public var ipv4Gateway: IPv4Address? - public var macAddress: String? + public var macAddress: MACAddress? - public init(ipv4Address: CIDRv4, ipv4Gateway: IPv4Address?, macAddress: String? = nil) { + public init(ipv4Address: CIDRv4, ipv4Gateway: IPv4Address?, macAddress: MACAddress? = nil) { self.ipv4Address = ipv4Address self.ipv4Gateway = ipv4Gateway self.macAddress = macAddress diff --git a/Sources/Containerization/NATNetworkInterface.swift b/Sources/Containerization/NATNetworkInterface.swift index 16b98cd1..7cdf8cef 100644 --- a/Sources/Containerization/NATNetworkInterface.swift +++ b/Sources/Containerization/NATNetworkInterface.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the Containerization project authors. +// Copyright © 2025-2026 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. @@ -29,7 +29,7 @@ import Synchronization public final class NATNetworkInterface: Interface, Sendable { public let ipv4Address: CIDRv4 public let ipv4Gateway: IPv4Address? - public let macAddress: String? + public let macAddress: MACAddress? @available(macOS 26, *) // `reference` isn't used concurrently. @@ -40,7 +40,7 @@ public final class NATNetworkInterface: Interface, Sendable { ipv4Address: CIDRv4, ipv4Gateway: IPv4Address?, reference: sending vmnet_network_ref, - macAddress: String? = nil + macAddress: MACAddress? = nil ) { self.ipv4Address = ipv4Address self.ipv4Gateway = ipv4Gateway @@ -52,7 +52,7 @@ public final class NATNetworkInterface: Interface, Sendable { public init( ipv4Address: CIDRv4, ipv4Gateway: IPv4Address?, - macAddress: String? = nil + macAddress: MACAddress? = nil ) { self.ipv4Address = ipv4Address self.ipv4Gateway = ipv4Gateway @@ -66,7 +66,7 @@ extension NATNetworkInterface: VZInterface { public func device() throws -> VZVirtioNetworkDeviceConfiguration { let config = VZVirtioNetworkDeviceConfiguration() if let macAddress = self.macAddress { - guard let mac = VZMACAddress(string: macAddress) else { + guard let mac = VZMACAddress(string: macAddress.description) else { throw ContainerizationError(.invalidArgument, message: "invalid mac address \(macAddress)") } config.macAddress = mac diff --git a/Sources/Containerization/VZVirtualMachineInstance.swift b/Sources/Containerization/VZVirtualMachineInstance.swift index 3ccd37a2..ac0e5ecf 100644 --- a/Sources/Containerization/VZVirtualMachineInstance.swift +++ b/Sources/Containerization/VZVirtualMachineInstance.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the Containerization project authors. +// Copyright © 2025-2026 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. @@ -465,7 +465,7 @@ extension NATInterface: VZInterface { public func device() throws -> VZVirtioNetworkDeviceConfiguration { let config = VZVirtioNetworkDeviceConfiguration() if let macAddress = self.macAddress { - guard let mac = VZMACAddress(string: macAddress) else { + guard let mac = VZMACAddress(string: macAddress.description) else { throw ContainerizationError(.invalidArgument, message: "invalid mac address \(macAddress)") } config.macAddress = mac diff --git a/Sources/ContainerizationExtras/IPAddressError.swift b/Sources/ContainerizationExtras/AddressError.swift similarity index 92% rename from Sources/ContainerizationExtras/IPAddressError.swift rename to Sources/ContainerizationExtras/AddressError.swift index de3f853d..dda7cd92 100644 --- a/Sources/ContainerizationExtras/IPAddressError.swift +++ b/Sources/ContainerizationExtras/AddressError.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the Containerization project authors. +// Copyright © 2026 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. @@ -14,7 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// -public struct IPAddressError: Error, Equatable, Hashable, CustomStringConvertible { +public struct AddressError: Error, Equatable, Hashable, CustomStringConvertible { public var description: String { String(describing: self.base) } diff --git a/Sources/ContainerizationExtras/CIDR.swift b/Sources/ContainerizationExtras/CIDR.swift index ed3f2446..8b562ece 100644 --- a/Sources/ContainerizationExtras/CIDR.swift +++ b/Sources/ContainerizationExtras/CIDR.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the Containerization project authors. +// Copyright © 2025-2026 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. @@ -133,3 +133,16 @@ extension CIDR { case invalidAddressRange(lower: String, upper: String) } } + +extension CIDR: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + try self.init(string) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(description) + } +} diff --git a/Sources/ContainerizationExtras/IPAddress.swift b/Sources/ContainerizationExtras/IPAddress.swift index 774ee87a..8b40f16c 100644 --- a/Sources/ContainerizationExtras/IPAddress.swift +++ b/Sources/ContainerizationExtras/IPAddress.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the Containerization project authors. +// Copyright © 2025-2026 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. @@ -27,7 +27,7 @@ public enum IPAddress: Sendable, Hashable, CustomStringConvertible, Equatable { /// /// - Parameter string: IP address string to parse /// - Returns: An `IPAddress` containing either an IPv4 or IPv6 address - /// - Throws: `IPAddressError.unableToParse` if invalid + /// - Throws: `AddressError.unableToParse` if invalid public init(_ string: String) throws { let utf8 = string.utf8 var hasColon = false @@ -50,7 +50,7 @@ public enum IPAddress: Sendable, Hashable, CustomStringConvertible, Equatable { let ipv4 = try IPv4Address(string) self = .v4(ipv4) } else { - throw IPAddressError.unableToParse + throw AddressError.unableToParse } } @@ -133,3 +133,16 @@ public enum IPAddress: Sendable, Hashable, CustomStringConvertible, Equatable { } } } + +extension IPAddress: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + try self.init(string) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(description) + } +} diff --git a/Sources/ContainerizationExtras/IPv4Address.swift b/Sources/ContainerizationExtras/IPv4Address.swift index e7c6723d..38e9ac18 100644 --- a/Sources/ContainerizationExtras/IPv4Address.swift +++ b/Sources/ContainerizationExtras/IPv4Address.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the Containerization project authors. +// Copyright © 2025-2026 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. @@ -26,7 +26,7 @@ public struct IPv4Address: Sendable, Hashable, CustomStringConvertible, Equatabl /// 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 + /// - Throws: `AddressError.unableToParse` if the string is not a valid IPv4 address @inlinable public init(_ string: String) throws { self.value = try Self.parse(string) @@ -94,7 +94,7 @@ public struct IPv4Address: Sendable, Hashable, CustomStringConvertible, Equatabl @usableFromInline internal static func parse(_ s: String) throws -> UInt32 { guard !s.isEmpty, s.count >= 7, s.count <= 15 else { - throw IPAddressError.unableToParse + throw AddressError.unableToParse } // IP addresses should only contain ASCII digits and dots @@ -102,7 +102,7 @@ public struct IPv4Address: Sendable, Hashable, CustomStringConvertible, Equatabl 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 + throw AddressError.unableToParse } } @@ -120,7 +120,7 @@ public struct IPv4Address: Sendable, Hashable, CustomStringConvertible, Equatabl if byte == 46 { // ASCII '.' // Validate octet before processing guard octetCount < 3, digitCount > 0, digitCount <= 3, currentOctet <= 255 else { - throw IPAddressError.unableToParse + throw AddressError.unableToParse } // Shift result and add current octet @@ -143,7 +143,7 @@ public struct IPv4Address: Sendable, Hashable, CustomStringConvertible, Equatabl currentOctet = 0 } else if digitCount > 1 && currentOctet == 0 { // We had a leading zero and now have more digits - invalid - throw IPAddressError.unableToParse + throw AddressError.unableToParse } else { // Normal case: build the octet value currentOctet = currentOctet * 10 + digit @@ -151,17 +151,17 @@ public struct IPv4Address: Sendable, Hashable, CustomStringConvertible, Equatabl // Early termination if octet becomes too large guard currentOctet <= 255, digitCount <= 3 else { - throw IPAddressError.unableToParse + throw AddressError.unableToParse } } else { - throw IPAddressError.unableToParse + throw AddressError.unableToParse } } // Validate final octet guard octetCount == 3, digitCount > 0, digitCount <= 3, currentOctet <= 255 else { - throw IPAddressError.unableToParse + throw AddressError.unableToParse } return (result << 8) | UInt32(currentOctet) diff --git a/Sources/ContainerizationExtras/IPv6Address+Parse.swift b/Sources/ContainerizationExtras/IPv6Address+Parse.swift index 40e04b5c..a701fbb8 100644 --- a/Sources/ContainerizationExtras/IPv6Address+Parse.swift +++ b/Sources/ContainerizationExtras/IPv6Address+Parse.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the Containerization project authors. +// Copyright © 2025-2026 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. @@ -115,7 +115,7 @@ extension IPv6Address { // Validate complete consumption of input guard currentPosition >= utf8.endIndex else { - throw IPAddressError.malformedAddress + throw AddressError.malformedAddress } // Apply ellipsis expansion for the IPv6 portion @@ -138,7 +138,7 @@ extension IPv6Address { /// /// - 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 + /// - Throws: `AddressError.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 '.' @@ -158,7 +158,7 @@ extension IPv6Address { let possibleIPv4 = String(input[afterColon...]) guard let ipv4Value = try? IPv4Address.parse(possibleIPv4) else { - throw IPAddressError.invalidIPv4SuffixInIPv6Address + throw AddressError.invalidIPv4SuffixInIPv6Address } // Check if lastColonIndex is the second ':' of '::'. If so, ensure to include it. @@ -171,7 +171,7 @@ extension IPv6Address { /// /// - Parameter input: The full IPv6 address string with potential zone identifier /// - Returns: Tuple of (address part, optional zone identifier) - /// - Throws: `IPAddressError.invalidZoneIdentifier` for malformed zone identifiers + /// - Throws: `AddressError.invalidZoneIdentifier` for malformed zone identifiers private static func extractZoneIdentifier(from input: String) throws -> (String, String?) { guard let percentIndex = input.lastIndex(of: "%") else { return (input, nil) @@ -179,7 +179,7 @@ extension IPv6Address { let zoneStartIndex = input.index(after: percentIndex) guard zoneStartIndex < input.endIndex else { - throw IPAddressError.invalidZoneIdentifier + throw AddressError.invalidZoneIdentifier } let addressPart = String(input[.. 0 else { // No hex digits found - throw IPAddressError.invalidHexGroup + throw AddressError.invalidHexGroup } return (accumulator, currentIndex) } @@ -266,20 +266,20 @@ extension IPv6Address { ) throws -> String.UTF8View.Index { // Expect colon separator guard group[position] == 58 else { // ASCII ':' - throw IPAddressError.malformedAddress + throw AddressError.malformedAddress } let afterFirstColon = group.index(after: position) guard afterFirstColon < group.endIndex else { // Trailing colon not allowed - throw IPAddressError.malformedAddress + throw AddressError.malformedAddress } // Check for double colon, return position after that if group[afterFirstColon] == 58 { // ASCII ':' guard ellipsisPosition == nil else { // Multiple :: not allowed - throw IPAddressError.multipleEllipsis + throw AddressError.multipleEllipsis } ellipsisPosition = currentByteIndex let afterSecondColon = group.index(after: afterFirstColon) @@ -295,7 +295,7 @@ extension IPv6Address { /// - 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 + /// - Throws: `AddressError.incompleteAddress` for invalid address lengths private static func expandEllipsis( in ipBytes: inout [UInt8], parsedBytes: Int, @@ -305,7 +305,7 @@ extension IPv6Address { 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 + throw AddressError.incompleteAddress // Incomplete address without ellipsis } return } @@ -313,7 +313,7 @@ extension IPv6Address { // Calculate expansion within the byte limit let bytesToExpand = byteLimit - parsedBytes guard bytesToExpand > 0 else { - throw IPAddressError.malformedAddress // No room for ellipsis expansion + throw AddressError.malformedAddress // No room for ellipsis expansion } let suffixBytes = Array(ipBytes[ellipsisPosition.. [UInt8] { + var result = [UInt8](repeating: 0, count: 6) + result[0] = UInt8((value >> 40) & 0xff) + result[1] = UInt8((value >> 32) & 0xff) + result[2] = UInt8((value >> 24) & 0xff) + result[3] = UInt8((value >> 16) & 0xff) + result[4] = UInt8((value >> 8) & 0xff) + result[5] = UInt8(value & 0xff) + return result + } + + @available(macOS 26.0, *) + @usableFromInline + static func bytes(_ value: UInt64) -> InlineArray<6, UInt8> { + let result: InlineArray<6, UInt8> = [ + 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 + } + + @inlinable + public var description: String { + bytes.map { String(format: "%02x", $0) }.joined(separator: ":") + } + + /// Parses an MAC address string into a UInt64 representation. + /// + /// ## Validation Rules + /// - Exactly six groups of two hexadecimal digits, separated by colons + /// or dashes + /// - No whitespace characters + /// - Only hexadecimal digits and colons allowed + /// + /// ## Examples + /// ```swift + /// MACAddress.parse("01:23:45:67:89:ab") // Returns: 0x0000_0123_4567_89ab + /// MACAddress.parse("01-23-45-67-89-AB") // Returns: 0x0000_0123_4567_89ab + /// MACAddress.parse("00:00:00:00:00:00") // Returns: 0x0000_0000_0000_0000 + /// MACAddress.parse("ff:ff:ff:ff:ff:ff") // Returns: 0x0000_ffff_ffff_ffff + /// + /// // Invalid examples: + /// MACAddress.parse("01:23:45:67:89") // Wrong number of octets + /// MACAddress.parse("01:23:45:67:89:a") // Invalid octet length + /// MACAddress.parse("01:23:45:67:89:hi") // Invalid octet content + /// MACAddress.parse("01:23-45:67-89:ab") // Inconsistent separators + /// MACAddress.parse(" 01:23:45:67:89:ab ") // Whitespace + /// ``` + /// + /// - Parameter s: The MAC address string to parse + /// - Returns: The 64-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 -> UInt64 { + guard !s.isEmpty, s.count == 17 else { + throw AddressError.unableToParse + } + + // MAC addresses should only contain ASCII hex 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 AddressError.unableToParse + } + } + + // accumulator for the 64 bit representation of the MAC address + var result: UInt64 = 0 + + // tracking octet count, max 6 allowed + var octetCount = 0 + var currentOctet = 0 + + // number of digits in the string representation of the octet + var digitCount = 0 + + // separator character to use + var separator: String.UTF8View.Element? + + for byte in utf8 { + if byte == 0x3a || byte == 0x2d { // ASCII ':' + // Ensure separator is consistent + guard separator == nil || byte == separator else { + throw AddressError.unableToParse + } + separator = byte + + // Validate octet before processing + guard octetCount < 5, digitCount == 2 else { + throw AddressError.unableToParse + } + + // Shift result and add current octet + result = (result << 8) | UInt64(currentOctet) + + // Reset for next octet + octetCount += 1 + currentOctet = 0 + digitCount = 0 + + } else if byte >= 0x30 && byte <= 0x39 { // ASCII '0'-'9' + let digit = Int(byte - 0x30) + + digitCount += 1 + currentOctet = (currentOctet << 4) + digit + + // Early termination if octet becomes too large + guard digitCount <= 2 else { + throw AddressError.unableToParse + } + + } else if byte >= 0x41 && byte <= 0x46 { // ASCII 'A'-'F' + let digit = Int(byte - 0x41 + 10) + + digitCount += 1 + currentOctet = (currentOctet << 4) + digit + + // Early termination if octet becomes too large + guard digitCount <= 2 else { + throw AddressError.unableToParse + } + + } else if byte >= 0x61 && byte <= 0x66 { // ASCII 'A'-'F' + let digit = Int(byte - 0x61 + 10) + + digitCount += 1 + currentOctet = (currentOctet << 4) + digit + + // Early termination if octet becomes too large + guard digitCount <= 2 else { + throw AddressError.unableToParse + } + + } else { + throw AddressError.unableToParse + } + } + + // Validate final octet + guard octetCount == 5, digitCount == 2 else { + throw AddressError.unableToParse + } + + return (result << 8) | UInt64(currentOctet) + } + + // MARK: - Address Classification Methods + + /// Returns `true` if the MAC address is locally administered. + /// + /// IEEE 802 specifies that the second-least-significant bit of + /// the first octet of the MAC address determines whether the + /// address is globally unique (bit cleared) or locally + /// administered (bit set). + @inlinable + public var isLocallyAdministered: Bool { + (value & 0x0000_0200_0000_0000) != 0 + } + + /// Returns `true` if the MAC address is multicast. + /// + /// IEEE 802 specifies that the least-significant bit of + /// the first octet of the MAC address determines whether the + /// address is unicast (bit cleared) or multicast (bit set). + @inlinable + public var isMulticast: Bool { + (value & 0x0000_0100_0000_0000) != 0 + } + + /// Returns the link local IP address based on the EUI-64 version + /// of the MAC address. + /// + /// - Parameter network: The IPv6 address to use for the network prefix + /// - Returns: The link local IP address for the MAC address + @inlinable + public func ipv6Address(network: IPv6Address) -> IPv6Address { + let prefixBytes = network.bytes + return IPv6Address([ + prefixBytes[0], prefixBytes[1], prefixBytes[2], prefixBytes[3], + prefixBytes[4], prefixBytes[5], prefixBytes[6], prefixBytes[7], + bytes[0] ^ 0x02, bytes[1], bytes[2], 0xff, + 0xfe, bytes[3], bytes[4], bytes[5], + ]) + } + + /// Compares two IPv4 addresses numerically. + @inlinable + public static func < (lhs: MACAddress, rhs: MACAddress) -> Bool { + lhs.value < rhs.value + } +} + +extension MACAddress: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + try self.init(string) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(description) + } +} diff --git a/Tests/ContainerizationExtrasTests/TestIPAddress.swift b/Tests/ContainerizationExtrasTests/TestIPAddress.swift index 3cb15d6d..8bf93a15 100644 --- a/Tests/ContainerizationExtrasTests/TestIPAddress.swift +++ b/Tests/ContainerizationExtrasTests/TestIPAddress.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the Containerization project authors. +// Copyright © 2025-2026 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. @@ -172,4 +172,66 @@ struct IPAddressTests { #expect(dict[ip2] == "IPv6") #expect(dict.count == 2) } + + @Test( + "Codable encodes to string representation", + arguments: [ + "127.0.0.1", + "192.168.1.1", + "0.0.0.0", + "255.255.255.255", + ] + ) + func testCodableEncodeIPv4(address: String) throws { + let original = try IPAddress(address) + let encoded = try JSONEncoder().encode(original) + #expect(String(data: encoded, encoding: .utf8) == "\"\(address)\"") + } + + @Test( + "Codable decodes from string representation", + arguments: [ + "127.0.0.1", + "192.168.1.1", + "0.0.0.0", + "255.255.255.255", + ] + ) + func testCodableDecodeIPv4(address: String) throws { + let json = Data("\"\(address)\"".utf8) + let decoded = try JSONDecoder().decode(IPAddress.self, from: json) + let expected = try IPAddress(address) + #expect(decoded == expected) + } + + @Test( + "Codable encodes to string representation", + arguments: [ + ("::1", "::1"), + ("2001:db8::1", "2001:db8::1"), + ("::", "::"), + ("fe80::1", "fe80::1"), + ] + ) + func testCodableEncodeIPv6(input: String, expected: String) throws { + let original = try IPAddress(input) + let encoded = try JSONEncoder().encode(original) + #expect(String(data: encoded, encoding: .utf8) == "\"\(expected)\"") + } + + @Test( + "Codable decodes from string representation", + arguments: [ + "::1", + "2001:db8::1", + "::", + "fe80::1", + ] + ) + func testCodableDecodeIPv6(address: String) throws { + let json = Data("\"\(address)\"".utf8) + let decoded = try JSONDecoder().decode(IPAddress.self, from: json) + let expected = try IPAddress(address) + #expect(decoded == expected) + } } diff --git a/Tests/ContainerizationExtrasTests/TestIPv4Address.swift b/Tests/ContainerizationExtrasTests/TestIPv4Address.swift index c97027e5..13fe9432 100644 --- a/Tests/ContainerizationExtrasTests/TestIPv4Address.swift +++ b/Tests/ContainerizationExtrasTests/TestIPv4Address.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the Containerization project authors. +// Copyright © 2025-2026 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. @@ -81,7 +81,7 @@ struct IPv4AddressTests { ] ) func testStringInitializerInvalid(invalidAddress: String) { - #expect(throws: IPAddressError.self) { + #expect(throws: AddressError.self) { try IPv4Address(invalidAddress) } } @@ -196,6 +196,37 @@ struct IPv4AddressTests { #expect(taskAddress.value == 0x7F00_0001) } } + + @Test( + "Codable encodes to string representation", + arguments: [ + "127.0.0.1", + "192.168.1.1", + "0.0.0.0", + "255.255.255.255", + ] + ) + func testCodableEncode(address: String) throws { + let original = try IPv4Address(address) + let encoded = try JSONEncoder().encode(original) + #expect(String(data: encoded, encoding: .utf8) == "\"\(address)\"") + } + + @Test( + "Codable decodes from string representation", + arguments: [ + "127.0.0.1", + "192.168.1.1", + "0.0.0.0", + "255.255.255.255", + ] + ) + func testCodableDecode(address: String) throws { + let json = Data("\"\(address)\"".utf8) + let decoded = try JSONDecoder().decode(IPv4Address.self, from: json) + let expected = try IPv4Address(address) + #expect(decoded == expected) + } } // MARK: - Edge Cases and Error Conditions @@ -247,7 +278,7 @@ struct IPv4AddressTests { ] ) func testLeadingZeroValidationInvalid(invalidAddress: String) { - #expect(throws: IPAddressError.self) { + #expect(throws: AddressError.self) { try IPv4Address(invalidAddress) } } @@ -267,7 +298,7 @@ struct IPv4AddressTests { ] ) func testStringLengthValidationTooShort(shortString: String) { - #expect(throws: IPAddressError.self) { + #expect(throws: AddressError.self) { try IPv4Address(shortString) } } @@ -283,7 +314,7 @@ struct IPv4AddressTests { ] ) func testStringLengthValidationTooLong(longString: String) { - #expect(throws: IPAddressError.self) { + #expect(throws: AddressError.self) { try IPv4Address(longString) } } @@ -421,42 +452,11 @@ struct IPv4AddressTests { do { _ = try IPv4Address(invalidInput) #expect(Bool(false), "Should have thrown for input: \(invalidInput)") - } catch let error as IPAddressError { - #expect(error == IPAddressError.unableToParse) + } catch let error as AddressError { + #expect(error == AddressError.unableToParse) } catch { - #expect(Bool(false), "Should have thrown IPAddressError, got: \(error)") + #expect(Bool(false), "Should have thrown AddressError, got: \(error)") } } - - @Test( - "Codable encodes to string representation", - arguments: [ - "127.0.0.1", - "192.168.1.1", - "0.0.0.0", - "255.255.255.255", - ] - ) - func testCodableEncode(address: String) throws { - let original = try IPv4Address(address) - let encoded = try JSONEncoder().encode(original) - #expect(String(data: encoded, encoding: .utf8) == "\"\(address)\"") - } - - @Test( - "Codable decodes from string representation", - arguments: [ - "127.0.0.1", - "192.168.1.1", - "0.0.0.0", - "255.255.255.255", - ] - ) - func testCodableDecode(address: String) throws { - let json = Data("\"\(address)\"".utf8) - let decoded = try JSONDecoder().decode(IPv4Address.self, from: json) - let expected = try IPv4Address(address) - #expect(decoded == expected) - } } } diff --git a/Tests/ContainerizationExtrasTests/TestIPv6Address+Parse.swift b/Tests/ContainerizationExtrasTests/TestIPv6Address+Parse.swift index 05191aef..36f50aaa 100644 --- a/Tests/ContainerizationExtrasTests/TestIPv6Address+Parse.swift +++ b/Tests/ContainerizationExtrasTests/TestIPv6Address+Parse.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the Containerization project authors. +// Copyright © 2025-2026 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. @@ -96,7 +96,7 @@ struct IPv6AddressParseTests { ] ) func testParseInvalidHexadecimalGroup(invalidInput: String) { - #expect(throws: IPAddressError.self) { + #expect(throws: AddressError.self) { let utf8 = invalidInput.utf8 _ = try IPv6Address.parseHexadecimal( from: utf8, @@ -222,7 +222,7 @@ struct IPv6AddressParseTests { #expect(actualBytes != parsedValidAddress, "\(testCase) should not match valid prefix") } catch { // If parsing fails, that's also acceptable for invalid representations - #expect(error is IPAddressError, "\(testCase) should throw IPAddressError if it fails to parse") + #expect(error is AddressError, "\(testCase) should throw IPAddressError if it fails to parse") } } } @@ -372,7 +372,7 @@ struct IPv6AddressParseTests { ] ) func testRFC4291Section22MultipleDoubleColonsShouldFail(invalid: String) { - #expect(throws: IPAddressError.self, "Multiple '::' should fail: \(invalid)") { + #expect(throws: AddressError.self, "Multiple '::' should fail: \(invalid)") { _ = try IPv6Address.parse(invalid) } } @@ -428,7 +428,7 @@ struct IPv6AddressParseTests { ] ) func testRFC4291Section22InvalidFormatsShouldFail(invalid: String) { - #expect(throws: IPAddressError.self, "Invalid format should fail: \(invalid)") { + #expect(throws: AddressError.self, "Invalid format should fail: \(invalid)") { _ = try IPv6Address.parse(invalid) } } @@ -633,7 +633,7 @@ struct IPv6AddressParseTests { ] ) func testRFC4291Section22IPv4MixedNotationInvalid(invalid: String) { - #expect(throws: IPAddressError.self, "Invalid IPv4 mixed notation should fail: \(invalid)") { + #expect(throws: AddressError.self, "Invalid IPv4 mixed notation should fail: \(invalid)") { _ = try IPv6Address.parse(invalid) } } diff --git a/Tests/ContainerizationExtrasTests/TestIPv6IPv4Parsing.swift b/Tests/ContainerizationExtrasTests/TestIPv6IPv4Parsing.swift index 38ad4b6e..15031a2c 100644 --- a/Tests/ContainerizationExtrasTests/TestIPv6IPv4Parsing.swift +++ b/Tests/ContainerizationExtrasTests/TestIPv6IPv4Parsing.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025 Apple Inc. and the Containerization project authors. +// Copyright © 2025-2026 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. @@ -60,7 +60,7 @@ struct IPv6IPv4ParsingTests { ] ) func testInvalidIPv4Throws(invalid: String) { - #expect(throws: IPAddressError.self) { + #expect(throws: AddressError.self) { _ = try IPv6Address.extractIPv4Suffix(from: invalid) } } diff --git a/Tests/ContainerizationExtrasTests/TestMACAddress.swift b/Tests/ContainerizationExtrasTests/TestMACAddress.swift new file mode 100644 index 00000000..aaee901e --- /dev/null +++ b/Tests/ContainerizationExtrasTests/TestMACAddress.swift @@ -0,0 +1,404 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 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("MACAddress Tests") +struct MACAddressTests { + + // MARK: - Initializer Tests + + @Suite("Initializers") + struct InitializerTests { + + @Test( + "UInt64 initializer - valid addresses", + arguments: [ + //(0x0123_4567_89ab, "01:23:45:67:89:ab"), // a valid address + //(0x0000_0000_0000, "00:00:00:00:00:00"), // zero address + //(0xFFFF_FFFF_FFFF, "ff:ff:ff:ff:ff:ff"), // max address + (0xffff_0123_4567_89ab, "01:23:45:67:89:ab") // drops the most significant 16 bits + ] + ) + func testUInt64InitializerValid(inputValue: UInt64, description: String) { + let address = MACAddress(inputValue) + #expect(address.value == inputValue & 0x0000_ffff_ffff_ffff) + } + + @Test( + "String initializer - valid addresses", + arguments: [ + ("01:23:45:67:89:ab", 0x0123_4567_89ab), // colon separators + ("01-23-45-67-89-ab", 0x0123_4567_89ab), // dash separators + ("ab:cd:ef:AB:CD:EF", 0xabcd_efab_cdef), // mixed case + ("00:00:00:00:00:00", 0x0000_0000_0000), // zero address + ("ff:ff:ff:ff:ff:ff", 0xffff_ffff_ffff), // max address + ] + ) + func testStringInitializerValid(addressString: String, expectedValue: UInt64) throws { + let address = try MACAddress(addressString) + #expect(address.value == expectedValue) + } + + @Test( + "String initializer - invalid addresses", + arguments: [ + "", // empty string + "01:23:45:67:89", // too few octets + "01:23:45:67:89:ab:cd", // too many octets + "01:23:45:67:89:", // empty octet + ":23:45:67:89:ab", // empty octet + "01::45:67:89:ab", // empty octet + "01:23:45:67:89:a", // short octet + "1:23:45:67:89:ab", // short octet + "01:2:45:67:89:ab", // short octet + "01:23:45:67:89:abc", // long octet + "012:23:45:67:89:ab", // long octet + "01:234:45:67:89:ab", // long octet + "01:23:45:67:89:@G", // invalid content 0x40, 0x47 + "`g:23:45:67:89:ab", // invalid content 0x60, 0x67 + "01:hi:45:67:89:ab", // invalid content + " 01:23:45:67:89:ab", // leading whitespace + "01:23:45:67:89:ab ", // trailing whitespace + "01: 23:45:67:89:ab", // internal whitespace + ] + ) + func testStringInitializerInvalid(invalidAddress: String) { + #expect(throws: AddressError.self) { + try MACAddress(invalidAddress) + } + } + } + + // MARK: - Property Tests + + @Suite("Properties") + struct PropertyTests { + + @Test( + "bytes property", + arguments: [ + ( + UInt64(0x0123_4567_89ab), + [UInt8(0x01), UInt8(0x23), UInt8(0x45), UInt8(0x67), UInt8(0x89), UInt8(0xab)] + ), + ( + UInt64(0x0000_0000_0000), + [UInt8(0x00), UInt8(0x00), UInt8(0x00), UInt8(0x00), UInt8(0x00), UInt8(0x00)] + ), + ( + UInt64(0xffff_ffff_ffff), + [UInt8(0xff), UInt8(0xff), UInt8(0xff), UInt8(0xff), UInt8(0xff), UInt8(0xff)] + ), + ( + UInt64(0xffff_0123_4567_89ab), + [UInt8(0x01), UInt8(0x23), UInt8(0x45), UInt8(0x67), UInt8(0x89), UInt8(0xab)] + ), + ] + ) + func testBytesProperty(inputValue: UInt64, expectedBytes: [UInt8]) { + let address = MACAddress(inputValue) + #expect(address.bytes == expectedBytes) + } + + @Test( + "description property", + arguments: [ + (0x0123_4567_89ab, "01:23:45:67:89:ab"), + (0x0000_0000_0000, "00:00:00:00:00:00"), + (0xffff_ffff_ffff, "ff:ff:ff:ff:ff:ff"), + (0xffff_0123_4567_89ab, "01:23:45:67:89:ab"), + ] + ) + func testDescriptionProperty(inputValue: UInt64, expectedDescription: String) { + let address = MACAddress(inputValue) + #expect(address.description == expectedDescription) + } + + @Test( + "isLocallyAdministered property", + arguments: [ + (0x0000_1234_5678, false), + (0x0200_1234_5678, true), + ] + ) + func testIsLocallyAdministeredProperty(inputValue: UInt64, expectedValue: Bool) { + let address = MACAddress(inputValue) + #expect(address.isLocallyAdministered == expectedValue) + } + + @Test( + "isMulticast property", + arguments: [ + (0x0000_1234_5678, false), + (0x0100_1234_5678, true), + ] + ) + func testIsMulticastProperty(inputValue: UInt64, expectedValue: Bool) { + let address = MACAddress(inputValue) + #expect(address.isMulticast == expectedValue) + } + + @Test( + "round-trip string conversion", + arguments: [ + "01:23:45:67:89:ab", + "00:00:00:00:00:00", + "ff:ff:ff:ff:ff:ff", + "01-23-45-67-89-AB", + ] + ) + func testRoundTripStringConversion(addressString: String) throws { + let address = try MACAddress(addressString) + #expect(address.description == addressString.lowercased().replacingOccurrences(of: "-", with: ":")) + } + } + + // MARK: - Link Local Address Tests + + @Suite("Link Local Addresses") + struct LinkLocalAddressTests { + + @Test( + "Link local address", + arguments: [ + (0x39a7_9407_cbd0, 0xfd97_7b15_d62e_75ac_3ba7_94ff_fe07_cbd0), + (0x5e3b_68d7_e510, 0xfd97_7b15_d62e_75ac_5c3b_68ff_fed7_e510), + ] + ) + func testLinkLocalAddress(mac: UInt64, ipv6: UInt128) { + let mac = MACAddress(mac) + let ipv6Prefix = IPv6Address(ipv6 & 0xffff_ffff_ffff_ffff_0000_0000_0000_0000) + #expect(mac.ipv6Address(network: ipv6Prefix) == IPv6Address(ipv6)) + } + } + + // MARK: - Protocol Conformance Tests + + @Suite("Protocol Conformances") + struct ProtocolConformanceTests { + + @Test("Equatable conformance") + func testEquatableConformance() { + let addr1 = MACAddress(0x0123_4567_89ab) + let addr2 = MACAddress(0x0123_4567_89ab) + let addr3 = MACAddress(0x0123_4567_89ac) + + #expect(addr1 == addr2) + #expect(addr1 != addr3) + #expect(addr2 != addr3) + } + + @Test("Hashable conformance") + func testHashableConformance() { + let addr1 = MACAddress(0x0123_4567_89ab) + let addr2 = MACAddress(0x0123_4567_89ab) + let addr3 = MACAddress(0x0123_4567_89ac) + + // 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 = MACAddress(0x0123_4567_89ab) + let stringRepresentation = String(describing: address) + #expect(stringRepresentation == "01:23:45:67:89:ab") + } + + @Test("Sendable conformance") + func testSendableConformance() { + // This test verifies that MACAddress can be safely passed across concurrency boundaries + let address = MACAddress(0x0123_4567_89ab) + + Task { + let taskAddress = address + #expect(taskAddress.value == 0x0123_4567_89ab) + } + } + } + + // MARK: - Performance Tests + + @Suite("Performance") + struct PerformanceTests { + + @Test("parsing performance") + func testParsingPerformance() throws { + let testAddresses = [ + "01:23:45:67:89:ab", + "01-23-45-67-89-ab", + "01-23-45-67-89-a", + "01-23-45-67-89-abc", + ] + + // Warm up + for _ in 0..<100 { + for address in testAddresses { + _ = try? MACAddress(address) + } + } + + // Measure performance + let iterations = 10000 + let startTime = Date() + + for _ in 0.. String + let addressFromUInt32 = MACAddress(expectedValue) + #expect(addressFromUInt32.description == expectedString) + + // Test String -> UInt32 + let addressFromString = try MACAddress(expectedString) + #expect(addressFromString.value == expectedValue) + + // Test equality + #expect(addressFromUInt32 == addressFromString) + } + + @Test( + "error message consistency", + arguments: [ + "", + "hi:00:00:00:00:00", + "01:23:45:67:89", + "01:23:45:67:89:ab:cd", + "001:23:45:67:89:ab:cd", + " 01:23:45:67:89:ab:cd", + "01:23:45:67:89:ab:cd ", + ] + ) + func testErrorMessageConsistency(invalidInput: String) { + do { + _ = try MACAddress(invalidInput) + #expect(Bool(false), "Should have thrown for input: \(invalidInput)") + } catch let error as AddressError { + #expect(error == AddressError.unableToParse) + } catch { + #expect(Bool(false), "Should have thrown AddressError, got: \(error)") + } + } + + @Test( + "Codable encodes to string representation", + arguments: [ + "01:23:45:67:89:ab", + "00:00:00:00:00:00", + "ff:ff:ff:ff:ff:ff", + ] + ) + func testCodableEncode(address: String) throws { + let original = try MACAddress(address) + let encoded = try JSONEncoder().encode(original) + #expect(String(data: encoded, encoding: .utf8) == "\"\(address)\"") + } + + @Test( + "Codable decodes from string representation", + arguments: [ + "01:23:45:67:89:ab", + "00:00:00:00:00:00", + "ff:ff:ff:ff:ff:ff", + ] + ) + func testCodableDecode(address: String) throws { + let json = Data("\"\(address)\"".utf8) + let decoded = try JSONDecoder().decode(MACAddress.self, from: json) + let expected = try MACAddress(address) + #expect(decoded == expected) + } + } +}