91e75e620b
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
292 lines
11 KiB
Swift
292 lines
11 KiB
Swift
import Foundation
|
|
import Testing
|
|
|
|
@testable import lume
|
|
|
|
// MARK: - Mock growers
|
|
|
|
@MainActor
|
|
final class NoopGrower: ContainerGrower {
|
|
var inspected = false
|
|
var grewToRecoveryFirst: UInt64?
|
|
func inspect(diskPath: URL) throws { inspected = true }
|
|
func grow(diskPath: URL, newRecoveryFirstLBA: UInt64) throws {
|
|
grewToRecoveryFirst = newRecoveryFirstLBA
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class ThrowingGrower: ContainerGrower {
|
|
func inspect(diskPath: URL) throws {}
|
|
func grow(diskPath: URL, newRecoveryFirstLBA: UInt64) throws {
|
|
throw DiskResizeError.containerGrowFailed("simulated Phase 5 failure")
|
|
}
|
|
}
|
|
|
|
// MARK: - Fixtures for a full VM dir
|
|
|
|
@MainActor
|
|
enum ResizerFixtures {
|
|
/// Builds a temp VM directory containing a fixture `disk.img` and a valid
|
|
/// macOS `config.json`, returning the directory, the resizer, and the disk URL.
|
|
static func makeVMDir(
|
|
totalSectors: UInt64 = GPTFixtures.defaultTotalSectors,
|
|
oldDiskSize: UInt64,
|
|
grower: ContainerGrower
|
|
) throws -> (vmDir: VMDirectory, resizer: MacDiskResizer, diskURL: URL) {
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("lume-vm-\(UUID().uuidString)")
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
|
|
let (fixtureURL, _) = try GPTFixtures.makeDisk(totalSectors: totalSectors)
|
|
let diskURL = root.appendingPathComponent("disk.img")
|
|
try FileManager.default.moveItem(at: fixtureURL, to: diskURL)
|
|
|
|
let vmDir = VMDirectory(Path(root.path))
|
|
let config = try VMConfig(os: "macOS", diskSize: oldDiskSize, display: "1024x768")
|
|
try vmDir.saveConfig(config)
|
|
|
|
let resizer = MacDiskResizer(
|
|
vmDir: vmDir, diskPath: diskURL, vmName: vmDir.name, grower: grower)
|
|
return (vmDir, resizer, diskURL)
|
|
}
|
|
|
|
static func remove(_ vmDir: VMDirectory) {
|
|
try? FileManager.default.removeItem(atPath: vmDir.dir.path)
|
|
try? FileManager.default.removeItem(atPath: vmDir.resizeGuardPath.path)
|
|
}
|
|
}
|
|
|
|
// MARK: - Happy path
|
|
|
|
@MainActor
|
|
@Suite("MacDiskResizer happy path")
|
|
struct MacDiskResizerHappyPathTests {
|
|
// 64 MiB → 96 MiB.
|
|
let oldSize: UInt64 = 64 * 1024 * 1024
|
|
let newSize: UInt64 = 96 * 1024 * 1024
|
|
|
|
@Test("expands, relocates recovery byte-identically, updates config and cleans up")
|
|
func happyPath() throws {
|
|
let grower = NoopGrower()
|
|
let (vmDir, resizer, diskURL) = try ResizerFixtures.makeVMDir(
|
|
oldDiskSize: oldSize, grower: grower)
|
|
defer { ResizerFixtures.remove(vmDir) }
|
|
|
|
// Capture the pre-move recovery hash from the original layout.
|
|
let preHandle = try FileHandle(forReadingFrom: diskURL)
|
|
let preTable = try GPTTable.read(from: preHandle, fileSizeBytes: oldSize)
|
|
let preLayout = try MacDiskLayout.fingerprint(preTable, fileSizeBytes: oldSize)
|
|
let preHash = try SectorCopier.hashRange(
|
|
fd: preHandle.fileDescriptor,
|
|
startLBA: preLayout.recovery.startingLBA,
|
|
lengthSectors: preLayout.recoveryLength)
|
|
try preHandle.close()
|
|
|
|
try resizer.resize(to: newSize, options: DiskResizeOptions())
|
|
|
|
// File grew.
|
|
#expect(GPTFixtures.fileSize(diskURL) == newSize)
|
|
|
|
// GPT re-fingerprints with recovery at the new tail; hash preserved.
|
|
let handle = try FileHandle(forReadingFrom: diskURL)
|
|
defer { try? handle.close() }
|
|
let table = try GPTTable.read(from: handle, fileSizeBytes: newSize)
|
|
let layout = try MacDiskLayout.fingerprint(table, fileSizeBytes: newSize)
|
|
let expectedTotal = newSize / 512
|
|
#expect(layout.totalSectors == expectedTotal)
|
|
#expect(table.header.lastUsableLBA == expectedTotal - 34)
|
|
#expect(layout.recovery.startingLBA > preLayout.recovery.endingLBA)
|
|
#expect(layout.recovery.startingLBA % 8 == 0)
|
|
#expect(layout.recoveryLength == preLayout.recoveryLength)
|
|
// Same recovery identity (unique GUID + name preserved).
|
|
#expect(layout.recovery.uniqueGUID == preLayout.recovery.uniqueGUID)
|
|
#expect(layout.recovery.nameRaw == preLayout.recovery.nameRaw)
|
|
// Recovery hugs LastUsable within 7 sectors.
|
|
#expect(table.header.lastUsableLBA - layout.recovery.endingLBA <= 7)
|
|
|
|
let postHash = try SectorCopier.hashRange(
|
|
fd: handle.fileDescriptor,
|
|
startLBA: layout.recovery.startingLBA,
|
|
lengthSectors: layout.recoveryLength)
|
|
#expect(postHash == preHash)
|
|
|
|
// Grower was driven with the relocated recovery start.
|
|
#expect(grower.grewToRecoveryFirst == layout.recovery.startingLBA)
|
|
|
|
// Config updated; marker + backup cleaned up.
|
|
let config = try vmDir.loadConfig()
|
|
#expect(config.diskSize == newSize)
|
|
#expect(!vmDir.hasResizeMarker())
|
|
#expect(!FileManager.default.fileExists(atPath: vmDir.diskBackupPath.path))
|
|
}
|
|
|
|
@Test("--keep-backup leaves the pre-resize backup in place")
|
|
func keepBackup() throws {
|
|
let (vmDir, resizer, _) = try ResizerFixtures.makeVMDir(
|
|
oldDiskSize: oldSize, grower: NoopGrower())
|
|
defer { ResizerFixtures.remove(vmDir) }
|
|
try resizer.resize(to: newSize, options: DiskResizeOptions(backup: true, keepBackup: true))
|
|
#expect(FileManager.default.fileExists(atPath: vmDir.diskBackupPath.path))
|
|
#expect(!vmDir.hasResizeMarker())
|
|
}
|
|
|
|
@Test("dry-run leaves disk and configuration unchanged")
|
|
func dryRun() throws {
|
|
let (vmDir, resizer, diskURL) = try ResizerFixtures.makeVMDir(
|
|
oldDiskSize: oldSize, grower: NoopGrower())
|
|
defer { ResizerFixtures.remove(vmDir) }
|
|
let original = try Data(contentsOf: diskURL)
|
|
try resizer.resize(to: newSize, options: DiskResizeOptions(dryRun: true))
|
|
#expect(try Data(contentsOf: diskURL) == original)
|
|
#expect(try vmDir.loadConfig().diskSize == oldSize)
|
|
#expect(!vmDir.hasResizeMarker())
|
|
}
|
|
}
|
|
|
|
// MARK: - Guards & rollback
|
|
|
|
@MainActor
|
|
@Suite("MacDiskResizer guards and rollback")
|
|
struct MacDiskResizerGuardTests {
|
|
let oldSize: UInt64 = 64 * 1024 * 1024
|
|
|
|
@Test("shrink is refused")
|
|
func shrink() throws {
|
|
let (vmDir, resizer, _) = try ResizerFixtures.makeVMDir(
|
|
oldDiskSize: oldSize, grower: NoopGrower())
|
|
defer { ResizerFixtures.remove(vmDir) }
|
|
#expect(throws: DiskResizeError.self) {
|
|
try resizer.resize(to: 32 * 1024 * 1024, options: DiskResizeOptions())
|
|
}
|
|
#expect(!vmDir.hasResizeMarker())
|
|
}
|
|
|
|
@Test("growth smaller than the recovery relocation minimum is refused")
|
|
func growthTooSmall() throws {
|
|
let (vmDir, resizer, _) = try ResizerFixtures.makeVMDir(
|
|
oldDiskSize: oldSize, grower: NoopGrower())
|
|
defer { ResizerFixtures.remove(vmDir) }
|
|
// +1 MiB is far below recovery(≈4 MiB) + slop.
|
|
#expect(throws: DiskResizeError.self) {
|
|
try resizer.resize(to: oldSize + 1024 * 1024, options: DiskResizeOptions())
|
|
}
|
|
#expect(!vmDir.hasResizeMarker())
|
|
}
|
|
|
|
@Test("a held config.json lock is treated as a running VM")
|
|
func runningLock() throws {
|
|
let (vmDir, resizer, _) = try ResizerFixtures.makeVMDir(
|
|
oldDiskSize: oldSize, grower: NoopGrower())
|
|
defer { ResizerFixtures.remove(vmDir) }
|
|
let lock = try FileHandle(forWritingTo: vmDir.configPath.url)
|
|
defer {
|
|
flock(lock.fileDescriptor, LOCK_UN)
|
|
try? lock.close()
|
|
}
|
|
|
|
#expect(flock(lock.fileDescriptor, LOCK_EX | LOCK_NB) == 0)
|
|
#expect(throws: DiskResizeError.self) {
|
|
try resizer.resize(to: 96 * 1024 * 1024, options: DiskResizeOptions())
|
|
}
|
|
}
|
|
|
|
@Test("a shared VM-operation guard blocks resize")
|
|
func sharedGuardBlocksResize() throws {
|
|
let (vmDir, resizer, _) = try ResizerFixtures.makeVMDir(
|
|
oldDiskSize: oldSize, grower: NoopGrower())
|
|
defer { ResizerFixtures.remove(vmDir) }
|
|
let acquired = try vmDir.tryAcquireResizeGuard(exclusive: false)
|
|
let guardHandle = try #require(acquired)
|
|
defer {
|
|
flock(guardHandle.fileDescriptor, LOCK_UN)
|
|
try? guardHandle.close()
|
|
}
|
|
#expect(throws: DiskResizeError.self) {
|
|
try resizer.resize(to: 96 * 1024 * 1024, options: DiskResizeOptions())
|
|
}
|
|
}
|
|
|
|
@Test("a Phase 5 failure rolls back to the byte-identical original disk")
|
|
func rollbackOnGrowFailure() throws {
|
|
let (vmDir, resizer, diskURL) = try ResizerFixtures.makeVMDir(
|
|
oldDiskSize: oldSize, grower: ThrowingGrower())
|
|
defer { ResizerFixtures.remove(vmDir) }
|
|
let original = try Data(contentsOf: diskURL)
|
|
|
|
#expect(throws: DiskResizeError.self) {
|
|
try resizer.resize(to: 96 * 1024 * 1024, options: DiskResizeOptions())
|
|
}
|
|
// Disk restored byte-for-byte; marker + backup gone; config unchanged.
|
|
let restored = try Data(contentsOf: diskURL)
|
|
#expect(restored == original)
|
|
#expect(!vmDir.hasResizeMarker())
|
|
#expect(!FileManager.default.fileExists(atPath: vmDir.diskBackupPath.path))
|
|
#expect(try vmDir.loadConfig().diskSize == oldSize)
|
|
}
|
|
|
|
@Test("an interrupted transaction with a backup auto-recovers on next invocation")
|
|
func recoverPending() throws {
|
|
let (vmDir, resizer, diskURL) = try ResizerFixtures.makeVMDir(
|
|
oldDiskSize: oldSize, grower: NoopGrower())
|
|
defer { ResizerFixtures.remove(vmDir) }
|
|
let original = try Data(contentsOf: diskURL)
|
|
|
|
// Simulate a crash mid-transaction: a backup exists and a marker is armed,
|
|
// while the live disk has been mangled.
|
|
_ = Darwin.clonefile(diskURL.path, vmDir.diskBackupPath.path, 0)
|
|
try FileManager.default.copyItem(
|
|
at: vmDir.configPath.url, to: vmDir.configBackupPath.url)
|
|
let mangle = try FileHandle(forWritingTo: diskURL)
|
|
try mangle.seek(toOffset: 512)
|
|
try mangle.write(contentsOf: Data(repeating: 0xAB, count: 512))
|
|
try mangle.close()
|
|
try vmDir.saveResizeMarker(
|
|
ResizeMarker(
|
|
version: 1, phase: 4, oldSizeBytes: oldSize, newSizeBytes: 96 * 1024 * 1024,
|
|
backupDiskPath: vmDir.diskBackupPath.path, startedAt: 0))
|
|
|
|
#expect(try resizer.recoverPendingIfAny() == true)
|
|
#expect(try Data(contentsOf: diskURL) == original)
|
|
#expect(!vmDir.hasResizeMarker())
|
|
}
|
|
|
|
@Test("a malformed resize marker fails closed")
|
|
func malformedMarker() throws {
|
|
let (vmDir, resizer, _) = try ResizerFixtures.makeVMDir(
|
|
oldDiskSize: oldSize, grower: NoopGrower())
|
|
defer { ResizerFixtures.remove(vmDir) }
|
|
try Data("not-json".utf8).write(to: vmDir.resizeMarkerPath.url)
|
|
#expect(throws: DiskResizeError.self) {
|
|
try resizer.resize(to: 96 * 1024 * 1024, options: DiskResizeOptions())
|
|
}
|
|
#expect(vmDir.hasResizeMarker())
|
|
}
|
|
}
|
|
|
|
// MARK: - VMDirectory.setDisk regression
|
|
|
|
@Suite("VMDirectory.setDisk")
|
|
struct VMDirectorySetDiskTests {
|
|
private func tempVMDir() throws -> VMDirectory {
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("lume-setdisk-\(UUID().uuidString)")
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
return VMDirectory(Path(root.path))
|
|
}
|
|
|
|
@Test("grow succeeds; shrink throws instead of silently corrupting")
|
|
func growThenShrink() throws {
|
|
let vmDir = try tempVMDir()
|
|
defer { try? FileManager.default.removeItem(atPath: vmDir.dir.path) }
|
|
try vmDir.setDisk(10 * 1024 * 1024)
|
|
#expect(GPTFixtures.fileSize(vmDir.diskPath.url) == 10 * 1024 * 1024)
|
|
try vmDir.setDisk(20 * 1024 * 1024) // grow OK
|
|
#expect(GPTFixtures.fileSize(vmDir.diskPath.url) == 20 * 1024 * 1024)
|
|
#expect(throws: VMDirectoryError.self) {
|
|
try vmDir.setDisk(5 * 1024 * 1024) // shrink refused
|
|
}
|
|
#expect(GPTFixtures.fileSize(vmDir.diskPath.url) == 20 * 1024 * 1024)
|
|
}
|
|
}
|