diff --git a/Sources/tart/Commands/Clone.swift b/Sources/tart/Commands/Clone.swift index f58c364..f993c18 100644 --- a/Sources/tart/Commands/Clone.swift +++ b/Sources/tart/Commands/Clone.swift @@ -106,6 +106,8 @@ struct Clone: AsyncParsableCommand { try rejectExistingDestination(localStorage) let sourceState = try sourceVM.state() + let sourceConfig = try VMConfig(fromURL: sourceVM.configURL) + let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress()) && sourceState != .Suspended @@ -128,6 +130,10 @@ struct Clone: AsyncParsableCommand { try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC) } + if sourceState != .Suspended, sourceConfig.platform is Linux { + try tmpVMDir.regenerateLinuxMachineIdentifier() + } + try localStorage.move(newName, from: tmpVMDir) try lock.unlock() diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index 1c965e1..9435062 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -285,7 +285,9 @@ struct Run: AsyncParsableCommand { var rootDiskOpts: String = "" #if arch(arm64) - @Flag(help: ArgumentHelp("Disables audio and entropy devices and switches to only Mac-specific input devices.", discussion: "Useful for running a VM that can be suspended via \"tart suspend\".")) + @Flag( + help: ArgumentHelp("Disables or replaces devices that do not support VM suspension, such as audio, entropy and some input devices.", + discussion: "Useful for running a VM that can be suspended via \"tart suspend\".")) #endif var suspendable: Bool = false @@ -374,7 +376,11 @@ struct Run: AsyncParsableCommand { if suspendable { let config = try VMConfig.init(fromURL: vmDir.configURL) if !(config.platform is PlatformSuspendable) { - throw ValidationError("You can only suspend macOS VMs") + throw ValidationError("This platform is not suspendable") + } + + if let linux = config.platform as? Linux, linux.machineIdentifier == nil { + throw ValidationError("Linux VMs without a machine identifier cannot be suspended or resumed") } if noTrackpad { @@ -421,14 +427,15 @@ struct Run: AsyncParsableCommand { let localStorage = try VMStorageLocal() let vmDir = try localStorage.open(name) + let storageLock = try FileLock(lockURL: Config().tartHomeDir) + try storageLock.lock() + // Validate disk format support let vmConfig = try VMConfig(fromURL: vmDir.configURL) if !vmConfig.diskFormat.isSupported { throw ValidationError("Disk format '\(vmConfig.diskFormat.rawValue)' is not supported on this system.") } - let storageLock = try FileLock(lockURL: Config().tartHomeDir) - try storageLock.lock() // check if there is a running VM with the same MAC address let hasRunningMACCollision = try localStorage.list().contains { // check if there is a running VM with the same MAC but different name @@ -440,6 +447,23 @@ struct Run: AsyncParsableCommand { try vmDir.regenerateMACAddress() } + // check if there is a running VM with the same Linux machine identifier but different name + if let linuxMachineIdentifier = (vmConfig.platform as? Linux)?.machineIdentifier { + let hasRunningLinuxMachineIdentifierCollision = try localStorage.list().contains { + if try !$1.running() || $1.name == vmDir.name { + return false + } + let otherConfig = try VMConfig(fromURL: $1.configURL) + return (otherConfig.platform as? Linux)?.machineIdentifier == linuxMachineIdentifier + } + + if hasRunningLinuxMachineIdentifierCollision { + print("There is already a running VM with the same Linux machine identifier!") + print("Resetting VM to assign a new Linux machine identifier...") + try vmDir.regenerateLinuxMachineIdentifier() + } + } + if netSoftnet && isInteractiveSession() { try Softnet.configureSUIDBitIfNeeded() } diff --git a/Sources/tart/Platform/Linux.swift b/Sources/tart/Platform/Linux.swift index 10772c9..3f0b353 100644 --- a/Sources/tart/Platform/Linux.swift +++ b/Sources/tart/Platform/Linux.swift @@ -1,7 +1,42 @@ import Virtualization @available(macOS 13, *) -struct Linux: Platform { +struct Linux: PlatformSuspendable { + + var machineIdentifier: VZGenericMachineIdentifier? + + init(machineIdentifier: VZGenericMachineIdentifier = VZGenericMachineIdentifier()) { + self.machineIdentifier = machineIdentifier + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + guard let encodedMachineIdentifier = try container.decodeIfPresent( + String.self, forKey: .machineIdentifier + ) else { + self.machineIdentifier = nil + return + } + guard let data = Data.init(base64Encoded: encodedMachineIdentifier) else { + throw DecodingError.dataCorruptedError(forKey: .machineIdentifier, + in: container, + debugDescription: "failed to initialize Data using the provided value") + } + guard let machineIdentifier = VZGenericMachineIdentifier.init(dataRepresentation: data) else { + throw DecodingError.dataCorruptedError(forKey: .machineIdentifier, + in: container, + debugDescription: "failed to initialize VZGenericMachineIdentifier using the provided value") + } + self.machineIdentifier = machineIdentifier + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(machineIdentifier?.dataRepresentation.base64EncodedString(), forKey: .machineIdentifier) + } + func os() -> OS { .linux } @@ -16,6 +51,11 @@ struct Linux: Platform { func platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration { let config = VZGenericPlatformConfiguration() + + if let machineIdentifier { + config.machineIdentifier = machineIdentifier + } + if #available(macOS 15, *) { config.isNestedVirtualizationEnabled = needsNestedVirtualization } @@ -47,4 +87,14 @@ struct Linux: Platform { // Linux doesn't support trackpad, so just return the regular pointing devices return pointingDevices(noUSB: noUSB) } + + func pointingDevicesSuspendable(noUSB: Bool) -> [VZPointingDeviceConfiguration] { + // VZUSBScreenCoordinatePointingDeviceConfiguration passes save/restore + // validation, but causes restoring a Linux VM to fail with "invalid argument". + [] + } + + func keyboardsSuspendable(noUSB: Bool) -> [VZKeyboardConfiguration] { + keyboards(noUSB: noUSB) + } } diff --git a/Sources/tart/VMConfig.swift b/Sources/tart/VMConfig.swift index 7fd58d9..e9630eb 100644 --- a/Sources/tart/VMConfig.swift +++ b/Sources/tart/VMConfig.swift @@ -30,6 +30,9 @@ enum CodingKeys: String, CodingKey { // macOS-specific keys case ecid case hardwareModel + + // Linux-specific keys + case machineIdentifier } struct VMDisplayConfig: Codable, Equatable { diff --git a/Sources/tart/VMDirectory.swift b/Sources/tart/VMDirectory.swift index 7f5ad83..92bb03d 100644 --- a/Sources/tart/VMDirectory.swift +++ b/Sources/tart/VMDirectory.swift @@ -230,6 +230,21 @@ struct VMDirectory: Prunable { try vmConfig.save(toURL: configURL) } + func regenerateLinuxMachineIdentifier() throws { + var vmConfig = try VMConfig(fromURL: configURL) + guard var vmLinux = vmConfig.platform as? Linux else { + throw RuntimeError.VMConfigurationError("cannot regenerate a Linux machine identifier on a non-Linux VM") + } + + vmLinux.machineIdentifier = VZGenericMachineIdentifier() + vmConfig.platform = vmLinux + + // cleanup state if any + try? FileManager.default.removeItem(at: stateURL) + + try vmConfig.save(toURL: configURL) + } + func resizeDisk( _ sizeGB: UInt16, format: DiskImageFormat = .raw, diff --git a/Tests/TartTests/VMConfigTests.swift b/Tests/TartTests/VMConfigTests.swift index 34fb901..5684670 100644 --- a/Tests/TartTests/VMConfigTests.swift +++ b/Tests/TartTests/VMConfigTests.swift @@ -1,6 +1,9 @@ import XCTest @testable import tart +import Foundation +import Virtualization + final class VMConfigTests: XCTestCase { func testVMDisplayConfig() throws { // Defaults units (points) @@ -15,4 +18,53 @@ final class VMConfigTests: XCTestCase { vmDisplayConfig = VMDisplayConfig.init(argument: "1234x5678px") XCTAssertEqual(VMDisplayConfig(width: 1234, height: 5678, unit: .pixel), vmDisplayConfig) } + + func testLinuxMachineIdentifierSerialization() throws { + let originalIdentifier = VZGenericMachineIdentifier() + let originalConfig = VMConfig( + platform: Linux(machineIdentifier: originalIdentifier), cpuCountMin: 2, memorySizeMin: 1024 * 1024 * 1024 + ) + let encodedConfigData = try originalConfig.toJSON() + + let decodedConfig = try VMConfig(fromJSON: encodedConfigData) + let decodedLinux = try XCTUnwrap(decodedConfig.platform as? Linux) + let decodedMachineIdentifier = try XCTUnwrap(decodedLinux.machineIdentifier) + + XCTAssertEqual( + decodedMachineIdentifier.dataRepresentation, + originalIdentifier.dataRepresentation, + "decoded machine identifier should match original identifier" + ) + + let platformConfiguration = try decodedLinux.platform( + nvramURL: URL(fileURLWithPath: "/dev/null"), + needsNestedVirtualization: false + ) + let decodedPlatformConfiguration = try XCTUnwrap( + platformConfiguration as? VZGenericPlatformConfiguration + ) + + XCTAssertEqual( + decodedPlatformConfiguration.machineIdentifier.dataRepresentation, + originalIdentifier.dataRepresentation, + "platform configuration should reuse decoded machine identifier" + ) + } + + func testLegacyLinuxConfigWithoutMachineIdentifier() throws { + let originalIdentifier = VZGenericMachineIdentifier() + let originalConfig = VMConfig( + platform: Linux(machineIdentifier: originalIdentifier), cpuCountMin: 2, memorySizeMin: 1024 * 1024 * 1024 + ) + let encodedConfigData = try originalConfig.toJSON() + + var configJSONObject = try XCTUnwrap(JSONSerialization.jsonObject(with: encodedConfigData) as? [String: Any]) + configJSONObject.removeValue(forKey: "machineIdentifier") + let legacyConfigData = try JSONSerialization.data(withJSONObject: configJSONObject) + + let decodedConfig = try VMConfig(fromJSON: legacyConfigData) + let decodedLinux = try XCTUnwrap(decodedConfig.platform as? Linux) + + XCTAssertNil(decodedLinux.machineIdentifier, "missing machineIdentifier should be decoded as nil") + } } diff --git a/Tests/TartTests/VMDirectoryTests.swift b/Tests/TartTests/VMDirectoryTests.swift new file mode 100644 index 0000000..3fbf62c --- /dev/null +++ b/Tests/TartTests/VMDirectoryTests.swift @@ -0,0 +1,56 @@ +import Foundation +import Virtualization +import XCTest +@testable import tart + +final class VMDirectoryTests: XCTestCase { + + func testRegenerateLinuxMachineIdentifierReplacesIdentifierAndDeletesState() throws { + let vmDir = try temporaryVMDirectory() + let originalIdentifier = VZGenericMachineIdentifier() + let originalConfig = try writeLinuxConfig(machineIdentifier: originalIdentifier, to: vmDir) + try touch(vmDir.stateURL) + + try vmDir.regenerateLinuxMachineIdentifier() + + let config = try VMConfig(fromURL: vmDir.configURL) + let linux = try XCTUnwrap(config.platform as? Linux) + let newIdentifier = try XCTUnwrap(linux.machineIdentifier) + XCTAssertNotEqual(newIdentifier.dataRepresentation, originalIdentifier.dataRepresentation) + XCTAssertFalse(FileManager.default.fileExists(atPath: vmDir.stateURL.path)) + XCTAssertEqual(config.macAddress.string, originalConfig.macAddress.string) + } + + func testRegenerateLinuxMachineIdentifierAssignsMissingIdentifier() throws { + let vmDir = try temporaryVMDirectory() + try writeLinuxConfig(machineIdentifier: nil, to: vmDir) + + try vmDir.regenerateLinuxMachineIdentifier() + + let linux = try XCTUnwrap(VMConfig(fromURL: vmDir.configURL).platform as? Linux) + XCTAssertNotNil(linux.machineIdentifier) + } + + @discardableResult + private func writeLinuxConfig(machineIdentifier: VZGenericMachineIdentifier?, to vmDir: VMDirectory) throws -> VMConfig { + var linux = Linux() + linux.machineIdentifier = machineIdentifier + let config = VMConfig(platform: linux, cpuCountMin: 2, memorySizeMin: 1024 * 1024 * 1024) + try config.save(toURL: vmDir.configURL) + return config + } + + private func temporaryVMDirectory() throws -> VMDirectory { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false) + addTeardownBlock { + try? FileManager.default.removeItem(at: url) + } + + return VMDirectory(baseURL: url) + } + + private func touch(_ url: URL) throws { + XCTAssertTrue(FileManager.default.createFile(atPath: url.path, contents: Data())) + } +}