Adds MACAddress type. (#468)

- Simplifies processing and validation of MAC addresses for
apple/container#1005, where we need to create MAC addresses and
corresponding link local IPv6 addresses.
- Adds Codable for IPAddress and CIDR for apple/container#1006, so we
can parse and relay IP addresses regardless of address family for port
forwards.
This commit is contained in:
J Logan
2026-01-05 11:31:23 -08:00
committed by GitHub
parent 2f55d75fec
commit 452f354bac
17 changed files with 842 additions and 94 deletions
@@ -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
+2 -2
View File
@@ -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 }
}
+3 -3
View File
@@ -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
@@ -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
@@ -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
@@ -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)
}
+14 -1
View File
@@ -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)
}
}
+16 -3
View File
@@ -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)
}
}
@@ -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)
@@ -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[..<percentIndex])
@@ -194,7 +194,7 @@ extension IPv6Address {
/// - utf8: The UTF-8 view to parse from
/// - startIndex: Starting position in the UTF-8 view
/// - Returns: Tuple of (parsed hex value as UInt16, next position after parsed digits)
/// - Throws: `IPAddressError.invalidHexGroup` if no valid hex digits are found
/// - Throws: `AddressError.invalidHexGroup` if no valid hex digits are found
///
/// ## Example
/// ```swift
@@ -233,7 +233,7 @@ extension IPv6Address {
guard digitCount > 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..<parsedBytes])
@@ -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.
@@ -25,7 +25,7 @@ public struct IPv6Address: Sendable, Hashable, CustomStringConvertible, Equatabl
/// Supports standard IPv6 formats including compressed notation (::), mixed IPv4 notation, and zone identifiers.
///
/// - Parameter address: String representation of an IPv6 address
/// - Throws: `IPAddressError` if the string is not a valid IPv6 address
/// - Throws: `AddressError` if the string is not a valid IPv6 address
public init(_ address: String) throws {
self = try Self.parse(address)
}
@@ -0,0 +1,256 @@
//===----------------------------------------------------------------------===//
// 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
/// An EUI-48 MAC address as specified by IEEE 802.
@frozen
public struct MACAddress: Sendable, Hashable, CustomStringConvertible, Equatable, Comparable {
public let value: UInt64
/// Creates an MACAddress from an integer.
///
/// - Parameter value: The big-endian value of the MAC address.
/// The most significant 16 bits of the value are ignored.
@inlinable
public init(_ value: UInt64) {
self.value = value & 0x0000_ffff_ffff_ffff
}
/// Creates an MACAddress from a string representation.
///
/// - Parameter string: The MAC address string with colon or dash delimiters.
/// - Throws: `AddressError.unableToParse` if the string is not a valid MAC 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: UInt64) -> [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)
}
}
@@ -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)
}
}
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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<MACAddress> = [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..<iterations {
for address in testAddresses {
_ = try? MACAddress(address)
}
}
let endTime = Date()
let totalTime = endTime.timeIntervalSince(startTime)
let averageTime = totalTime / Double(iterations * testAddresses.count)
// Should be very fast - less than 1ms per parse on average
#expect(averageTime < 0.001, "Parsing should be fast: \(averageTime)s per address")
}
@Test("bytes property performance")
func testBytesPropertyPerformance() {
let address = MACAddress(0x0123_4567_89ab)
let iterations = 100000
let startTime = Date()
for _ in 0..<iterations {
_ = address.bytes
}
let endTime = Date()
let totalTime = endTime.timeIntervalSince(startTime)
let averageTime = totalTime / Double(iterations)
// Should be very fast - less than 0.1ms per call on average
#expect(averageTime < 0.0001, "Bytes property should be fast: \(averageTime)s per call")
}
@Test("description property performance")
func testDescriptionPropertyPerformance() {
let address = MACAddress(0x0123_4567_89ab)
let iterations = 10000
let startTime = Date()
for _ in 0..<iterations {
_ = address.description
}
let endTime = Date()
let totalTime = endTime.timeIntervalSince(startTime)
let averageTime = totalTime / Double(iterations)
// Should be reasonably fast - less than 1ms per call on average
#expect(averageTime < 0.001, "Description property should be fast: \(averageTime)s per call")
}
}
// MARK: - Integration Tests
@Suite("Integration")
struct IntegrationTests {
@Test(
"comprehensive round-trip test",
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"),
]
)
func testComprehensiveRoundTrip(expectedValue: UInt64, expectedString: String) throws {
// Test UInt32 -> 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)
}
}
}