From 3a6c5fb81dc53389856603c77d1d4e33e42f2dc2 Mon Sep 17 00:00:00 2001 From: Fedor Korotkov Date: Thu, 19 Jun 2025 10:27:30 -0400 Subject: [PATCH] feat: Add disk image format selection with ASIF support (#1094) * feat: Add disk image format selection with ASIF support * fixed goreleaser-pro * Fix ASIF disk format compatibility issues - Use .uncached caching mode for ASIF disks to avoid Virtualization framework compatibility issues - Improve caching mode selection logic for better maintainability - Fix compiler warning by changing var to let for attachment variable This resolves VM startup failures when using ASIF disk format by ensuring proper disk attachment configuration. * Update goreleaser installation to use tap-specific formula Change from 'brew install --cask goreleaser-pro' to 'brew install --cask goreleaser/tap/goreleaser-pro' for proper installation from the official goreleaser tap. * Remove VS Code configuration and add to gitignore - Remove .vscode/launch.json from repository - Add .vscode/ to .gitignore to prevent VS Code settings from being tracked * Implement ASIF disk resize using diskutil - Add support for resizing ASIF disk images using diskutil image resize - Detect disk format from VM config and route to appropriate resize method - Use diskutil image info to get current ASIF disk size and validate resize - Remove restriction that prevented ASIF disk resizing in Set command - Add FailedToResizeDisk error case for proper error handling - Maintain backward compatibility with raw disk resizing - Add comprehensive size validation to prevent data loss * Update Sources/tart/Commands/Create.swift Co-authored-by: Nikolay Edigaryev * Update Sources/tart/DiskImageFormat.swift Co-authored-by: Nikolay Edigaryev * Update Sources/tart/DiskImageFormat.swift Co-authored-by: Nikolay Edigaryev * Fix test naming and remove redundant test cases - Rename testFormatArgument to testCaseInsensitivity for clarity - Remove redundant 'raw' and 'invalid' test cases already covered in testFormatFromString - Remove testFormatDescriptions test as it's not very useful Addresses review comment: https://github.com/cirruslabs/tart/pull/1094#discussion_r2152093510 * Remove canCreate property and simplify DiskImageFormat - Remove canCreate property since it's the same as isSupported - Remove description property entirely as it's not used - Fix displayName for RAW format (remove UDIF reference) - Remove checkDiskutilASIFSupport helper function Addresses review comments: - https://github.com/cirruslabs/tart/pull/1094#discussion_r2152109450 - https://github.com/cirruslabs/tart/pull/1094#discussion_r2152115610 - https://github.com/cirruslabs/tart/pull/1094#discussion_r2152124330 * Update Create command validation and help text - Simplify ArgumentParser help text to let it show possible values automatically - Remove canCreate validation since property was removed - Simplify error message for unsupported disk formats Addresses review comment: https://github.com/cirruslabs/tart/pull/1094#discussion_r2152113480 * Add disk format validation to Run command - Add validation to ensure ASIF disk format is supported on current system - Check disk format compatibility before attempting to run VM Addresses review comment: https://github.com/cirruslabs/tart/pull/1094#discussion_r2152109450 * Use proper namespaced constant for OCI label - Add diskFormatLabelAnnotation constant in Manifest.swift - Use org.cirruslabs.tart.disk.format namespace for consistency - Use variable shadowing instead of new variable name for labels Addresses review comment: https://github.com/cirruslabs/tart/pull/1094#discussion_r2152163515 * Remove special ASIF caching mode - Remove .uncached caching mode for ASIF disks - Use default caching logic for all disk formats - Testing shows .cached mode works fine on macOS 26.0 Addresses review comment: https://github.com/cirruslabs/tart/pull/1094#discussion_r2152133589 * Improve code structure in VMDirectory - Use guard let instead of nested if let for better readability - Reduce nesting in resizeASIFDisk function - Improve error handling flow Addresses review comment: https://github.com/cirruslabs/tart/pull/1094#discussion_r2152141916 * diskFormatLabel * reverted caching mode * Use PropertyListDecoder --------- Co-authored-by: Nikolay Edigaryev --- .gitignore | 3 + Sources/tart/Commands/Create.swift | 12 +- Sources/tart/Commands/Get.swift | 3 +- Sources/tart/Commands/Run.swift | 6 + Sources/tart/DiskImageFormat.swift | 43 +++++ Sources/tart/OCI/Manifest.swift | 3 + Sources/tart/VM.swift | 12 +- Sources/tart/VMConfig.swift | 9 +- Sources/tart/VMDirectory+OCI.swift | 4 + Sources/tart/VMDirectory.swift | 198 ++++++++++++++++++++- Sources/tart/VMStorageHelper.swift | 6 + Tests/TartTests/DiskImageFormatTests.swift | 67 +++++++ 12 files changed, 353 insertions(+), 13 deletions(-) create mode 100644 Sources/tart/DiskImageFormat.swift create mode 100644 Tests/TartTests/DiskImageFormatTests.swift diff --git a/.gitignore b/.gitignore index d272a9e..21d2e7b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ tart.xcodeproj/ # AppCode .idea/ +# VS Code +.vscode/ + # Swift .build/ diff --git a/Sources/tart/Commands/Create.swift b/Sources/tart/Commands/Create.swift index 495572f..3c81a6b 100644 --- a/Sources/tart/Commands/Create.swift +++ b/Sources/tart/Commands/Create.swift @@ -19,6 +19,9 @@ struct Create: AsyncParsableCommand { @Option(help: ArgumentHelp("Disk size in GB")) var diskSize: UInt16 = 50 + @Option(help: ArgumentHelp("Disk image format", discussion: "ASIF format provides better performance but requires macOS 26 Tahoe or later")) + var diskFormat: DiskImageFormat = .raw + func validate() throws { if fromIPSW == nil && !linux { throw ValidationError("Please specify either a --from-ipsw or --linux option!") @@ -28,6 +31,11 @@ struct Create: AsyncParsableCommand { throw ValidationError("Only Linux VMs are supported on Intel!") } #endif + + // Validate disk format support + if !diskFormat.isSupported { + throw ValidationError("Disk format '\(diskFormat.rawValue)' is not supported on this system.") + } } func run() async throws { @@ -58,12 +66,12 @@ struct Create: AsyncParsableCommand { ipswURL = URL(fileURLWithPath: NSString(string: fromIPSW).expandingTildeInPath) } - _ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize) + _ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize, diskFormat: diskFormat) } #endif if linux { - _ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize) + _ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize, diskFormat: diskFormat) } try VMStorageLocal().move(name, from: tmpVMDir) diff --git a/Sources/tart/Commands/Get.swift b/Sources/tart/Commands/Get.swift index 1dba89d..7ffada6 100644 --- a/Sources/tart/Commands/Get.swift +++ b/Sources/tart/Commands/Get.swift @@ -6,6 +6,7 @@ fileprivate struct VMInfo: Encodable { let CPU: Int let Memory: UInt64 let Disk: Int + let DiskFormat: String let Size: String let Display: String let Running: Bool @@ -26,7 +27,7 @@ struct Get: AsyncParsableCommand { let vmConfig = try VMConfig(fromURL: vmDir.configURL) let memorySizeInMb = vmConfig.memorySize / 1024 / 1024 - let info = VMInfo(OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.sizeGB(), Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000), Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state().rawValue) + let info = VMInfo(OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.sizeGB(), DiskFormat: vmConfig.diskFormat.rawValue, Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000), Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state().rawValue) print(format.renderSingle(info)) } } diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index b00c706..1f3e142 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -345,6 +345,12 @@ struct Run: AsyncParsableCommand { let localStorage = VMStorageLocal() let vmDir = try localStorage.open(name) + // 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 diff --git a/Sources/tart/DiskImageFormat.swift b/Sources/tart/DiskImageFormat.swift new file mode 100644 index 0000000..36b6641 --- /dev/null +++ b/Sources/tart/DiskImageFormat.swift @@ -0,0 +1,43 @@ +import Foundation +import ArgumentParser + +enum DiskImageFormat: String, CaseIterable, Codable { + case raw = "raw" + case asif = "asif" + + var displayName: String { + switch self { + case .raw: + return "RAW" + case .asif: + return "ASIF (Apple Sparse Image Format)" + } + } + + + /// Check if the format is supported on the current system + var isSupported: Bool { + switch self { + case .raw: + return true + case .asif: + if #available(macOS 15, *) { + return true + } else { + return false + } + } + } + + +} + +extension DiskImageFormat: ExpressibleByArgument { + init?(argument: String) { + self.init(rawValue: argument.lowercased()) + } + + static var allValueStrings: [String] { + return allCases.map { $0.rawValue } + } +} diff --git a/Sources/tart/OCI/Manifest.swift b/Sources/tart/OCI/Manifest.swift index 2d04f0c..363ab6a 100644 --- a/Sources/tart/OCI/Manifest.swift +++ b/Sources/tart/OCI/Manifest.swift @@ -14,6 +14,9 @@ let nvramMediaType = "application/vnd.cirruslabs.tart.nvram.v1" let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size" let uploadTimeAnnotation = "org.cirruslabs.tart.upload-time" +// Manifest labels +let diskFormatLabel = "org.cirruslabs.tart.disk.format" + // Layer annotations let uncompressedSizeAnnotation = "org.cirruslabs.tart.uncompressed-size" let uncompressedContentDigestAnnotation = "org.cirruslabs.tart.uncompressed-content-digest" diff --git a/Sources/tart/VM.swift b/Sources/tart/VM.swift index c8ea492..15b3cc9 100644 --- a/Sources/tart/VM.swift +++ b/Sources/tart/VM.swift @@ -143,6 +143,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { vmDir: VMDirectory, ipswURL: URL, diskSizeGB: UInt16, + diskFormat: DiskImageFormat = .raw, network: Network = NetworkShared(), additionalStorageDevices: [VZStorageDeviceConfiguration] = [], directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [], @@ -175,14 +176,15 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { _ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel) // Create disk - try vmDir.resizeDisk(diskSizeGB) + try vmDir.resizeDisk(diskSizeGB, format: diskFormat) name = vmDir.name // Create config config = VMConfig( platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel), cpuCountMin: requirements.minimumSupportedCPUCount, - memorySizeMin: requirements.minimumSupportedMemorySize + memorySizeMin: requirements.minimumSupportedMemorySize, + diskFormat: diskFormat ) // allocate at least 4 CPUs because otherwise VMs are frequently freezing try config.setCPU(cpuCount: max(4, requirements.minimumSupportedCPUCount)) @@ -224,15 +226,15 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { #endif @available(macOS 13, *) - static func linux(vmDir: VMDirectory, diskSizeGB: UInt16) async throws -> VM { + static func linux(vmDir: VMDirectory, diskSizeGB: UInt16, diskFormat: DiskImageFormat = .raw) async throws -> VM { // Create NVRAM _ = try VZEFIVariableStore(creatingVariableStoreAt: vmDir.nvramURL) // Create disk - try vmDir.resizeDisk(diskSizeGB) + try vmDir.resizeDisk(diskSizeGB, format: diskFormat) // Create config - let config = VMConfig(platform: Linux(), cpuCountMin: 4, memorySizeMin: 4096 * 1024 * 1024) + let config = VMConfig(platform: Linux(), cpuCountMin: 4, memorySizeMin: 4096 * 1024 * 1024, diskFormat: diskFormat) try config.save(toURL: vmDir.configURL) return try VM(vmDir: vmDir) diff --git a/Sources/tart/VMConfig.swift b/Sources/tart/VMConfig.swift index 869895d..198cb24 100644 --- a/Sources/tart/VMConfig.swift +++ b/Sources/tart/VMConfig.swift @@ -25,6 +25,7 @@ enum CodingKeys: String, CodingKey { case macAddress case display case displayRefit + case diskFormat // macOS-specific keys case ecid @@ -54,12 +55,14 @@ struct VMConfig: Codable { var macAddress: VZMACAddress var display: VMDisplayConfig = VMDisplayConfig() var displayRefit: Bool? + var diskFormat: DiskImageFormat = .raw init( platform: Platform, cpuCountMin: Int, memorySizeMin: UInt64, - macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered() + macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered(), + diskFormat: DiskImageFormat = .raw ) { self.os = platform.os() self.arch = CurrentArchitecture() @@ -67,6 +70,7 @@ struct VMConfig: Codable { self.macAddress = macAddress self.cpuCountMin = cpuCountMin self.memorySizeMin = memorySizeMin + self.diskFormat = diskFormat cpuCount = cpuCountMin memorySize = memorySizeMin } @@ -124,6 +128,8 @@ struct VMConfig: Codable { display = try container.decodeIfPresent(VMDisplayConfig.self, forKey: .display) ?? VMDisplayConfig() displayRefit = try container.decodeIfPresent(Bool.self, forKey: .displayRefit) + let diskFormatString = try container.decodeIfPresent(String.self, forKey: .diskFormat) ?? "raw" + diskFormat = DiskImageFormat(rawValue: diskFormatString) ?? .raw } func encode(to encoder: Encoder) throws { @@ -142,6 +148,7 @@ struct VMConfig: Codable { if let displayRefit = displayRefit { try container.encode(displayRefit, forKey: .displayRefit) } + try container.encode(diskFormat.rawValue, forKey: .diskFormat) } mutating func setCPU(cpuCount: Int) throws { diff --git a/Sources/tart/VMDirectory+OCI.swift b/Sources/tart/VMDirectory+OCI.swift index 71ef930..6240853 100644 --- a/Sources/tart/VMDirectory+OCI.swift +++ b/Sources/tart/VMDirectory+OCI.swift @@ -92,6 +92,10 @@ extension VMDirectory { // Read VM's config and push it as blob let config = try VMConfig(fromURL: configURL) + + // Add disk format label automatically + var labels = labels + labels[diskFormatLabel] = config.diskFormat.rawValue let configJSON = try JSONEncoder().encode(config) defaultLogger.appendNewLine("pushing config...") let configDigest = try await registry.pushBlob(fromData: configJSON, chunkSizeMb: chunkSizeMb) diff --git a/Sources/tart/VMDirectory.swift b/Sources/tart/VMDirectory.swift index b198bb9..36d224e 100644 --- a/Sources/tart/VMDirectory.swift +++ b/Sources/tart/VMDirectory.swift @@ -2,6 +2,25 @@ import Foundation import Virtualization import CryptoKit +// MARK: - Disk Image Info Structures +struct DiskImageInfo: Codable { + let sizeInfo: SizeInfo? + let size: UInt64? + + enum CodingKeys: String, CodingKey { + case sizeInfo = "Size Info" + case size = "Size" + } +} + +struct SizeInfo: Codable { + let totalBytes: UInt64? + + enum CodingKeys: String, CodingKey { + case totalBytes = "Total Bytes" + } +} + struct VMDirectory: Prunable { enum State: String { case Running = "running" @@ -142,14 +161,34 @@ struct VMDirectory: Prunable { try vmConfig.save(toURL: configURL) } - func resizeDisk(_ sizeGB: UInt16) throws { - if !FileManager.default.fileExists(atPath: diskURL.path) { - FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil) - } + func resizeDisk(_ sizeGB: UInt16, format: DiskImageFormat = .raw) throws { + let diskExists = FileManager.default.fileExists(atPath: diskURL.path) + if diskExists { + // Existing disk - resize it + try resizeExistingDisk(sizeGB) + } else { + // New disk - create it with the specified format + try createDisk(sizeGB: sizeGB, format: format) + } + } + + private func resizeExistingDisk(_ sizeGB: UInt16) throws { + // Check if this is an ASIF disk by reading the VM config + let vmConfig = try VMConfig(fromURL: configURL) + + if vmConfig.diskFormat == .asif { + try resizeASIFDisk(sizeGB) + } else { + try resizeRawDisk(sizeGB) + } + } + + private func resizeRawDisk(_ sizeGB: UInt16) throws { let diskFileHandle = try FileHandle.init(forWritingTo: diskURL) let currentDiskFileLength = try diskFileHandle.seekToEnd() let desiredDiskFileLength = UInt64(sizeGB) * 1000 * 1000 * 1000 + if desiredDiskFileLength < currentDiskFileLength { let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentDiskFileLength)) let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredDiskFileLength)) @@ -161,6 +200,157 @@ struct VMDirectory: Prunable { try diskFileHandle.close() } + private func resizeASIFDisk(_ sizeGB: UInt16) throws { + guard let diskutilURL = resolveBinaryPath("diskutil") else { + throw RuntimeError.FailedToResizeDisk("diskutil not found in PATH") + } + + // First, get current disk image info to check current size + let infoProcess = Process() + infoProcess.executableURL = diskutilURL + infoProcess.arguments = ["image", "info", "--plist", diskURL.path] + + let infoPipe = Pipe() + infoProcess.standardOutput = infoPipe + infoProcess.standardError = infoPipe + + do { + try infoProcess.run() + infoProcess.waitUntilExit() + + let infoData = infoPipe.fileHandleForReading.readDataToEndOfFile() + + if infoProcess.terminationStatus != 0 { + let output = String(data: infoData, encoding: .utf8) ?? "Unknown error" + throw RuntimeError.FailedToResizeDisk("Failed to get ASIF disk info: \(output)") + } + + // Parse the plist using PropertyListDecoder + do { + let diskImageInfo = try PropertyListDecoder().decode(DiskImageInfo.self, from: infoData) + + // Extract current size from the decoded structure + var currentSizeBytes: UInt64? + + // Try to get size from Size Info -> Total Bytes first + if let totalBytes = diskImageInfo.sizeInfo?.totalBytes { + currentSizeBytes = totalBytes + } else if let size = diskImageInfo.size { + // Fallback to top-level Size field + currentSizeBytes = size + } + + guard let currentSizeBytes = currentSizeBytes else { + throw RuntimeError.FailedToResizeDisk("Could not find size information in disk image info") + } + + let desiredSizeBytes = UInt64(sizeGB) * 1000 * 1000 * 1000 + + if desiredSizeBytes < currentSizeBytes { + let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentSizeBytes)) + let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredSizeBytes)) + throw RuntimeError.InvalidDiskSize("new disk size of \(desiredLengthHuman) should be larger " + + "than the current disk size of \(currentLengthHuman)") + } else if desiredSizeBytes > currentSizeBytes { + // Resize the ASIF disk image using diskutil + try performASIFResize(sizeGB) + } + // If sizes are equal, no action needed + } catch let error as RuntimeError { + throw error + } catch { + let outputString = String(data: infoData, encoding: .utf8) ?? "Unable to decode output" + throw RuntimeError.FailedToResizeDisk("Failed to parse disk image info: \(error). Output: \(outputString)") + } + } catch { + throw RuntimeError.FailedToResizeDisk("Failed to get disk image info: \(error)") + } + } + + private func performASIFResize(_ sizeGB: UInt16) throws { + guard let diskutilURL = resolveBinaryPath("diskutil") else { + throw RuntimeError.FailedToResizeDisk("diskutil not found in PATH") + } + + let process = Process() + process.executableURL = diskutilURL + process.arguments = [ + "image", "resize", + "--size", "\(sizeGB)G", + diskURL.path + ] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + do { + try process.run() + process.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + + if process.terminationStatus != 0 { + let output = String(data: data, encoding: .utf8) ?? "Unknown error" + throw RuntimeError.FailedToResizeDisk("Failed to resize ASIF disk image: \(output)") + } + } catch { + throw RuntimeError.FailedToResizeDisk("Failed to execute diskutil resize: \(error)") + } + } + + private func createDisk(sizeGB: UInt16, format: DiskImageFormat) throws { + switch format { + case .raw: + try createRawDisk(sizeGB: sizeGB) + case .asif: + try createASIFDisk(sizeGB: sizeGB) + } + } + + private func createRawDisk(sizeGB: UInt16) throws { + // Create traditional raw disk image + FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil) + + let diskFileHandle = try FileHandle.init(forWritingTo: diskURL) + let desiredDiskFileLength = UInt64(sizeGB) * 1000 * 1000 * 1000 + try diskFileHandle.truncate(atOffset: desiredDiskFileLength) + try diskFileHandle.close() + } + + private func createASIFDisk(sizeGB: UInt16) throws { + guard let diskutilURL = resolveBinaryPath("diskutil") else { + throw RuntimeError.FailedToCreateDisk("diskutil not found in PATH") + } + + let process = Process() + process.executableURL = diskutilURL + process.arguments = [ + "image", "create", "blank", + "--format", "ASIF", + "--size", "\(sizeGB)G", + "--volumeName", "Tart", + diskURL.path + ] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + do { + try process.run() + process.waitUntilExit() + + if process.terminationStatus != 0 { + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "Unknown error" + throw RuntimeError.FailedToCreateDisk("Failed to create ASIF disk image: \(output)") + } + } catch { + throw RuntimeError.FailedToCreateDisk("Failed to execute diskutil: \(error)") + } + } + func delete() throws { let lock = try lock() diff --git a/Sources/tart/VMStorageHelper.swift b/Sources/tart/VMStorageHelper.swift index 788e81d..09ebc98 100644 --- a/Sources/tart/VMStorageHelper.swift +++ b/Sources/tart/VMStorageHelper.swift @@ -60,6 +60,8 @@ enum RuntimeError : Error { case DiskAlreadyInUse(_ message: String) case FailedToOpenBlockDevice(_ path: String, _ explanation: String) case InvalidDiskSize(_ message: String) + case FailedToCreateDisk(_ message: String) + case FailedToResizeDisk(_ message: String) case FailedToUpdateAccessDate(_ message: String) case PIDLockFailed(_ message: String) case PIDLockMissing(_ message: String) @@ -109,6 +111,10 @@ extension RuntimeError : CustomStringConvertible { return "failed to open block device \(path): \(explanation)" case .InvalidDiskSize(let message): return message + case .FailedToCreateDisk(let message): + return message + case .FailedToResizeDisk(let message): + return message case .FailedToUpdateAccessDate(let message): return message case .PIDLockFailed(let message): diff --git a/Tests/TartTests/DiskImageFormatTests.swift b/Tests/TartTests/DiskImageFormatTests.swift new file mode 100644 index 0000000..bc78a4d --- /dev/null +++ b/Tests/TartTests/DiskImageFormatTests.swift @@ -0,0 +1,67 @@ +import XCTest +@testable import tart + +final class DiskImageFormatTests: XCTestCase { + func testRawFormatIsAlwaysSupported() throws { + XCTAssertTrue(DiskImageFormat.raw.isSupported) + } + + func testASIFFormatSupport() throws { + // ASIF should be supported on macOS 15+ + if #available(macOS 15, *) { + XCTAssertTrue(DiskImageFormat.asif.isSupported) + } else { + XCTAssertFalse(DiskImageFormat.asif.isSupported) + } + } + + func testFormatFromString() throws { + XCTAssertEqual(DiskImageFormat(rawValue: "raw"), .raw) + XCTAssertEqual(DiskImageFormat(rawValue: "asif"), .asif) + XCTAssertNil(DiskImageFormat(rawValue: "invalid")) + } + + func testCaseInsensitivity() throws { + XCTAssertEqual(DiskImageFormat(argument: "ASIF"), .asif) // case insensitive + XCTAssertEqual(DiskImageFormat(argument: "Raw"), .raw) // case insensitive + } + + func testAllValueStrings() throws { + let allValues = DiskImageFormat.allValueStrings + XCTAssertTrue(allValues.contains("raw")) + XCTAssertTrue(allValues.contains("asif")) + XCTAssertEqual(allValues.count, 2) + } + + func testVMConfigDiskFormatSerialization() throws { + // Test that VMConfig properly serializes and deserializes disk format + let config = VMConfig( + platform: Linux(), + cpuCountMin: 2, + memorySizeMin: 1024 * 1024 * 1024, + diskFormat: .asif + ) + + XCTAssertEqual(config.diskFormat, .asif) + + // Test JSON encoding/decoding + let encoder = JSONEncoder() + let data = try encoder.encode(config) + + let decoder = JSONDecoder() + let decodedConfig = try decoder.decode(VMConfig.self, from: data) + + XCTAssertEqual(decodedConfig.diskFormat, .asif) + } + + func testVMConfigDefaultDiskFormat() throws { + // Test that VMConfig defaults to raw format + let config = VMConfig( + platform: Linux(), + cpuCountMin: 2, + memorySizeMin: 1024 * 1024 * 1024 + ) + + XCTAssertEqual(config.diskFormat, .raw) + } +}