Files
UTM/Configuration/UTMAppleConfigurationDrive.swift
osy 2d48f084e6 apple(disk): stack disk image snapshots as layers
DiskImageKit can put a copy-on-write layer on an image and remembers what
each layer sits on. Use that to freeze the contents of a drive: the layer
holding what the guest has written so far is closed off under the name of a
snapshot and a fresh one is opened above it for further writes.

Layers are kept beside the image as `<image>.<name>.asif`. A layer whose
snapshot is deleted cannot be merged away while other layers are stacked on
it, so it is only marked as unlinked and is swept once nothing needs it.

Attachments are built through the same path so that a drive with layers is
opened as the whole stack, and a disposable run adds its throwaway layer on
top of it.

A stack is only ever opened whole. When one of its layers cannot be read the
drive is not attached at all: opening the rest without it would hide what it
holds, and falling back to the bare image would write underneath every layer
stacked on it. For the same reason a layer to write to is always put in place
before the one it replaces goes, and the image under a stack cannot be resized.
Nor can a drive with layers be attached read only, which a stack cannot be, or
before macOS 27, which cannot read the layers: the image alone is the drive as
it was before the first snapshot. Removable media never has layers.

Assisted-by: Claude:claude-fable-5-1
Assisted-by: Claude:claude-opus-5
2026-09-22 10:57:29 -05:00

158 lines
6.1 KiB
Swift

//
// Copyright © 2022 osy. All rights reserved.
//
// 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
//
// http://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 Virtualization
@available(iOS, unavailable, message: "Apple Virtualization not available on iOS")
struct UTMAppleConfigurationDrive: UTMConfigurationDrive {
private let bytesInMib = 1048576
var sizeMib: Int = 0
var isReadOnly: Bool
var isExternal: Bool
var isNvme: Bool
var imageURL: URL?
var imageName: String?
var isASIF: Bool = false // not saved
private(set) var id = UUID().uuidString
var isRawImage: Bool {
true // always true for Apple VMs
}
private enum CodingKeys: String, CodingKey {
case isReadOnly = "ReadOnly"
case isNvme = "Nvme"
case imageName = "ImageName"
case bookmark = "Bookmark" // legacy only
case identifier = "Identifier"
}
var sizeString: String {
let sizeBytes: Int64
if let attributes = try? imageURL?.resourceValues(forKeys: [.fileSizeKey]), let fileSize = attributes.fileSize {
sizeBytes = Int64(fileSize)
} else {
sizeBytes = Int64(sizeMib) * Int64(bytesInMib)
}
return ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .binary)
}
init(newSize: Int) {
sizeMib = newSize
isReadOnly = false
isExternal = false
isNvme = false
}
init(existingURL url: URL?, isExternal: Bool = false, isNvme: Bool = false) {
self.imageURL = url
self.isReadOnly = isExternal
self.isExternal = isExternal
self.isNvme = isNvme
}
init(from decoder: Decoder) throws {
guard let dataURL = decoder.userInfo[.dataURL] as? URL else {
throw UTMConfigurationError.invalidDataURL
}
let container = try decoder.container(keyedBy: CodingKeys.self)
if let imageName = try container.decodeIfPresent(String.self, forKey: .imageName) {
self.imageName = imageName
imageURL = dataURL.appendingPathComponent(imageName)
isExternal = false
} else if let bookmark = try container.decodeIfPresent(Data.self, forKey: .bookmark) {
var stale: Bool = false
imageURL = try? URL(resolvingBookmarkData: bookmark, options: .withSecurityScope, bookmarkDataIsStale: &stale)
imageName = imageURL?.lastPathComponent
isExternal = true
} else {
imageURL = nil
imageName = nil
isExternal = true
}
isReadOnly = try container.decodeIfPresent(Bool.self, forKey: .isReadOnly) ?? isExternal
isNvme = try container.decodeIfPresent(Bool.self, forKey: .isNvme) ?? false
id = try container.decode(String.self, forKey: .identifier)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
if !isExternal {
try container.encodeIfPresent(imageName, forKey: .imageName)
}
try container.encode(isReadOnly, forKey: .isReadOnly)
try container.encode(isNvme, forKey: .isNvme)
try container.encode(id, forKey: .identifier)
}
/// Create the storage attachment for this drive
/// - Parameters:
/// - useFsWorkAround: Use cached caching mode for virtio drives to prevent fs corruption on Linux
/// - disposableOverlayDirectoryURL: If set, guest writes go to an ephemeral layer in this directory and the image is never modified (macOS 27+)
func vzDiskImage(useFsWorkAround: Bool = false, disposableOverlayDirectoryURL: URL? = nil) throws -> VZDiskImageStorageDeviceAttachment? {
guard let imageURL = imageURL else {
return nil
}
// Use cached caching mode for virtio drive to prevent fs corruption on linux when possible
let useCachedMode = !isNvme && useFsWorkAround
if #available(macOS 27, *) {
let overlayURL = isReadOnly ? nil : disposableOverlayDirectoryURL?.appendingPathComponent("\(id).asif")
if let attachment = try UTMAppleDiskImage.attachment(for: imageURL, includesLayers: !isExternal, ephemeralOverlayAt: overlayURL, isReadOnly: isReadOnly, cachingMode: useCachedMode ? .cached : .automatic, synchronizationMode: .full) {
return attachment
}
} else if !isExternal && !UTMAppleDiskImage.layerFileURLs(of: imageURL).isEmpty {
throw UTMAppleDiskImageError.snapshotsRequireNewerOS
}
if useCachedMode {
return try VZDiskImageStorageDeviceAttachment(url: imageURL, readOnly: isReadOnly, cachingMode: .cached, synchronizationMode: .full)
} else {
return try VZDiskImageStorageDeviceAttachment(url: imageURL, readOnly: isReadOnly)
}
}
func hash(into hasher: inout Hasher) {
imageName?.hash(into: &hasher)
sizeMib.hash(into: &hasher)
isReadOnly.hash(into: &hasher)
isNvme.hash(into: &hasher)
isExternal.hash(into: &hasher)
id.hash(into: &hasher)
isASIF.hash(into: &hasher)
}
func clone() -> UTMAppleConfigurationDrive {
var cloned = self
cloned.id = UUID().uuidString
return cloned
}
}
// MARK: - Conversion of old config format
@available(iOS, unavailable, message: "Apple Virtualization not available on iOS")
extension UTMAppleConfigurationDrive {
init(migrating oldDrive: DiskImage) {
sizeMib = oldDrive.sizeMib
isReadOnly = oldDrive.isReadOnly
isExternal = oldDrive.isExternal
isNvme = false
imageURL = oldDrive.imageURL
}
}