From 2d48f084e60f3a7ddd64f0eeb88eb9880e8f01ba Mon Sep 17 00:00:00 2001 From: osy <50960678+osy@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:13:39 -0500 Subject: [PATCH] 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 `..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 --- .../UTMAppleConfigurationDrive.swift | 13 +- Services/UTMAppleDiskImage.swift | 264 +++++++++++++++++- 2 files changed, 264 insertions(+), 13 deletions(-) diff --git a/Configuration/UTMAppleConfigurationDrive.swift b/Configuration/UTMAppleConfigurationDrive.swift index ff50b200..8c49ac1c 100644 --- a/Configuration/UTMAppleConfigurationDrive.swift +++ b/Configuration/UTMAppleConfigurationDrive.swift @@ -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) diff --git a/Services/UTMAppleDiskImage.swift b/Services/UTMAppleDiskImage.swift index 1607628e..2a53b40e 100644 --- a/Services/UTMAppleDiskImage.swift +++ b/Services/UTMAppleDiskImage.swift @@ -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 `..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") + } } }