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") + } } }