From 1d0ae4566add1b668641aec7068643809814d7cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Pi=C3=B1era=20Buend=C3=ADa?= Date: Tue, 16 Jun 2026 16:15:12 +0200 Subject: [PATCH 1/3] feat(tart): native vmnet networking via VZVmnetNetworkDeviceAttachment --- Sources/tart/Commands/Run.swift | 51 +++++++- Sources/tart/Network/NetworkVmnet.swift | 156 ++++++++++++++++++++++++ Tests/TartTests/NetworkVmnetTests.swift | 44 +++++++ 3 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 Sources/tart/Network/NetworkVmnet.swift create mode 100644 Tests/TartTests/NetworkVmnetTests.swift diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index 9788db11..21901cc3 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -243,6 +243,29 @@ struct Run: AsyncParsableCommand { @Flag(help: ArgumentHelp("Restrict network access to the host-only network")) var netHost: Bool = false + #if compiler(>=6.4) + @Flag(help: ArgumentHelp("Use native vmnet-backed networking instead of Softnet for port forwarding", + discussion: """ + Adopts the VZVmnetNetworkDeviceAttachment API introduced in macOS 27 (WWDC26). + vmnet networks run in-process with no sidecar, so this can replace --net-softnet + for the common "CI VM with a few forwarded ports" case. + + Requires the host to be running macOS 27 (or newer). + """)) + var netVmnet: Bool = false + + @Option(help: ArgumentHelp("Comma-separated list of ports to forward into the vmnet guest (e.g. --net-vmnet-expose 2222:22,8080:80/tcp,5353:53/udp)", + discussion: """ + Each rule has the form EXTERNAL_PORT:INTERNAL_PORT[/PROTOCOL] where PROTOCOL + is tcp (default) or udp. EXTERNAL_PORT is bound on the host's egress interface + and forwarded to INTERNAL_PORT on the guest. + + Implies --net-vmnet. + """, + valueName: "comma-separated port specifications")) + var netVmnetExpose: String? + #endif + @Option(help: ArgumentHelp("Set the root disk options (e.g. --root-disk-opts=\"ro\" or --root-disk-opts=\"caching=cached,sync=none\")", discussion: """ Options are comma-separated and are as follows: @@ -322,11 +345,30 @@ struct Run: AsyncParsableCommand { if netBridged.count > 0 { netFlags += 1 } if netSoftnet { netFlags += 1 } if netHost { netFlags += 1 } + #if compiler(>=6.4) + // Automatically enable --net-vmnet when --net-vmnet-expose is specified + if netVmnetExpose != nil { + netVmnet = true + } + if netVmnet { netFlags += 1 } + #endif if netFlags > 1 { - throw ValidationError("--net-bridged, --net-softnet and --net-host are mutually exclusive") + #if compiler(>=6.4) + throw ValidationError("--net-bridged, --net-softnet, --net-host and --net-vmnet are mutually exclusive") + #else + throw ValidationError("--net-bridged, --net-softnet and --net-host are mutually exclusive") + #endif } + #if compiler(>=6.4) + if netVmnet { + if #unavailable(macOS 27) { + throw ValidationError("--net-vmnet requires the host to be running macOS 27 (or newer)") + } + } + #endif + if graphics && noGraphics { throw ValidationError("--graphics and --no-graphics are mutually exclusive") } @@ -690,6 +732,13 @@ struct Run: AsyncParsableCommand { return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: ["--vm-net-type", "host"] + softnetExtraArguments) } + #if compiler(>=6.4) + if netVmnet, #available(macOS 27, *) { + let portForwardings = try netVmnetExpose.map { try NetworkVmnet.parsePortForwardings($0) } ?? [] + return try NetworkVmnet(portForwardings: portForwardings) + } + #endif + if netBridged.count > 0 { func findBridgedInterface(_ name: String) throws -> VZBridgedNetworkInterface { let interface = VZBridgedNetworkInterface.networkInterfaces.first { interface in diff --git a/Sources/tart/Network/NetworkVmnet.swift b/Sources/tart/Network/NetworkVmnet.swift new file mode 100644 index 00000000..b560e5e0 --- /dev/null +++ b/Sources/tart/Network/NetworkVmnet.swift @@ -0,0 +1,156 @@ +// Native vmnet-backed networking, adopting the macOS 27 (WWDC26) Virtualization +// API surface introduced in session 224 ("Expand the capabilities of your +// Virtualization app"). +// +// Goal: provide a sidecar-free alternative to the external Softnet process for +// CI-style port forwarding from host TCP/UDP ports to guest ports. The vmnet +// configuration is created in-process and handed to +// VZVmnetNetworkDeviceAttachment, so the VM keeps using the standard +// VZVirtioNetworkDeviceConfiguration path. +// +// Compile-verification status: this file targets Swift 6.4 (Xcode 27 beta) and +// the macOS 27 SDK headers. The host Swift available when this branch was +// written was 6.3.2, so it has not been compiled. The C entry points used here +// (vmnet_network_configuration_create, vmnet_network_create) are the names +// shown verbatim in WWDC26 session 224. The port-forwarding configuration +// symbol is not stated in the session and is left as a clearly marked FIXME so +// it can be wired up once the final Xcode 27 headers are available. +// +// The whole file is gated behind `#if compiler(>=6.4)` to match how Tart +// already gates VZMacGuestProvisioningOptions in VM.swift (the same situation: +// macOS 27 SDK symbols referenced by a tree that still builds under Xcode 26). + +import Foundation +import Semaphore +import Virtualization + +#if compiler(>=6.4) + import vmnet + + @available(macOS 27, *) + class NetworkVmnet: Network { + enum NetworkProtocol: String, CaseIterable { + case tcp + case udp + } + + struct PortForwarding: Equatable { + let proto: NetworkProtocol + let externalPort: UInt16 + let internalPort: UInt16 + } + + private let network: vmnet_network_t + private let portForwardings: [PortForwarding] + + init(portForwardings: [PortForwarding] = []) throws { + self.portForwardings = portForwardings + + var configStatus: vmnet_return_t = .VMNET_FAILURE + guard let configuration = vmnet_network_configuration_create(.VMNET_SHARED_MODE, &configStatus) else { + throw NetworkVmnetError.ConfigurationCreationFailed(status: configStatus) + } + defer { vmnet_network_configuration_release(configuration) } + + try Self.applyPortForwarding(rules: portForwardings, to: configuration) + + var networkStatus: vmnet_return_t = .VMNET_FAILURE + guard let network = vmnet_network_create(configuration, &networkStatus) else { + throw NetworkVmnetError.NetworkCreationFailed(status: networkStatus) + } + self.network = network + } + + deinit { + vmnet_network_release(network) + } + + func attachments() -> [VZNetworkDeviceAttachment] { + [VZVmnetNetworkDeviceAttachment(network: network)] + } + + func run(_ sema: AsyncSemaphore) throws { + // vmnet networks run in-process. There is no sidecar to monitor. + } + + func stop() async throws { + // The network handle is released in deinit; the VM tears down the + // attachment as part of its normal shutdown. + } + + // FIXME(macOS 27 SDK): wire up the actual vmnet port-forwarding setter. + // + // WWDC26 session 224 advertises "forward host TCP/UDP ports to specific + // VMs" as part of the new vmnet_network_configuration_t surface but does + // not show the exact C symbol. Once the final Xcode 27 SDK ships, replace + // the body below with the real calls (likely shaped like + // `vmnet_network_configuration_add_port_forwarding_rule(configuration, + // protocol, externalPort, internalPort, &status)`). + // + // For now, refuse to start a VM with port-forwarding rules so the failure + // mode is loud rather than silently dropped traffic. + private static func applyPortForwarding( + rules: [PortForwarding], + to configuration: vmnet_network_configuration_t + ) throws { + guard !rules.isEmpty else { return } + throw NetworkVmnetError.PortForwardingPendingSDKFinalization + } + + static func parsePortForwardings(_ spec: String) throws -> [PortForwarding] { + try spec.split(separator: ",").map { try parseSingle(String($0)) } + } + + private static func parseSingle(_ raw: String) throws -> PortForwarding { + let (portPart, protoPart): (String, String) = { + if let slashIdx = raw.firstIndex(of: "/") { + return (String(raw[.. 0, internalPort > 0 + else { + throw NetworkVmnetError.InvalidPortForwardingSpec( + spec: raw, + why: "expected EXTERNAL_PORT:INTERNAL_PORT[/PROTOCOL] with non-zero ports" + ) + } + + guard let proto = NetworkProtocol(rawValue: protoPart.lowercased()) else { + throw NetworkVmnetError.InvalidPortForwardingSpec( + spec: raw, + why: "unknown protocol \"\(protoPart)\", expected tcp or udp" + ) + } + + return PortForwarding(proto: proto, externalPort: external, internalPort: internalPort) + } + } + + enum NetworkVmnetError: Error, CustomStringConvertible { + case ConfigurationCreationFailed(status: vmnet_return_t) + case NetworkCreationFailed(status: vmnet_return_t) + case InvalidPortForwardingSpec(spec: String, why: String) + case PortForwardingPendingSDKFinalization + + var description: String { + switch self { + case .ConfigurationCreationFailed(let status): + return "vmnet_network_configuration_create() failed with status \(status)" + case .NetworkCreationFailed(let status): + return "vmnet_network_create() failed with status \(status)" + case .InvalidPortForwardingSpec(let spec, let why): + return "invalid port forwarding spec \"\(spec)\": \(why)" + case .PortForwardingPendingSDKFinalization: + return "--net-vmnet-expose is not yet wired through to the macOS 27 vmnet port-forwarding API; " + + "use --net-softnet-expose for now or remove the rule" + } + } + } + +#endif diff --git a/Tests/TartTests/NetworkVmnetTests.swift b/Tests/TartTests/NetworkVmnetTests.swift new file mode 100644 index 00000000..90e2d9de --- /dev/null +++ b/Tests/TartTests/NetworkVmnetTests.swift @@ -0,0 +1,44 @@ +import XCTest +@testable import tart + +#if compiler(>=6.4) + @available(macOS 27, *) + final class NetworkVmnetTests: XCTestCase { + func testParsesSingleTCPRule() throws { + let rules = try NetworkVmnet.parsePortForwardings("2222:22") + XCTAssertEqual(rules, [ + NetworkVmnet.PortForwarding(proto: .tcp, externalPort: 2222, internalPort: 22), + ]) + } + + func testParsesExplicitProtocols() throws { + let rules = try NetworkVmnet.parsePortForwardings("8080:80/tcp,5353:53/udp") + XCTAssertEqual(rules, [ + NetworkVmnet.PortForwarding(proto: .tcp, externalPort: 8080, internalPort: 80), + NetworkVmnet.PortForwarding(proto: .udp, externalPort: 5353, internalPort: 53), + ]) + } + + func testProtocolIsCaseInsensitive() throws { + let rules = try NetworkVmnet.parsePortForwardings("9000:9000/UDP") + XCTAssertEqual(rules.first?.proto, .udp) + } + + func testRejectsMissingInternalPort() { + XCTAssertThrowsError(try NetworkVmnet.parsePortForwardings("2222")) + } + + func testRejectsZeroPort() { + XCTAssertThrowsError(try NetworkVmnet.parsePortForwardings("0:22")) + XCTAssertThrowsError(try NetworkVmnet.parsePortForwardings("2222:0")) + } + + func testRejectsUnknownProtocol() { + XCTAssertThrowsError(try NetworkVmnet.parsePortForwardings("2222:22/sctp")) + } + + func testRejectsOutOfRangePort() { + XCTAssertThrowsError(try NetworkVmnet.parsePortForwardings("99999:22")) + } + } +#endif From cf304103958274ba32fab509740991a9ac6395d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Pi=C3=B1era=20Buend=C3=ADa?= Date: Tue, 16 Jun 2026 16:26:57 +0200 Subject: [PATCH 2/3] fix(tart): wire native vmnet port forwarding --- Sources/tart/Commands/Run.swift | 13 +- Sources/tart/Network/NetworkVmnet.swift | 191 +++++++++++++++++++----- Tests/TartTests/NetworkVmnetTests.swift | 2 +- 3 files changed, 161 insertions(+), 45 deletions(-) diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index 21901cc3..8922401a 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -246,11 +246,11 @@ struct Run: AsyncParsableCommand { #if compiler(>=6.4) @Flag(help: ArgumentHelp("Use native vmnet-backed networking instead of Softnet for port forwarding", discussion: """ - Adopts the VZVmnetNetworkDeviceAttachment API introduced in macOS 27 (WWDC26). + Adopts the VZVmnetNetworkDeviceAttachment API introduced in macOS 26. vmnet networks run in-process with no sidecar, so this can replace --net-softnet for the common "CI VM with a few forwarded ports" case. - Requires the host to be running macOS 27 (or newer). + Requires the host to be running macOS 26 (or newer). """)) var netVmnet: Bool = false @@ -363,8 +363,8 @@ struct Run: AsyncParsableCommand { #if compiler(>=6.4) if netVmnet { - if #unavailable(macOS 27) { - throw ValidationError("--net-vmnet requires the host to be running macOS 27 (or newer)") + if #unavailable(macOS 26) { + throw ValidationError("--net-vmnet requires the host to be running macOS 26 (or newer)") } } #endif @@ -733,9 +733,10 @@ struct Run: AsyncParsableCommand { } #if compiler(>=6.4) - if netVmnet, #available(macOS 27, *) { + if netVmnet, #available(macOS 26, *) { + let config = try VMConfig.init(fromURL: vmDir.configURL) let portForwardings = try netVmnetExpose.map { try NetworkVmnet.parsePortForwardings($0) } ?? [] - return try NetworkVmnet(portForwardings: portForwardings) + return try NetworkVmnet(vmMACAddress: config.macAddress.string, portForwardings: portForwardings) } #endif diff --git a/Sources/tart/Network/NetworkVmnet.swift b/Sources/tart/Network/NetworkVmnet.swift index b560e5e0..48136882 100644 --- a/Sources/tart/Network/NetworkVmnet.swift +++ b/Sources/tart/Network/NetworkVmnet.swift @@ -1,6 +1,5 @@ -// Native vmnet-backed networking, adopting the macOS 27 (WWDC26) Virtualization -// API surface introduced in session 224 ("Expand the capabilities of your -// Virtualization app"). +// Native vmnet-backed networking, adopting the vmnet logical network API +// surface introduced alongside VZVmnetNetworkDeviceAttachment. // // Goal: provide a sidecar-free alternative to the external Softnet process for // CI-style port forwarding from host TCP/UDP ports to guest ports. The vmnet @@ -8,18 +7,11 @@ // VZVmnetNetworkDeviceAttachment, so the VM keeps using the standard // VZVirtioNetworkDeviceConfiguration path. // -// Compile-verification status: this file targets Swift 6.4 (Xcode 27 beta) and -// the macOS 27 SDK headers. The host Swift available when this branch was -// written was 6.3.2, so it has not been compiled. The C entry points used here -// (vmnet_network_configuration_create, vmnet_network_create) are the names -// shown verbatim in WWDC26 session 224. The port-forwarding configuration -// symbol is not stated in the session and is left as a clearly marked FIXME so -// it can be wired up once the final Xcode 27 headers are available. -// // The whole file is gated behind `#if compiler(>=6.4)` to match how Tart // already gates VZMacGuestProvisioningOptions in VM.swift (the same situation: -// macOS 27 SDK symbols referenced by a tree that still builds under Xcode 26). +// new SDK symbols referenced by a tree that still builds under Xcode 26). +import Darwin import Foundation import Semaphore import Virtualization @@ -27,7 +19,7 @@ import Virtualization #if compiler(>=6.4) import vmnet - @available(macOS 27, *) + @available(macOS 26, *) class NetworkVmnet: Network { enum NetworkProtocol: String, CaseIterable { case tcp @@ -40,19 +32,22 @@ import Virtualization let internalPort: UInt16 } - private let network: vmnet_network_t - private let portForwardings: [PortForwarding] + private let network: vmnet_network_ref - init(portForwardings: [PortForwarding] = []) throws { - self.portForwardings = portForwardings + init(vmMACAddress: String, portForwardings: [PortForwarding] = []) throws { + let macAddress = try Self.parseMACAddress(vmMACAddress) + let addressing = try Self.addressing(for: macAddress) var configStatus: vmnet_return_t = .VMNET_FAILURE guard let configuration = vmnet_network_configuration_create(.VMNET_SHARED_MODE, &configStatus) else { throw NetworkVmnetError.ConfigurationCreationFailed(status: configStatus) } - defer { vmnet_network_configuration_release(configuration) } + defer { Self.release(configuration) } - try Self.applyPortForwarding(rules: portForwardings, to: configuration) + if !portForwardings.isEmpty { + try Self.configureIPv4Addressing(addressing, for: macAddress, to: configuration) + try Self.applyPortForwarding(rules: portForwardings, internalAddress: addressing.guestAddress, to: configuration) + } var networkStatus: vmnet_return_t = .VMNET_FAILURE guard let network = vmnet_network_create(configuration, &networkStatus) else { @@ -62,7 +57,7 @@ import Virtualization } deinit { - vmnet_network_release(network) + Self.release(network) } func attachments() -> [VZNetworkDeviceAttachment] { @@ -78,23 +73,65 @@ import Virtualization // attachment as part of its normal shutdown. } - // FIXME(macOS 27 SDK): wire up the actual vmnet port-forwarding setter. - // - // WWDC26 session 224 advertises "forward host TCP/UDP ports to specific - // VMs" as part of the new vmnet_network_configuration_t surface but does - // not show the exact C symbol. Once the final Xcode 27 SDK ships, replace - // the body below with the real calls (likely shaped like - // `vmnet_network_configuration_add_port_forwarding_rule(configuration, - // protocol, externalPort, internalPort, &status)`). - // - // For now, refuse to start a VM with port-forwarding rules so the failure - // mode is loud rather than silently dropped traffic. private static func applyPortForwarding( rules: [PortForwarding], - to configuration: vmnet_network_configuration_t + internalAddress: in_addr, + to configuration: vmnet_network_configuration_ref ) throws { - guard !rules.isEmpty else { return } - throw NetworkVmnetError.PortForwardingPendingSDKFinalization + for rule in rules { + var internalAddress = internalAddress + let status = withUnsafePointer(to: &internalAddress) { + vmnet_network_configuration_add_port_forwarding_rule( + configuration, + rule.vmnetProtocol, + sa_family_t(AF_INET), + rule.internalPort, + rule.externalPort, + UnsafeRawPointer($0) + ) + } + + guard status == .VMNET_SUCCESS else { + throw NetworkVmnetError.PortForwardingConfigurationFailed(rule: rule, status: status) + } + } + } + + private static func configureIPv4Addressing( + _ addressing: IPv4Addressing, + for macAddress: MACAddress, + to configuration: vmnet_network_configuration_ref + ) throws { + var subnet = addressing.subnet + var mask = addressing.mask + let subnetStatus = withUnsafePointer(to: &subnet) { subnetPointer in + withUnsafePointer(to: &mask) { maskPointer in + vmnet_network_configuration_set_ipv4_subnet(configuration, subnetPointer, maskPointer) + } + } + guard subnetStatus == .VMNET_SUCCESS else { + throw NetworkVmnetError.IPv4SubnetConfigurationFailed(status: subnetStatus) + } + + var guestAddress = addressing.guestAddress + var etherAddress = ether_addr_t( + octet: ( + macAddress.mac[0], + macAddress.mac[1], + macAddress.mac[2], + macAddress.mac[3], + macAddress.mac[4], + macAddress.mac[5] + ) + ) + let reservationStatus = withUnsafePointer(to: ðerAddress) { macPointer in + withUnsafePointer(to: &guestAddress) { guestAddressPointer in + vmnet_network_configuration_add_dhcp_reservation(configuration, macPointer, guestAddressPointer) + } + } + guard reservationStatus == .VMNET_SUCCESS else { + throw NetworkVmnetError.DHCPReservationConfigurationFailed(status: reservationStatus) + } } static func parsePortForwardings(_ spec: String) throws -> [PortForwarding] { @@ -130,13 +167,84 @@ import Virtualization return PortForwarding(proto: proto, externalPort: external, internalPort: internalPort) } + + private struct IPv4Addressing { + let subnet: in_addr + let mask: in_addr + let guestAddress: in_addr + } + + private static func addressing(for macAddress: MACAddress) throws -> IPv4Addressing { + let thirdOctet = subnetThirdOctet(for: macAddress) + + return try IPv4Addressing( + subnet: ipv4Address("192.168.\(thirdOctet).0"), + mask: ipv4Address("255.255.255.0"), + guestAddress: ipv4Address("192.168.\(thirdOctet).2") + ) + } + + private static func subnetThirdOctet(for macAddress: MACAddress) -> UInt8 { + var hash: UInt32 = 2_166_136_261 + for byte in macAddress.mac { + hash ^= UInt32(byte) + hash &*= 16_777_619 + } + + // Keep forwarded VMs on stable per-MAC subnets and avoid bridge100's common default. + var octet = UInt8((hash % 253) + 2) + if octet == 64 { + octet = 65 + } + return octet + } + + private static func ipv4Address(_ string: String) throws -> in_addr { + var address = in_addr() + guard inet_pton(AF_INET, string, &address) == 1 else { + throw NetworkVmnetError.InvalidIPv4Address(string) + } + return address + } + + private static func parseMACAddress(_ string: String) throws -> MACAddress { + guard let macAddress = MACAddress(fromString: string) else { + throw NetworkVmnetError.InvalidMACAddress(string) + } + return macAddress + } + + private static func release(_ pointer: OpaquePointer) { + Unmanaged.fromOpaque(UnsafeRawPointer(pointer)).release() + } + } + + @available(macOS 26, *) + extension NetworkVmnet.PortForwarding: CustomStringConvertible { + fileprivate var vmnetProtocol: UInt8 { + switch proto { + case .tcp: + return UInt8(IPPROTO_TCP) + case .udp: + return UInt8(IPPROTO_UDP) + } + } + + var description: String { + "\(externalPort):\(internalPort)/\(proto.rawValue)" + } } + @available(macOS 26, *) enum NetworkVmnetError: Error, CustomStringConvertible { case ConfigurationCreationFailed(status: vmnet_return_t) case NetworkCreationFailed(status: vmnet_return_t) + case IPv4SubnetConfigurationFailed(status: vmnet_return_t) + case DHCPReservationConfigurationFailed(status: vmnet_return_t) + case PortForwardingConfigurationFailed(rule: NetworkVmnet.PortForwarding, status: vmnet_return_t) case InvalidPortForwardingSpec(spec: String, why: String) - case PortForwardingPendingSDKFinalization + case InvalidIPv4Address(String) + case InvalidMACAddress(String) var description: String { switch self { @@ -146,9 +254,16 @@ import Virtualization return "vmnet_network_create() failed with status \(status)" case .InvalidPortForwardingSpec(let spec, let why): return "invalid port forwarding spec \"\(spec)\": \(why)" - case .PortForwardingPendingSDKFinalization: - return "--net-vmnet-expose is not yet wired through to the macOS 27 vmnet port-forwarding API; " - + "use --net-softnet-expose for now or remove the rule" + case .IPv4SubnetConfigurationFailed(let status): + return "vmnet_network_configuration_set_ipv4_subnet() failed with status \(status)" + case .DHCPReservationConfigurationFailed(let status): + return "vmnet_network_configuration_add_dhcp_reservation() failed with status \(status)" + case .PortForwardingConfigurationFailed(let rule, let status): + return "vmnet_network_configuration_add_port_forwarding_rule(\(rule)) failed with status \(status)" + case .InvalidIPv4Address(let address): + return "invalid IPv4 address \"\(address)\"" + case .InvalidMACAddress(let macAddress): + return "invalid MAC address \"\(macAddress)\"" } } } diff --git a/Tests/TartTests/NetworkVmnetTests.swift b/Tests/TartTests/NetworkVmnetTests.swift index 90e2d9de..c52fcd7f 100644 --- a/Tests/TartTests/NetworkVmnetTests.swift +++ b/Tests/TartTests/NetworkVmnetTests.swift @@ -2,7 +2,7 @@ import XCTest @testable import tart #if compiler(>=6.4) - @available(macOS 27, *) + @available(macOS 26, *) final class NetworkVmnetTests: XCTestCase { func testParsesSingleTCPRule() throws { let rules = try NetworkVmnet.parsePortForwardings("2222:22") From 001cfca5658f852c81f7cd3e71cc9f4b49d31b7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Pi=C3=B1era=20Buend=C3=ADa?= Date: Tue, 16 Jun 2026 16:42:18 +0200 Subject: [PATCH 3/3] feat(tart): label virtual machines on macOS 27 --- Sources/tart/VM.swift | 19 ++++++++++++++++++- Tests/TartTests/VMTests.swift | 23 +++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 Tests/TartTests/VMTests.swift diff --git a/Sources/tart/VM.swift b/Sources/tart/VM.swift index 77ef4576..c5cc89f6 100644 --- a/Sources/tart/VM.swift +++ b/Sources/tart/VM.swift @@ -66,6 +66,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { self.network = network configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL, vmConfig: config, + label: name, network: network, additionalStorageDevices: additionalStorageDevices, directorySharingDevices: directorySharingDevices, serialPorts: serialPorts, @@ -197,7 +198,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { // Initialize the virtual machine and its configuration self.network = network configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL, - vmConfig: config, network: network, + vmConfig: config, label: name, network: network, additionalStorageDevices: additionalStorageDevices, directorySharingDevices: directorySharingDevices, serialPorts: serialPorts @@ -315,6 +316,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { diskURL: URL, nvramURL: URL, vmConfig: VMConfig, + label: String? = nil, network: Network = NetworkShared(), additionalStorageDevices: [VZStorageDeviceConfiguration], directorySharingDevices: [VZDirectorySharingDeviceConfiguration], @@ -444,11 +446,26 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { // Socket device configuration.socketDevices = [VZVirtioSocketDeviceConfiguration()] + #if compiler(>=6.4) + if #available(macOS 27, *), let label, let virtualMachineLabel = Self.virtualMachineLabel(for: label) { + configuration.label = virtualMachineLabel + } + #endif + try configuration.validate() return configuration } + static func virtualMachineLabel(for name: String) -> String? { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedName.contains(where: { !$0.isWhitespace }) else { + return nil + } + + return String(trimmedName.prefix(64)) + } + func guestDidStop(_ virtualMachine: VZVirtualMachine) { print("guest has stopped the virtual machine") sema.signal() diff --git a/Tests/TartTests/VMTests.swift b/Tests/TartTests/VMTests.swift new file mode 100644 index 00000000..82407d2a --- /dev/null +++ b/Tests/TartTests/VMTests.swift @@ -0,0 +1,23 @@ +import XCTest +@testable import tart + +final class VMTests: XCTestCase { + func testVirtualMachineLabelUsesVMName() { + XCTAssertEqual(VM.virtualMachineLabel(for: "macos-runner"), "macos-runner") + } + + func testVirtualMachineLabelTrimsWhitespace() { + XCTAssertEqual(VM.virtualMachineLabel(for: " macos-runner "), "macos-runner") + } + + func testVirtualMachineLabelRejectsWhitespaceOnlyName() { + XCTAssertNil(VM.virtualMachineLabel(for: " ")) + } + + func testVirtualMachineLabelCapsAtSixtyFourCharacters() { + XCTAssertEqual( + VM.virtualMachineLabel(for: String(repeating: "a", count: 65)), + String(repeating: "a", count: 64) + ) + } +}