diff --git a/Sources/tart/ContentStore.swift b/Sources/tart/ContentStore.swift new file mode 100644 index 0000000..4402ae9 --- /dev/null +++ b/Sources/tart/ContentStore.swift @@ -0,0 +1,94 @@ +import Foundation + +enum ContentStoreError: Error, Equatable { + case invalidContentDigest(String) + case contentDigestMismatch(expected: String, actual: String) +} + +/// Opaque content-addressed storage for immutable reconstructed files. +/// +/// Stacked disks currently use it for complete base disks and published ASIF +/// overlays reconstructed from Tart disk chunks. OCI blob digests may differ +/// across registries, so the key is the full reconstructed-file digest. +struct ContentStore { + private static let digestAlgorithm = "sha256" + private static let digestPrefix = "\(digestAlgorithm):" + + let baseURL: URL + private let digestDirectoryURL: URL + + init() throws { + try self.init(baseURL: Config().tartCacheDir.appendingPathComponent("content", isDirectory: true)) + } + + init(baseURL: URL) throws { + self.baseURL = baseURL + self.digestDirectoryURL = baseURL.appendingPathComponent(Self.digestAlgorithm, isDirectory: true) + try FileManager.default.createDirectory(at: digestDirectoryURL, withIntermediateDirectories: true) + } + + func contentURL(for contentDigest: String) throws -> URL { + let digestHex = try validatedDigestHex(contentDigest) + + return digestDirectoryURL.appendingPathComponent(digestHex) + } + + func temporaryContentURL(for contentDigest: String) throws -> URL { + let targetURL = try contentURL(for: contentDigest) + + return targetURL.deletingLastPathComponent().appendingPathComponent(".\(UUID().uuidString).tmp") + } + + /// Returns a validated cache hit. Corrupt files are treated as misses so a + /// later pull can safely rebuild them. + func existingContentURL(for contentDigest: String) throws -> URL? { + let url = try contentURL(for: contentDigest) + + guard FileManager.default.fileExists(atPath: url.path) else { + return nil + } + + guard try Digest.hash(url) == contentDigest else { + return nil + } + + return url + } + + /// Move a fully reconstructed temporary file into the cache after verifying + /// its semantic identity. The caller should create the temporary file with + /// `temporaryContentURL(for:)` so rename stays on the same filesystem. + func install(_ temporaryURL: URL, contentDigest: String) throws -> URL { + let actualDigest = try Digest.hash(temporaryURL) + guard actualDigest == contentDigest else { + throw ContentStoreError.contentDigestMismatch(expected: contentDigest, actual: actualDigest) + } + + let targetURL = try contentURL(for: contentDigest) + + if let existingURL = try existingContentURL(for: contentDigest) { + try? FileManager.default.removeItem(at: temporaryURL) + return existingURL + } + + try? FileManager.default.removeItem(at: targetURL) + try FileManager.default.moveItem(at: temporaryURL, to: targetURL) + + return targetURL + } + + private func validatedDigestHex(_ contentDigest: String) throws -> String { + guard contentDigest.hasPrefix(Self.digestPrefix) else { + throw ContentStoreError.invalidContentDigest(contentDigest) + } + + let digestHex = String(contentDigest.dropFirst(Self.digestPrefix.count)) + let isHex = digestHex.allSatisfy { $0.isHexDigit && !$0.isUppercase } + + guard digestHex.count == 64, isHex else { + throw ContentStoreError.invalidContentDigest(contentDigest) + } + + return digestHex + } +} diff --git a/Sources/tart/DiskImageStack.swift b/Sources/tart/DiskImageStack.swift new file mode 100644 index 0000000..27543a6 --- /dev/null +++ b/Sources/tart/DiskImageStack.swift @@ -0,0 +1,266 @@ +import Foundation +import Virtualization + +#if canImport(DiskImageKit) + import DiskImageKit +#endif + +/// One immutable complete disk file used by a stacked disk. +/// +/// This is a reconstructed base disk or published ASIF overlay, not an OCI +/// layer or an individual Tart disk chunk. +struct DiskImageFile { + let url: URL + let contentDigest: String +} + +enum DiskImageStackError: Error, Equatable, CustomStringConvertible { + case unavailable + case writableOverlayAlreadyExists(URL) + case writableOverlayMissing(URL) + case invalidGeometry(String) + case invalidDiskImage(URL, String) + + var description: String { + switch self { + case .unavailable: + "stacked disks require DiskImageKit on macOS 27 or newer" + case .writableOverlayAlreadyExists(let url): + "writable overlay already exists: \(url.path)" + case .writableOverlayMissing(let url): + "writable overlay is missing: \(url.path)" + case .invalidGeometry(let reason): + reason + case .invalidDiskImage(let url, let reason): + "\(reason): \(url.path)" + } + } +} + +struct DiskImageStack { + /// DiskImageKit-ready paths and geometry after Tart disk chunks have been + /// reconstructed into complete immutable files. The writable overlay stays + /// private to one VM. + let base: DiskImageFile + let baseFormat: DiskImageFormat + let overlays: [DiskImageFile] + let writableOverlayURL: URL + let blockSize: UInt64 + let blockCount: UInt64 + + func createWritableOverlay() throws { + #if canImport(DiskImageKit) + if #available(macOS 27.0, *) { + try createWritableOverlayWithDiskImageKit() + return + } + #endif + + throw DiskImageStackError.unavailable + } + + func copyWritableOverlay(to destinationURL: URL) throws { + guard !FileManager.default.fileExists(atPath: destinationURL.path) else { + throw DiskImageStackError.writableOverlayAlreadyExists(destinationURL) + } + + try FileManager.default.copyItem(at: writableOverlayURL, to: destinationURL) + } + + func makeAttachment( + cachingMode: VZDiskImageCachingMode = .automatic, + synchronizationMode: VZDiskImageSynchronizationMode = .full + ) throws -> VZDiskImageStorageDeviceAttachment { + #if canImport(DiskImageKit) + if #available(macOS 27.0, *) { + return try attachmentWithDiskImageKit( + cachingMode: cachingMode, + synchronizationMode: synchronizationMode + ) + } + #endif + + throw DiskImageStackError.unavailable + } + + func growWritableOverlay(toBlockCount blockCount: UInt64) throws { + #if canImport(DiskImageKit) + if #available(macOS 27.0, *) { + try growWritableOverlayWithDiskImageKit(toBlockCount: blockCount) + return + } + #endif + + throw DiskImageStackError.unavailable + } + + #if canImport(DiskImageKit) + @available(macOS 27.0, *) + private func createWritableOverlayWithDiskImageKit() throws { + guard !FileManager.default.fileExists(atPath: writableOverlayURL.path) else { + throw DiskImageStackError.writableOverlayAlreadyExists(writableOverlayURL) + } + + let parent = try validatedParentImage() + let stackedImage = try parent.appending(.asifLayer(url: writableOverlayURL, type: .overlay)) + try validateAppendedOverlay(stackedImage, at: writableOverlayURL) + } + + @available(macOS 27.0, *) + private func attachmentWithDiskImageKit( + cachingMode: VZDiskImageCachingMode, + synchronizationMode: VZDiskImageSynchronizationMode + ) throws -> VZDiskImageStorageDeviceAttachment { + guard FileManager.default.fileExists(atPath: writableOverlayURL.path) else { + throw DiskImageStackError.writableOverlayMissing(writableOverlayURL) + } + + let parent = try validatedParentImage() + let writableOverlay = try openOverlay( + at: writableOverlayURL, + mode: .readWrite + ) + let stackedImage = try append(writableOverlay, to: parent, at: writableOverlayURL) + try validateAppendedOverlay(stackedImage, at: writableOverlayURL) + + return try VZDiskImageStorageDeviceAttachment( + diskImage: stackedImage, + cachingMode: cachingMode, + synchronizationMode: synchronizationMode + ) + } + + @available(macOS 27.0, *) + private func growWritableOverlayWithDiskImageKit(toBlockCount blockCount: UInt64) throws { + guard blockCount > 0, let desiredBlockCount = Int(exactly: blockCount) else { + throw DiskImageStackError.invalidGeometry("invalid stacked disk block count \(blockCount)") + } + + let parent = try validatedParentImage() + let overlay = try openOverlay( + at: writableOverlayURL, + mode: .readWrite + ) + let currentBlockCount = overlay.blockCount + let stackedImage = try append(overlay, to: parent, at: writableOverlayURL) + try validateAppendedOverlay(stackedImage, at: writableOverlayURL) + guard desiredBlockCount >= currentBlockCount else { + throw DiskImageStackError.invalidDiskImage(writableOverlayURL, "ASIF overlay block count shrinks the stacked disk") + } + + guard let writableOverlay = stackedImage.layers.last else { + throw DiskImageStackError.invalidDiskImage(writableOverlayURL, "disk image must be an ASIF overlay") + } + if desiredBlockCount > currentBlockCount { + try writableOverlay.truncate(blockCount: desiredBlockCount) + } + } + + @available(macOS 27.0, *) + private func validatedParentImage() throws -> DiskImage { + let expectedBlockSize = try diskImageBlockSize(blockSize) + guard blockCount > 0, let expectedBlockCount = Int(exactly: blockCount) else { + throw DiskImageStackError.invalidGeometry("invalid stacked disk block count \(blockCount)") + } + + try verifyContentDigest(base) + let baseImage = try DiskImage(opening: .open(url: base.url, mode: .readOnly)) + try validateBase(baseImage, at: base.url, expectedFormat: baseFormat) + + var image = baseImage + + for overlay in overlays { + let openedOverlay = try openOverlay( + at: overlay.url, + expectedDigest: overlay.contentDigest, + mode: .readOnly + ) + let stackedImage = try append(openedOverlay, to: image, at: overlay.url) + try validateAppendedOverlay(stackedImage, at: overlay.url) + image = stackedImage + } + + guard image.blockSize == expectedBlockSize else { + throw DiskImageStackError.invalidGeometry("immutable disk stack does not match manifest block size") + } + guard image.blockCount == expectedBlockCount else { + throw DiskImageStackError.invalidGeometry("immutable disk stack does not match manifest block count") + } + + return image + } + + @available(macOS 27.0, *) + private func validateBase( + _ image: DiskImage, + at url: URL, + expectedFormat: DiskImageFormat + ) throws { + let matchesFormat = switch expectedFormat { + case .raw: + image.format == .raw + case .asif: + image.format == .asif + } + guard matchesFormat else { + throw DiskImageStackError.invalidDiskImage(url, "base disk format does not match") + } + guard image.layerType == nil, image.parentUUID == nil else { + throw DiskImageStackError.invalidDiskImage(url, "base disk must not be an overlay") + } + if expectedFormat == .asif && image.layerUUID == nil { + throw DiskImageStackError.invalidDiskImage(url, "ASIF base disk is missing a UUID") + } + } + + @available(macOS 27.0, *) + private func openOverlay( + at url: URL, + expectedDigest: String? = nil, + mode: OpenConfiguration.Mode + ) throws -> DiskImage { + if let expectedDigest { + try verifyContentDigest(DiskImageFile(url: url, contentDigest: expectedDigest)) + } + + let image = try DiskImage(opening: .open(url: url, mode: mode)) + guard image.format == .asif else { + throw DiskImageStackError.invalidDiskImage(url, "overlay must use ASIF format") + } + + return image + } + + @available(macOS 27.0, *) + private func append(_ overlay: DiskImage, to parent: DiskImage, at url: URL) throws -> any StackedImage { + do { + return try parent.appending(overlay) + } catch is IncompatibleStackingError { + throw DiskImageStackError.invalidDiskImage(url, "ASIF overlay is incompatible with its parent") + } + } + + @available(macOS 27.0, *) + private func validateAppendedOverlay(_ image: any StackedImage, at url: URL) throws { + guard image.layers.last?.layerType == .overlay else { + throw DiskImageStackError.invalidDiskImage(url, "disk image must be an ASIF overlay") + } + } + + @available(macOS 27.0, *) + private func verifyContentDigest(_ diskImage: DiskImageFile) throws { + guard try Digest.hash(diskImage.url) == diskImage.contentDigest else { + throw DiskImageStackError.invalidDiskImage(diskImage.url, "disk image content digest does not match") + } + } + + @available(macOS 27.0, *) + private func diskImageBlockSize(_ value: UInt64) throws -> DiskImage.BlockSize { + guard let intValue = Int(exactly: value), let blockSize = DiskImage.BlockSize(rawValue: intValue) else { + throw DiskImageStackError.invalidGeometry("unsupported stacked disk block size \(value)") + } + + return blockSize + } + #endif +} diff --git a/Sources/tart/OCI/Manifest.swift b/Sources/tart/OCI/Manifest.swift index 897bd97..76801a6 100644 --- a/Sources/tart/OCI/Manifest.swift +++ b/Sources/tart/OCI/Manifest.swift @@ -7,11 +7,13 @@ let ociConfigMediaType = "application/vnd.oci.image.config.v1+json" // Layer media types let configMediaType = "application/vnd.cirruslabs.tart.config.v1" let diskV2MediaType = "application/vnd.cirruslabs.tart.disk.v2" +let asifOverlayMediaType = "application/vnd.cirruslabs.tart.disk.asif.overlay.v1" let nvramMediaType = "application/vnd.cirruslabs.tart.nvram.v1" // Manifest annotations let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size" let uploadTimeAnnotation = "org.cirruslabs.tart.upload-time" +let diskBlockSizeAnnotation = "org.cirruslabs.tart.disk.block-size" // Manifest labels let diskFormatLabel = "org.cirruslabs.tart.disk.format" @@ -19,6 +21,33 @@ let diskFormatLabel = "org.cirruslabs.tart.disk.format" // Layer annotations let uncompressedSizeAnnotation = "org.cirruslabs.tart.uncompressed-size" let uncompressedContentDigestAnnotation = "org.cirruslabs.tart.uncompressed-content-digest" +let diskFileContentDigestAnnotation = "org.cirruslabs.tart.disk-file-content-digest" +let diskFileChunkCountAnnotation = "org.cirruslabs.tart.disk-file-chunk-count" + +/// The OCI-layer descriptors whose Tart disk chunks reconstruct one complete +/// base disk or ASIF overlay. +struct TartDiskFileGroup: Equatable { + enum Kind: Equatable { + case base + case asifOverlay + } + + var kind: Kind + var chunks: [OCIManifestLayer] + /// Whole reconstructed-file digest. Existing flat manifests do not have + /// this until a macOS 27 clone normalizes its local manifest copy. + var contentDigest: String? +} + +enum TartDiskRepresentation: Equatable { + case flat(base: TartDiskFileGroup) + case stacked(base: TartDiskFileGroup, overlays: [TartDiskFileGroup]) +} + +enum OCIManifestValidationError: Error, Equatable { + case invalidLayout(String) + case invalidDiskMetadata(String) +} struct OCIManifest: Codable, Equatable { var schemaVersion: Int = 2 @@ -63,6 +92,106 @@ struct OCIManifest: Codable, Equatable { return UInt64(value) } + + /// Parse Tart's canonical `config -> disk descriptors -> NVRAM` order. + /// A stacked image has a leading `disk.v2` base run followed by one or more + /// contiguous ASIF overlay chunk groups. + func tartDiskRepresentation() throws -> TartDiskRepresentation { + guard layers.filter({ $0.mediaType == configMediaType }).count == 1 else { + throw OCIManifestValidationError.invalidLayout("manifest must contain exactly one Tart config descriptor") + } + guard layers.filter({ $0.mediaType == nvramMediaType }).count == 1 else { + throw OCIManifestValidationError.invalidLayout("manifest must contain exactly one NVRAM descriptor") + } + guard layers.first?.mediaType == configMediaType, + layers.last?.mediaType == nvramMediaType else { + throw OCIManifestValidationError.invalidLayout("descriptors must be ordered as config, disk chunks, then NVRAM") + } + + let diskDescriptors = Array(layers.dropFirst().dropLast()) + guard !diskDescriptors.isEmpty else { + throw OCIManifestValidationError.invalidLayout("manifest has no disk chunks") + } + + let baseChunkCount = diskDescriptors.prefix { $0.mediaType == diskV2MediaType }.count + guard baseChunkCount > 0 else { + throw OCIManifestValidationError.invalidLayout("disk chunks must start with a disk.v2 base") + } + + let baseChunks = Array(diskDescriptors.prefix(baseChunkCount)) + try validateChunkMetadata(baseChunks) + guard baseChunks.first?.diskFileChunkCount() == nil, + baseChunks.dropFirst().allSatisfy({ + $0.diskFileContentDigest() == nil && $0.diskFileChunkCount() == nil + }) else { + throw OCIManifestValidationError.invalidDiskMetadata("base disk metadata must appear only on its first chunk") + } + let base = TartDiskFileGroup( + kind: .base, + chunks: baseChunks, + contentDigest: baseChunks.first?.diskFileContentDigest() + ) + + guard baseChunkCount < diskDescriptors.count else { + return .flat(base: base) + } + + guard base.contentDigest != nil else { + throw OCIManifestValidationError.invalidDiskMetadata("a stacked base disk needs a whole-file content digest") + } + + var overlays: [TartDiskFileGroup] = [] + var index = baseChunkCount + + while index < diskDescriptors.count { + let first = diskDescriptors[index] + guard first.mediaType == asifOverlayMediaType else { + throw OCIManifestValidationError.invalidLayout("unsupported disk chunk media type: \(first.mediaType)") + } + guard let contentDigest = first.diskFileContentDigest(), + let chunkCount = first.diskFileChunkCount() else { + throw OCIManifestValidationError.invalidDiskMetadata("an ASIF overlay needs a content digest and chunk count") + } + guard chunkCount > 0, index + chunkCount <= diskDescriptors.count else { + throw OCIManifestValidationError.invalidDiskMetadata("ASIF overlay chunk count is invalid") + } + + let chunks = Array(diskDescriptors[index..<(index + chunkCount)]) + guard chunks.allSatisfy({ $0.mediaType == asifOverlayMediaType }) else { + throw OCIManifestValidationError.invalidLayout("ASIF overlay chunks must be contiguous") + } + guard chunks.dropFirst().allSatisfy({ $0.diskFileContentDigest() == nil && $0.diskFileChunkCount() == nil }) else { + throw OCIManifestValidationError.invalidDiskMetadata("ASIF overlay metadata must appear only on its first chunk") + } + try validateChunkMetadata(chunks) + + overlays.append(TartDiskFileGroup(kind: .asifOverlay, chunks: chunks, contentDigest: contentDigest)) + index += chunkCount + } + + return .stacked(base: base, overlays: overlays) + } + + private func validateChunkMetadata(_ chunks: [OCIManifestLayer]) throws { + guard chunks.allSatisfy({ $0.uncompressedSize() != nil && $0.uncompressedContentDigest() != nil }) else { + throw OCIManifestValidationError.invalidDiskMetadata("disk chunks need uncompressed size and content digest") + } + } + + func diskBlockSize() -> UInt64? { + annotations?[diskBlockSizeAnnotation].flatMap(UInt64.init) + } + + func diskBlockCount() -> UInt64? { + guard let diskSize = uncompressedDiskSize(), + let blockSize = diskBlockSize(), + blockSize > 0, + diskSize.isMultiple(of: blockSize) else { + return nil + } + + return diskSize / blockSize + } } struct OCIConfig: Codable { @@ -121,6 +250,14 @@ struct OCIManifestLayer: Codable, Equatable, Hashable { annotations?[uncompressedContentDigestAnnotation] } + func diskFileContentDigest() -> String? { + annotations?[diskFileContentDigestAnnotation] + } + + func diskFileChunkCount() -> Int? { + annotations?[diskFileChunkCountAnnotation].flatMap(Int.init) + } + static func == (lhs: Self, rhs: Self) -> Bool { return lhs.digest == rhs.digest } diff --git a/Sources/tart/VMDirectory.swift b/Sources/tart/VMDirectory.swift index 6319632..443a838 100644 --- a/Sources/tart/VMDirectory.swift +++ b/Sources/tart/VMDirectory.swift @@ -26,6 +26,9 @@ struct VMDirectory: Prunable { var manifestURL: URL { baseURL.appendingPathComponent("manifest.json") } + var overlayURL: URL { + baseURL.appendingPathComponent("overlay.asif") + } var controlSocketURL: URL { URL(fileURLWithPath: "control.sock", relativeTo: baseURL) } @@ -87,10 +90,56 @@ struct VMDirectory: Prunable { return VMDirectory(baseURL: tmpDir) } + private var hasRequiredMetadata: Bool { + let fileManager = FileManager.default + + return fileManager.fileExists(atPath: configURL.path) && + fileManager.fileExists(atPath: nvramURL.path) + } + + enum Layout: Equatable { + /// Existing Tart layout with one independently attachable `disk.img`. + /// A pulled standalone OCI record may also carry `manifest.json`. + case standalone + + /// Runnable stacked VM with immutable disk files from `manifest.json` and + /// a private writable `overlay.asif`. + case stackedLocal + + /// Pulled OCI record for a stacked image. It intentionally has no writable + /// overlay and becomes runnable only after `tart clone` creates one. + case stackedOCIRecord + + var isRunnable: Bool { + self != .stackedOCIRecord + } + } + + var layout: Layout? { + let fileManager = FileManager.default + let hasDisk = fileManager.fileExists(atPath: diskURL.path) + let hasManifest = fileManager.fileExists(atPath: manifestURL.path) + let hasOverlay = fileManager.fileExists(atPath: overlayURL.path) + + guard hasRequiredMetadata else { + return nil + } + + if hasDisk && !hasOverlay { + return .standalone + } + if !hasDisk && hasManifest && hasOverlay { + return .stackedLocal + } + if !hasDisk && hasManifest && !hasOverlay { + return .stackedOCIRecord + } + + return nil + } + var initialized: Bool { - FileManager.default.fileExists(atPath: configURL.path) && - FileManager.default.fileExists(atPath: diskURL.path) && - FileManager.default.fileExists(atPath: nvramURL.path) + layout?.isRunnable == true } func initialize(overwrite: Bool = false) throws { @@ -103,6 +152,9 @@ struct VMDirectory: Prunable { try? FileManager.default.removeItem(at: configURL) try? FileManager.default.removeItem(at: diskURL) try? FileManager.default.removeItem(at: nvramURL) + try? FileManager.default.removeItem(at: manifestURL) + try? FileManager.default.removeItem(at: overlayURL) + try? FileManager.default.removeItem(at: stateURL) } func validate(userFriendlyName: String) throws { @@ -111,8 +163,12 @@ struct VMDirectory: Prunable { } if !initialized { - throw RuntimeError.VMMissingFiles("VM is missing some of its files (\(configURL.lastPathComponent)," - + " \(diskURL.lastPathComponent) or \(nvramURL.lastPathComponent))") + throw RuntimeError.VMMissingFiles( + "VM is missing files for a supported layout: " + + "standalone requires \(configURL.lastPathComponent), \(diskURL.lastPathComponent) and \(nvramURL.lastPathComponent); " + + "stacked requires \(configURL.lastPathComponent), \(manifestURL.lastPathComponent), " + + "\(overlayURL.lastPathComponent) and \(nvramURL.lastPathComponent)" + ) } } diff --git a/Tests/TartTests/ContentStoreTests.swift b/Tests/TartTests/ContentStoreTests.swift new file mode 100644 index 0000000..66f64bd --- /dev/null +++ b/Tests/TartTests/ContentStoreTests.swift @@ -0,0 +1,67 @@ +import Foundation +import XCTest +@testable import tart + +final class ContentStoreTests: XCTestCase { + func testCreatesDigestDirectoryDuringInitialization() throws { + let store = try temporaryStore() + let contentURL = try store.contentURL(for: Digest.hash(Data())) + + XCTAssertTrue(FileManager.default.fileExists(atPath: contentURL.deletingLastPathComponent().path)) + } + + func testInstallAndValidatedLookup() throws { + let store = try temporaryStore() + let data = Data("base disk".utf8) + let digest = Digest.hash(data) + let temporaryURL = try store.temporaryContentURL(for: digest) + try data.write(to: temporaryURL) + + let installedURL = try store.install(temporaryURL, contentDigest: digest) + + XCTAssertEqual(installedURL, try store.contentURL(for: digest)) + XCTAssertEqual(try store.existingContentURL(for: digest), installedURL) + } + + func testCorruptCacheEntryIsMiss() throws { + let store = try temporaryStore() + let expectedDigest = Digest.hash(Data("expected".utf8)) + let contentURL = try store.contentURL(for: expectedDigest) + try FileManager.default.createDirectory(at: contentURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("corrupt".utf8).write(to: contentURL) + + XCTAssertNil(try store.existingContentURL(for: expectedDigest)) + } + + func testInstallRejectsWrongContentDigest() throws { + let store = try temporaryStore() + let expectedDigest = Digest.hash(Data("expected".utf8)) + let temporaryURL = try store.temporaryContentURL(for: expectedDigest) + try Data("actual".utf8).write(to: temporaryURL) + + XCTAssertThrowsError(try store.install(temporaryURL, contentDigest: expectedDigest)) { error in + guard case ContentStoreError.contentDigestMismatch(let expected, _) = error else { + return XCTFail("unexpected error: \(error)") + } + + XCTAssertEqual(expected, expectedDigest) + } + } + + func testRejectsNonCanonicalDigest() throws { + let store = try temporaryStore() + + XCTAssertThrowsError(try store.contentURL(for: "sha256:ABC")) { error in + XCTAssertEqual(error as? ContentStoreError, .invalidContentDigest("sha256:ABC")) + } + } + + private func temporaryStore() throws -> ContentStore { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + addTeardownBlock { + try? FileManager.default.removeItem(at: url) + } + + return try ContentStore(baseURL: url) + } +} diff --git a/Tests/TartTests/DiskImageStackTests.swift b/Tests/TartTests/DiskImageStackTests.swift new file mode 100644 index 0000000..70a5654 --- /dev/null +++ b/Tests/TartTests/DiskImageStackTests.swift @@ -0,0 +1,290 @@ +import Foundation +import XCTest +@testable import tart + +#if canImport(DiskImageKit) + import DiskImageKit + + @available(macOS 27.0, *) + final class DiskImageStackTests: XCTestCase { + override func setUpWithError() throws { + try super.setUpWithError() + + if #unavailable(macOS 27.0) { + throw XCTSkip("DiskImageKit tests require macOS 27 or newer") + } + } + + func testCreatesAndAttachesRawBaseWithWritableOverlay() throws { + let fixture = try Fixture(baseFormat: .raw) + + try fixture.disk.createWritableOverlay() + XCTAssertTrue(FileManager.default.fileExists(atPath: fixture.disk.writableOverlayURL.path)) + + _ = try fixture.disk.makeAttachment() + } + + func testCreatesAndAttachesASIFBaseWithPublishedOverlay() throws { + let fixture = try Fixture(baseFormat: .asif, publishedOverlayCount: 1) + + try fixture.disk.createWritableOverlay() + _ = try fixture.disk.makeAttachment() + } + + func testCreatesAndAttachesMultiplePublishedOverlays() throws { + let fixture = try Fixture(baseFormat: .asif, publishedOverlayCount: 2) + + try fixture.disk.createWritableOverlay() + _ = try fixture.disk.makeAttachment() + } + + func testCreatesAndAttachesLongPublishedOverlayChain() throws { + let fixture = try Fixture(baseFormat: .asif, publishedOverlayCount: 8) + + try fixture.disk.createWritableOverlay() + _ = try fixture.disk.makeAttachment() + } + + func testRejectsMissingWritableOverlayWhenAttaching() throws { + let fixture = try Fixture(baseFormat: .raw) + + assertThrows(.writableOverlayMissing(fixture.disk.writableOverlayURL)) { + try fixture.disk.makeAttachment() + } + } + + func testRejectsExistingWritableOverlayWhenCreating() throws { + let fixture = try Fixture(baseFormat: .raw) + XCTAssertTrue(FileManager.default.createFile(atPath: fixture.disk.writableOverlayURL.path, contents: nil)) + + assertThrows(.writableOverlayAlreadyExists(fixture.disk.writableOverlayURL)) { + try fixture.disk.createWritableOverlay() + } + } + + func testRejectsExistingCopyDestination() throws { + let fixture = try Fixture(baseFormat: .raw) + try fixture.disk.createWritableOverlay() + + let destinationURL = fixture.directory.appendingPathComponent("existing-overlay.asif") + XCTAssertTrue(FileManager.default.createFile(atPath: destinationURL.path, contents: nil)) + + assertThrows(.writableOverlayAlreadyExists(destinationURL)) { + try fixture.disk.copyWritableOverlay(to: destinationURL) + } + } + + func testRejectsWrongContentDigest() throws { + let fixture = try Fixture(baseFormat: .raw) + fixture.disk = DiskImageStack( + base: DiskImageFile(url: fixture.disk.base.url, contentDigest: "sha256:wrong"), + baseFormat: fixture.disk.baseFormat, + overlays: fixture.disk.overlays, + writableOverlayURL: fixture.disk.writableOverlayURL, + blockSize: fixture.disk.blockSize, + blockCount: fixture.disk.blockCount + ) + + assertThrows(.invalidDiskImage(fixture.disk.base.url, "disk image content digest does not match")) { + try fixture.disk.createWritableOverlay() + } + } + + func testRejectsWrongOverlayContentDigest() throws { + let fixture = try Fixture(baseFormat: .asif, publishedOverlayCount: 1) + fixture.disk = DiskImageStack( + base: fixture.disk.base, + baseFormat: fixture.disk.baseFormat, + overlays: [ + DiskImageFile(url: fixture.disk.overlays[0].url, contentDigest: "sha256:wrong"), + ], + writableOverlayURL: fixture.disk.writableOverlayURL, + blockSize: fixture.disk.blockSize, + blockCount: fixture.disk.blockCount + ) + + assertThrows(.invalidDiskImage(fixture.disk.overlays[0].url, "disk image content digest does not match")) { + try fixture.disk.createWritableOverlay() + } + } + + func testRejectsNonASIFPublishedOverlay() throws { + let fixture = try Fixture(baseFormat: .raw) + let overlayURL = fixture.directory.appendingPathComponent("published-raw.img") + _ = try DiskImage(creating: .raw(url: overlayURL, blockCount: 8)) + fixture.disk = DiskImageStack( + base: fixture.disk.base, + baseFormat: fixture.disk.baseFormat, + overlays: [ + DiskImageFile(url: overlayURL, contentDigest: try Digest.hash(overlayURL)), + ], + writableOverlayURL: fixture.disk.writableOverlayURL, + blockSize: fixture.disk.blockSize, + blockCount: fixture.disk.blockCount + ) + + assertThrows(.invalidDiskImage(overlayURL, "overlay must use ASIF format")) { + try fixture.disk.createWritableOverlay() + } + } + + func testRejectsWrongBaseFormat() throws { + let fixture = try Fixture(baseFormat: .raw) + fixture.disk = DiskImageStack( + base: fixture.disk.base, + baseFormat: .asif, + overlays: fixture.disk.overlays, + writableOverlayURL: fixture.disk.writableOverlayURL, + blockSize: fixture.disk.blockSize, + blockCount: fixture.disk.blockCount + ) + + assertThrows(.invalidDiskImage(fixture.disk.base.url, "base disk format does not match")) { + try fixture.disk.createWritableOverlay() + } + } + + func testRejectsBlockSizeMismatch() throws { + let fixture = try Fixture(baseFormat: .raw) + fixture.disk = DiskImageStack( + base: fixture.disk.base, + baseFormat: fixture.disk.baseFormat, + overlays: fixture.disk.overlays, + writableOverlayURL: fixture.disk.writableOverlayURL, + blockSize: 4096, + blockCount: fixture.disk.blockCount + ) + + assertThrows(.invalidGeometry("immutable disk stack does not match manifest block size")) { + try fixture.disk.createWritableOverlay() + } + } + + func testRejectsUnsupportedBlockSize() throws { + let fixture = try Fixture(baseFormat: .raw) + fixture.disk = DiskImageStack( + base: fixture.disk.base, + baseFormat: fixture.disk.baseFormat, + overlays: fixture.disk.overlays, + writableOverlayURL: fixture.disk.writableOverlayURL, + blockSize: 123, + blockCount: fixture.disk.blockCount + ) + + assertThrows(.invalidGeometry("unsupported stacked disk block size 123")) { + try fixture.disk.createWritableOverlay() + } + } + + func testRejectsManifestBlockCountMismatch() throws { + let fixture = try Fixture(baseFormat: .raw) + fixture.disk = DiskImageStack( + base: fixture.disk.base, + baseFormat: fixture.disk.baseFormat, + overlays: fixture.disk.overlays, + writableOverlayURL: fixture.disk.writableOverlayURL, + blockSize: fixture.disk.blockSize, + blockCount: fixture.disk.blockCount + 1 + ) + + assertThrows(.invalidGeometry("immutable disk stack does not match manifest block count")) { + try fixture.disk.createWritableOverlay() + } + } + + func testCopiesAndGrowsWritableOverlay() throws { + let fixture = try Fixture(baseFormat: .raw) + try fixture.disk.createWritableOverlay() + + let copiedURL = fixture.directory.appendingPathComponent("copied-overlay.asif") + try fixture.disk.copyWritableOverlay(to: copiedURL) + fixture.disk = DiskImageStack( + base: fixture.disk.base, + baseFormat: fixture.disk.baseFormat, + overlays: fixture.disk.overlays, + writableOverlayURL: copiedURL, + blockSize: fixture.disk.blockSize, + blockCount: fixture.disk.blockCount + ) + try fixture.disk.growWritableOverlay(toBlockCount: 16) + + let copied = try DiskImage(opening: .open(url: copiedURL, mode: .readOnly)) + XCTAssertEqual(copied.blockCount, 16) + } + + func testRejectsOverlayFromDifferentASIFParent() throws { + let fixture = try Fixture(baseFormat: .asif) + let other = try Fixture(baseFormat: .asif, publishedOverlayCount: 1) + fixture.disk = DiskImageStack( + base: fixture.disk.base, + baseFormat: fixture.disk.baseFormat, + overlays: other.disk.overlays, + writableOverlayURL: fixture.disk.writableOverlayURL, + blockSize: fixture.disk.blockSize, + blockCount: fixture.disk.blockCount + ) + + assertThrows(.invalidDiskImage(other.disk.overlays[0].url, "ASIF overlay is incompatible with its parent")) { + try fixture.disk.createWritableOverlay() + } + } + + func testRejectsWritableOverlayShrink() throws { + let fixture = try Fixture(baseFormat: .raw) + try fixture.disk.createWritableOverlay() + + assertThrows(.invalidDiskImage(fixture.disk.writableOverlayURL, "ASIF overlay block count shrinks the stacked disk")) { + try fixture.disk.growWritableOverlay(toBlockCount: 4) + } + } + + private func assertThrows( + _ expected: DiskImageStackError, + operation: () throws -> T + ) { + XCTAssertThrowsError(try operation()) { error in + XCTAssertEqual(error as? DiskImageStackError, expected) + } + } + + private final class Fixture { + let directory: URL + var disk: DiskImageStack + + init(baseFormat: DiskImageFormat, publishedOverlayCount: Int = 0) throws { + directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + + let baseURL = directory.appendingPathComponent("base.img") + switch baseFormat { + case .raw: + _ = try DiskImage(creating: .raw(url: baseURL, blockCount: 8)) + case .asif: + _ = try DiskImage(creating: .asif(url: baseURL, blockCount: 8, blockSize: .bytes512)) + } + + var overlays: [DiskImageFile] = [] + var image = try DiskImage(opening: .open(url: baseURL, mode: .readOnly)) + for index in 0.. OCIManifest { + OCIManifest( + config: OCIManifestConfig(size: 1, digest: "sha256:oci-config"), + layers: [configLayer()] + diskDescriptors + [nvramLayer()] + ) + } + + private func configLayer() -> OCIManifestLayer { + OCIManifestLayer(mediaType: configMediaType, size: 1, digest: "sha256:tart-config") + } + + private func nvramLayer() -> OCIManifestLayer { + OCIManifestLayer(mediaType: nvramMediaType, size: 1, digest: "sha256:nvram") + } + + private func chunk(mediaType: String, suffix: String, diskFileDigest: String? = nil, chunkCount: Int? = nil) -> OCIManifestLayer { + var descriptor = OCIManifestLayer( + mediaType: mediaType, + size: 1, + digest: "sha256:\(suffix)", + uncompressedSize: 1, + uncompressedContentDigest: "sha256:uncompressed-\(suffix)" + ) + + if let diskFileDigest { + descriptor.annotations?[diskFileContentDigestAnnotation] = diskFileDigest + } + if let chunkCount { + descriptor.annotations?[diskFileChunkCountAnnotation] = String(chunkCount) + } + + return descriptor + } +} diff --git a/Tests/TartTests/VMDirectoryLayoutTests.swift b/Tests/TartTests/VMDirectoryLayoutTests.swift new file mode 100644 index 0000000..3e130d3 --- /dev/null +++ b/Tests/TartTests/VMDirectoryLayoutTests.swift @@ -0,0 +1,67 @@ +import Foundation +import XCTest +@testable import tart + +final class VMDirectoryLayoutTests: XCTestCase { + func testStandaloneLayoutWithPinnedManifest() throws { + let vmDir = try temporaryVMDirectory() + + try touch(vmDir.configURL) + try touch(vmDir.nvramURL) + try touch(vmDir.diskURL) + try touch(vmDir.manifestURL) + + XCTAssertEqual(vmDir.layout, .standalone) + XCTAssertTrue(vmDir.initialized) + } + + func testStackedLocalLayout() throws { + let vmDir = try temporaryVMDirectory() + + try touch(vmDir.configURL) + try touch(vmDir.nvramURL) + try touch(vmDir.manifestURL) + try touch(vmDir.overlayURL) + + XCTAssertEqual(vmDir.layout, .stackedLocal) + XCTAssertTrue(vmDir.initialized) + } + + func testStackedOCIRecordLayout() throws { + let vmDir = try temporaryVMDirectory() + + try touch(vmDir.configURL) + try touch(vmDir.nvramURL) + try touch(vmDir.manifestURL) + + XCTAssertEqual(vmDir.layout, .stackedOCIRecord) + XCTAssertFalse(vmDir.initialized) + } + + func testAmbiguousDiskAndOverlayIsNotInitialized() throws { + let vmDir = try temporaryVMDirectory() + + try touch(vmDir.configURL) + try touch(vmDir.nvramURL) + try touch(vmDir.diskURL) + try touch(vmDir.manifestURL) + try touch(vmDir.overlayURL) + + XCTAssertNil(vmDir.layout) + XCTAssertFalse(vmDir.initialized) + } + + private func temporaryVMDirectory() throws -> VMDirectory { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false) + addTeardownBlock { + try? FileManager.default.removeItem(at: url) + } + + return VMDirectory(baseURL: url) + } + + private func touch(_ url: URL) throws { + XCTAssertTrue(FileManager.default.createFile(atPath: url.path, contents: Data())) + } +}