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
This commit is contained in:
osy
2026-09-22 10:57:29 -05:00
parent a021240ab3
commit 2d48f084e6
2 changed files with 264 additions and 13 deletions
@@ -111,10 +111,15 @@ struct UTMAppleConfigurationDrive: UTMConfigurationDrive {
}
// Use cached caching mode for virtio drive to prevent fs corruption on linux when possible
let useCachedMode = !isNvme && useFsWorkAround
if #available(macOS 27, *), let overlayDirectoryURL = disposableOverlayDirectoryURL, !isReadOnly {
let overlayURL = overlayDirectoryURL.appendingPathComponent("\(id).asif")
return try UTMAppleDiskImage.attachment(for: imageURL, ephemeralOverlayAt: overlayURL, cachingMode: useCachedMode ? .cached : .automatic, synchronizationMode: .full)
} else if useCachedMode {
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)
+255 -9
View File
@@ -48,6 +48,7 @@ enum UTMAppleDiskImage {
}
private static let bytesInMib = 1048576
fileprivate static let layerFileExtension = "asif"
/// Block size used when creating images, matches the legacy DiskImages2 path
private static let blockSize = 512
@@ -95,12 +96,25 @@ enum UTMAppleDiskImage {
}
}
/// Files beside an image that its snapshots stacked on it, found by name so that this works
/// where the layers themselves cannot be read
static func layerFileURLs(of imageURL: URL) -> [URL] {
let prefix = imageURL.lastPathComponent + "."
// an image outside of the bundle may be in a directory that cannot be read
let siblings = (try? FileManager.default.contentsOfDirectory(at: imageURL.deletingLastPathComponent(), includingPropertiesForKeys: nil)) ?? []
return siblings.filter { $0.lastPathComponent.hasPrefix(prefix) && $0.pathExtension == layerFileExtension }
}
/// Change the size of the virtual disk of an existing image
/// - Parameters:
/// - url: Location of the image
/// - sizeMib: New size of the virtual disk in MiB
@available(macOS 14, *)
static func resize(_ url: URL, toSizeMib sizeMib: Int) throws {
// every layer stacked on the image was built on it as it is
guard layerFileURLs(of: url).isEmpty else {
throw UTMAppleDiskImageError.hasSnapshots
}
if #available(macOS 27, *) {
let image = try DiskImageKit.DiskImage(opening: .open(url: url, mode: .readWrite))
try image.truncate(blockCount: sizeMib * bytesInMib / image.blockSize.rawValue)
@@ -114,19 +128,251 @@ enum UTMAppleDiskImage {
@available(macOS 27, *)
extension UTMAppleDiskImage {
/// Create an attachment whose guest writes land in a new copy-on-write layer
/// Copy-on-write layer stacked on the image of a drive.
///
/// The image at `url` is opened read-only and is never modified. Any existing file at `overlayURL` is replaced.
/// Layers are kept next to the image as `<image>.<name>.asif`. Each one only holds what was
/// written while it was on top, so it depends on the layers below it.
struct Layer: Equatable {
/// Name of the layer that guest writes go to
static let currentName = "current"
fileprivate static let fileExtension = layerFileExtension
fileprivate static let unlinkedMarker = "unlinked"
let url: URL
/// `currentName` or the snapshot the layer was frozen as
let name: String
/// Not a snapshot anymore but still below other layers
let isUnlinked: Bool
let uuid: UUID
/// Layer right below this one, `nil` if that is the image itself
let parentUUID: UUID?
var isCurrent: Bool {
name == Self.currentName
}
fileprivate static func url(for imageURL: URL, name: String, isUnlinked: Bool = false) -> URL {
var url = imageURL.appendingPathExtension(name)
if isUnlinked {
url.appendPathExtension(unlinkedMarker)
}
return url.appendingPathExtension(fileExtension)
}
}
/// Find the layers of an image.
///
/// The current layer is left out while it cannot be opened because a VM is using it.
static func layers(of imageURL: URL) throws -> [Layer] {
let prefix = imageURL.lastPathComponent + "."
let candidates = layerFileURLs(of: imageURL)
guard !candidates.isEmpty else {
// most images have no layers, leave them alone
return []
}
let baseUUID = try DiskImageKit.DiskImage(opening: .open(url: imageURL, mode: .readOnly)).layerUUID
return candidates.compactMap { url -> Layer? in
var name = String(url.deletingPathExtension().lastPathComponent.dropFirst(prefix.count))
let isUnlinked = name.hasSuffix("." + Layer.unlinkedMarker)
if isUnlinked {
name.removeLast(Layer.unlinkedMarker.count + 1)
}
guard !name.isEmpty, let image = try? DiskImageKit.DiskImage(opening: .open(url: url, mode: .readOnly)), let uuid = image.layerUUID else {
return nil
}
// an image without an identifier cannot be told apart from no layer
let parentUUID = image.parentUUID == baseUUID ? nil : image.parentUUID
return Layer(url: url, name: name, isUnlinked: isUnlinked, uuid: uuid, parentUUID: parentUUID)
}
}
/// Layers from the one right above the image up to `top`
///
/// - Throws: `UTMAppleDiskImageError.layerMissing` when a layer below `top` cannot be read,
/// because opening the rest without it would hide everything that layer holds.
private static func chain(endingAt top: Layer?, in layers: [Layer]) throws -> [Layer] {
var chain = [Layer]()
var next = top
while let layer = next, !chain.contains(layer) {
chain.insert(layer, at: 0)
next = layers.first { $0.uuid == layer.parentUUID }
guard next != nil || layer.parentUUID == nil else {
throw UTMAppleDiskImageError.layerMissing
}
}
return chain
}
/// Open an image along with some of its layers, all of them read-only unless `isTopWritable`.
private static func open(_ imageURL: URL, chain: [Layer], isTopWritable: Bool = false) throws -> DiskImageKit.DiskImage {
var image = try DiskImageKit.DiskImage(opening: .open(url: imageURL, mode: chain.isEmpty && isTopWritable ? .readWrite : .readOnly))
for layer in chain {
let isWritable = isTopWritable && layer == chain.last
image = try image.appending(DiskImageKit.DiskImage(opening: .open(url: layer.url, mode: isWritable ? .readWrite : .readOnly)))
}
return image
}
/// Put a new layer on top of `chain` that guest writes go to from now on.
private static func createCurrentLayer(of imageURL: URL, above chain: [Layer]) throws {
let fileManager = FileManager.default
let url = Layer.url(for: imageURL, name: Layer.currentName)
// built under a name that is not taken for a layer, so the one it replaces stays until it exists
let pendingURL = imageURL.deletingLastPathComponent().appendingPathComponent(".\(UUID().uuidString).\(Layer.fileExtension)")
do {
_ = try open(imageURL, chain: chain).appending(ASIFCreationConfiguration.layer(url: pendingURL, type: .overlay))
if fileManager.fileExists(atPath: url.path) {
_ = try fileManager.replaceItemAt(url, withItemAt: pendingURL)
} else {
try fileManager.moveItem(at: pendingURL, to: url)
}
} catch {
try? fileManager.removeItem(at: pendingURL)
throw error
}
}
/// Create an attachment for an image that has layers or that should not be modified.
///
/// Any existing file at `overlayURL` is replaced.
/// - Parameters:
/// - url: Location of the base image
/// - overlayURL: Location of the overlay layer to create
/// - url: Location of the image
/// - includesLayers: Look for layers, which snapshots only stack on the images in the bundle
/// - overlayURL: If set, guest writes land in a new layer created here and nothing else is modified
/// - isReadOnly: The drive may not be written to, which a stack cannot be attached as
/// - cachingMode: Caching mode of the attachment
/// - synchronizationMode: Synchronization mode of the attachment
static func attachment(for url: URL, ephemeralOverlayAt overlayURL: URL, cachingMode: VZDiskImageCachingMode = .automatic, synchronizationMode: VZDiskImageSynchronizationMode = .full) throws -> VZDiskImageStorageDeviceAttachment {
try? FileManager.default.removeItem(at: overlayURL)
let base = try DiskImageKit.DiskImage(opening: .open(url: url, mode: .readOnly))
let stack = try base.appending(ASIFCreationConfiguration.layer(url: overlayURL, type: .overlay))
return try VZDiskImageStorageDeviceAttachment(diskImage: stack, cachingMode: cachingMode, synchronizationMode: synchronizationMode)
/// - Returns: `nil` if the image has no layers and can be attached by itself
static func attachment(for url: URL, includesLayers: Bool, ephemeralOverlayAt overlayURL: URL? = nil, isReadOnly: Bool = false, cachingMode: VZDiskImageCachingMode = .automatic, synchronizationMode: VZDiskImageSynchronizationMode = .full) throws -> VZDiskImageStorageDeviceAttachment? {
let layers = includesLayers ? try layers(of: url) : []
let current = layers.first { $0.isCurrent }
// writing to the image itself is only safe while nothing is stacked on it, otherwise it
// would change what every layer above it was built on
guard current != nil || !includesLayers || layerFileURLs(of: url).isEmpty else {
throw UTMAppleDiskImageError.layerMissing
}
let chain = try chain(endingAt: current, in: layers)
if let overlayURL = overlayURL {
try? FileManager.default.removeItem(at: overlayURL)
let stack = try open(url, chain: chain).appending(ASIFCreationConfiguration.layer(url: overlayURL, type: .overlay))
return try VZDiskImageStorageDeviceAttachment(diskImage: stack, cachingMode: cachingMode, synchronizationMode: synchronizationMode)
} else if chain.isEmpty {
return nil
} else if isReadOnly {
// the image alone is the drive as it was before the first snapshot
throw UTMAppleDiskImageError.readOnlyWithSnapshots
} else {
return try VZDiskImageStorageDeviceAttachment(diskImage: try open(url, chain: chain, isTopWritable: true), cachingMode: cachingMode, synchronizationMode: synchronizationMode)
}
}
/// Freeze what has been written to an image so far and direct further writes to a new layer.
///
/// The image must not be in use.
/// - Parameters:
/// - imageURL: Location of the image
/// - name: Name of the snapshot
static func createSnapshot(of imageURL: URL, name: String) throws {
let fileManager = FileManager.default
let snapshotURL = Layer.url(for: imageURL, name: name)
let current = try layers(of: imageURL).first(where: { $0.isCurrent })
// once there are layers only the one being written to may be frozen, or they would be lost
guard current != nil || layerFileURLs(of: imageURL).isEmpty else {
throw UTMAppleDiskImageError.layerMissing
}
if let current = current {
try fileManager.moveItem(at: current.url, to: snapshotURL)
// the date of a snapshot is when its layer was frozen, not when the guest last wrote to it
try? fileManager.setAttributes([.modificationDate: Date()], ofItemAtPath: snapshotURL.path)
} else {
// so far everything was written to the image itself
_ = try open(imageURL, chain: []).appending(ASIFCreationConfiguration.layer(url: snapshotURL, type: .overlay))
}
do {
let layers = try self.layers(of: imageURL)
try createCurrentLayer(of: imageURL, above: try chain(endingAt: layers.first { $0.name == name }, in: layers))
} catch {
// without a layer to write to the drive could not be used anymore
if let current = current {
try? fileManager.moveItem(at: snapshotURL, to: current.url)
} else {
try? fileManager.removeItem(at: snapshotURL)
}
throw error
}
}
/// Discard what was written to an image since a snapshot was created.
///
/// The image must not be in use.
static func restoreSnapshot(of imageURL: URL, name: String) throws {
try createCurrentLayer(of: imageURL, above: try snapshotChain(of: imageURL, name: name))
try removeUnusedLayers(of: imageURL)
}
/// Make sure that a snapshot can be restored, without changing anything.
static func validateSnapshot(of imageURL: URL, name: String) throws {
_ = try snapshotChain(of: imageURL, name: name)
}
private static func snapshotChain(of imageURL: URL, name: String) throws -> [Layer] {
let layers = try layers(of: imageURL)
guard let snapshot = layers.first(where: { $0.name == name && !$0.isUnlinked }) else {
throw UTMAppleDiskImageError.snapshotNotFound(name)
}
return try chain(endingAt: snapshot, in: layers)
}
/// Delete a snapshot. Its layer stays for as long as there are layers above it.
static func deleteSnapshot(of imageURL: URL, name: String) throws {
guard let snapshot = try layers(of: imageURL).first(where: { $0.name == name && !$0.isUnlinked }) else {
throw UTMAppleDiskImageError.snapshotNotFound(name)
}
try FileManager.default.moveItem(at: snapshot.url, to: Layer.url(for: imageURL, name: name, isUnlinked: true))
try removeUnusedLayers(of: imageURL)
}
/// Delete the layers of deleted snapshots that no other layer needs anymore
private static func removeUnusedLayers(of imageURL: URL) throws {
var layers = try layers(of: imageURL)
// the current layer is not found while it is in use so keep what may be below it
guard layers.contains(where: { $0.isCurrent }) || layers.isEmpty else {
return
}
while let unused = layers.first(where: { layer in layer.isUnlinked && !layers.contains { $0.parentUUID == layer.uuid } }) {
try FileManager.default.removeItem(at: unused.url)
layers.removeAll { $0 == unused }
}
}
}
enum UTMAppleDiskImageError: Error {
case snapshotNotFound(String)
case layerMissing
case hasSnapshots
case snapshotsRequireNewerOS
case readOnlyWithSnapshots
}
extension UTMAppleDiskImageError: LocalizedError {
var errorDescription: String? {
switch self {
case .snapshotNotFound(let name):
return String.localizedStringWithFormat(NSLocalizedString("The disk image has no snapshot '%@'.", comment: "UTMAppleDiskImage"), name)
case .layerMissing:
return NSLocalizedString("The disk image is incomplete because some of its data could not be read.", comment: "UTMAppleDiskImage")
case .hasSnapshots:
return NSLocalizedString("A drive cannot be resized once a snapshot has been taken of it.", comment: "UTMAppleDiskImage")
case .snapshotsRequireNewerOS:
return NSLocalizedString("This drive holds snapshots and can only be used on macOS 27 or later.", comment: "UTMAppleDiskImage")
case .readOnlyWithSnapshots:
return NSLocalizedString("A drive cannot be read only once a snapshot has been taken of it.", comment: "UTMAppleDiskImage")
}
}
}