diff --git a/Sources/AppleDockerApp/AppState.swift b/Sources/AppleDockerApp/AppState.swift index 96bbbfc..03f2028 100644 --- a/Sources/AppleDockerApp/AppState.swift +++ b/Sources/AppleDockerApp/AppState.swift @@ -75,6 +75,10 @@ final class AppState { var diskUsage = SystemDiskUsage() /// Whether a cleanup operation is in flight (disables the cleanup buttons). var isCleaning = false + /// Whether the runtime store is being moved. + var isMovingStorage = false + /// Progress or result of the last storage move, shown in Settings > Storage. + var storageMessage: String? /// Whether a CLI install is in flight. var isInstalling = false /// Whether an app install into /Applications is in flight. @@ -692,6 +696,63 @@ final class AppState { } } + /// Moves the runtime store into `parent`. The daemon is stopped for the copy + /// and started again on the new location. If the move fails, the daemon is + /// started on the original location; if the start fails after the move, the + /// original is restored. + func moveStorage(toParent parent: URL) async { + guard !isMovingStorage else { return } + isMovingStorage = true + defer { isMovingStorage = false } + let opID = beginOperation("moveStorage", section: .settings) + defer { endOperation(opID) } + + let location = StorageLocation.standard + storageMessage = "Stopping the runtime…" + do { + try await service.daemon.stop() + storageMessage = "Copying the storage to \(parent.path)…" + let result: StorageMoveResult + do { + // The copy can take minutes; keep it off the main thread so the window stays responsive. + result = try await Task.detached(priority: .userInitiated) { + try location.move(toParent: parent) + }.value + } catch { + try? await service.daemon.start() + throw error + } + storageMessage = "Starting the runtime on the new location…" + do { + try await service.daemon.start(environment: [StorageLocation.environmentName: result.newRoot.path]) + } catch { + // Back to the original: put it back, drop the unused copy, and + // start the daemon on the original location. + try? location.restoreBackup() + try? FileManager.default.removeItem(at: result.newRoot) + try? await service.daemon.start() + throw error + } + reconnect() + await refresh() + errorMessage = nil + storageMessage = "Storage moved to \(result.newRoot.path). The original is kept as a backup." + } catch { + storageMessage = "Move failed: \(error.localizedDescription)" + errorMessage = error.localizedDescription + } + } + + /// Deletes the backup left by `moveStorage`, once the moved store is in use. + func deleteStorageBackup() { + do { + try StorageLocation.standard.deleteBackup() + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + /// Delete the buildkit builder container (frees the build cache). func deleteBuildkitBuilder() async { let opID = beginOperation("deleteBuildkitBuilder", section: .images) diff --git a/Sources/AppleDockerApp/Views/SettingsView.swift b/Sources/AppleDockerApp/Views/SettingsView.swift index 59ce329..2bd190d 100644 --- a/Sources/AppleDockerApp/Views/SettingsView.swift +++ b/Sources/AppleDockerApp/Views/SettingsView.swift @@ -3,6 +3,7 @@ //===----------------------------------------------------------------------===// import SwiftUI +import AppKit import ServiceManagement import ContainerBackend @@ -21,6 +22,11 @@ struct SettingsView: View { @State private var confirmFull = false @State private var confirmInstall = false @State private var confirmInstallApp = false + @State private var storageSizes: [(name: String, bytes: UInt64)] = [] + @State private var storageRedirected = false + @State private var storageHasBackup = false + @State private var pendingStorageParent: URL? + @State private var confirmDeleteBackup = false @State private var confirmRuntimeUpgrade = false var body: some View { @@ -293,9 +299,126 @@ struct SettingsView: View { } } + /// Where the runtime keeps its data, with a move action. + private var storageLocation: some View { + Section("Location") { + HStack { + Text("Current") + Spacer() + Text(StorageLocation.standard.resolvedRoot.path) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + if storageRedirected { + Text("This folder is a link to another disk.") + .font(.caption) + .foregroundStyle(.secondary) + } + ForEach(storageSizes, id: \.name) { folder in + HStack { + Text(folder.name) + .foregroundStyle(.secondary) + Spacer() + Text(Self.formatBytes(folder.bytes)) + .foregroundStyle(.secondary) + } + .font(.caption) + } + if storageHasBackup { + HStack { + Text("The original data is kept as a backup until you delete it.") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button("Delete backup…", role: .destructive) { + confirmDeleteBackup = true + } + .disabled(state.isMovingStorage) + } + } + HStack { + Button("Move storage…") { + chooseStorageFolder() + } + .disabled(state.isMovingStorage) + if state.isMovingStorage { + ProgressView() + .controlSize(.small) + } + } + if let message = state.storageMessage { + Text(message) + .font(.caption) + .foregroundStyle(message.hasPrefix("Move failed") ? .red : .secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .onAppear(perform: reloadStorageInfo) + .onChange(of: state.isMovingStorage) { _, moving in + if !moving { reloadStorageInfo() } + } + .confirmationDialog( + "Move the container storage?", + isPresented: Binding( + get: { pendingStorageParent != nil }, + set: { if !$0 { pendingStorageParent = nil } } + ), + titleVisibility: .visible + ) { + Button("Move storage") { + guard let parent = pendingStorageParent else { return } + pendingStorageParent = nil + Task { @MainActor in + await state.moveStorage(toParent: parent) + } + } + Button("Cancel", role: .cancel) { + pendingStorageParent = nil + } + } message: { + Text("Images, containers, volumes and the build cache are copied to \(pendingStorageParent?.path ?? "the chosen folder"). Running containers are stopped during the move. The original stays as a backup until you delete it.") + } + .confirmationDialog( + "Delete the backup?", + isPresented: $confirmDeleteBackup, + titleVisibility: .visible + ) { + Button("Delete backup", role: .destructive) { + state.deleteStorageBackup() + reloadStorageInfo() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Only do this after checking that images and containers work from the new location.") + } + } + + private func reloadStorageInfo() { + let location = StorageLocation.standard + storageRedirected = location.isRedirected + storageHasBackup = location.backup != nil + storageSizes = location.sizes() + } + + /// Lets the user pick a parent folder, then asks for confirmation. + private func chooseStorageFolder() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.canCreateDirectories = true + panel.allowsMultipleSelection = false + panel.prompt = "Choose" + panel.message = "Choose the folder where the container storage should live." + if panel.runModal() == .OK, let url = panel.url { + pendingStorageParent = url + } + } + private var storage: some View { ScrollView { Form { + storageLocation Section("Disk usage") { HStack { Text("Total") diff --git a/Sources/ContainerBackend/LaunchdManager.swift b/Sources/ContainerBackend/LaunchdManager.swift index 1472e9a..8276b1d 100644 --- a/Sources/ContainerBackend/LaunchdManager.swift +++ b/Sources/ContainerBackend/LaunchdManager.swift @@ -24,9 +24,10 @@ public struct LaunchdManager: Sendable { } /// Start the container services. Idempotent: starting an already-running - /// daemon is a no-op. - public func start() async throws { - let result = try await runner.run(["system", "start"]) + /// daemon is a no-op. `environment` is passed to `container system start`, + /// which records it for the daemon (for example CONTAINER_APP_ROOT). + public func start(environment: [String: String] = [:]) async throws { + let result = try await runner.run(["system", "start"], environment: environment) guard result.succeeded else { throw BackendError.operationFailed( "failed to start container services: \(result.stderr.trimmingCharacters(in: .whitespacesAndNewlines))" diff --git a/Sources/ContainerBackend/StorageLocation.swift b/Sources/ContainerBackend/StorageLocation.swift new file mode 100644 index 0000000..bf5cb57 --- /dev/null +++ b/Sources/ContainerBackend/StorageLocation.swift @@ -0,0 +1,293 @@ +//===----------------------------------------------------------------------===// +// StorageLocation — where the container runtime keeps images, layers, +// containers, volumes and the build cache. +// +// The runtime uses one root directory (CONTAINER_APP_ROOT, default +// ~/Library/Application Support/com.apple.container). To move it, the data is +// copied to a new parent folder, verified, and the default path is replaced by +// a symlink to the copy. The original is kept as ".bak" until the caller +// deletes it. The CLI and the daemon both follow the symlink, so no environment +// variable is needed for the CLI. +// +// This type only moves files. Stopping and starting the daemon is the caller's +// job, and must happen before `move` runs. +//===----------------------------------------------------------------------===// + +import Foundation + +public enum StorageLocationError: LocalizedError, Equatable { + case alreadyRedirected(String) + case destinationMissing(String) + case destinationExists(String) + case notEnoughSpace(needed: UInt64, available: UInt64) + case backupExists(String) + case copyFailed(String) + case sizeMismatch(expected: UInt64, actual: UInt64) + + static func format(_ bytes: UInt64) -> String { + ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file) + } + + public var errorDescription: String? { + switch self { + case .alreadyRedirected(let target): + "Storage already points to \(target). Restore the default location before moving it again." + case .destinationMissing(let path): + "The destination folder does not exist: \(path). If it is on an external disk, connect it first." + case .destinationExists(let path): + "Something already exists at \(path). Choose another folder." + case .notEnoughSpace(let needed, let available): + "Not enough free space at the destination: \(Self.format(needed)) needed, \(Self.format(available)) available." + case .backupExists(let path): + "A backup already exists at \(path). Delete it before moving the storage again." + case .copyFailed(let detail): + "Copying the storage failed: \(detail)" + case .sizeMismatch(let expected, let actual): + "The copy is incomplete: expected \(expected) bytes, found \(actual)." + } + } +} + +/// The result of a successful move. +public struct StorageMoveResult: Sendable, Equatable { + /// Where the data lives now (the target of the symlink). + public let newRoot: URL + /// The original folder, kept until the caller removes it. + public let backup: URL +} + +public struct StorageLocation: Sendable { + /// The path the runtime and the CLI use: the real folder, or a symlink to it. + public let link: URL + + public init(link: URL) { + self.link = link + } + + /// The default runtime root. + public static var standard: StorageLocation { + StorageLocation(link: defaultRoot) + } + + /// `~/Library/Application Support/com.apple.container`. + public static var defaultRoot: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("com.apple.container", isDirectory: true) + } + + /// True when the default path is a symlink to another folder. Asks the file + /// manager directly: `URL.resourceValues` caches its answer on the URL instance. + public var isRedirected: Bool { + (try? FileManager.default.destinationOfSymbolicLink(atPath: link.path)) != nil + } + + /// The folder that actually holds the data. + public var resolvedRoot: URL { + link.resolvingSymlinksInPath() + } + + /// Space used by each top-level folder of the store (allocated blocks, so + /// sparse disk images count only what they occupy), largest first. + public func sizes() -> [(name: String, bytes: UInt64)] { + let fm = FileManager.default + let entries = (try? fm.contentsOfDirectory(at: resolvedRoot, includingPropertiesForKeys: nil)) ?? [] + return entries + .map { (name: $0.lastPathComponent, bytes: Self.allocatedSize($0)) } + .sorted { $0.bytes > $1.bytes } + } + + /// The backup folder that `move` leaves behind, if one exists. + public var backup: URL? { + let url = link.deletingLastPathComponent().appendingPathComponent(link.lastPathComponent + ".bak", isDirectory: true) + return FileManager.default.fileExists(atPath: url.path) ? url : nil + } + + /// Copies `source` to `destination`. `allocationLimit` is the most space the + /// copy may occupy on disk; a copy that exceeds it must stop with an error. + public typealias CopyFunction = (_ source: URL, _ destination: URL, _ allocationLimit: UInt64) throws -> Void + + /// Copies the store into `parent/`, verifies the copy, and swaps the + /// default path for a symlink to it. On any failure the original is put + /// back and the partial copy is removed. + public func move( + toParent parent: URL, + copy: CopyFunction = StorageLocation.sparseCopy, + availableCapacity: (URL) -> UInt64 = StorageLocation.availableCapacity + ) throws -> StorageMoveResult { + let fm = FileManager.default + if isRedirected { + throw StorageLocationError.alreadyRedirected(resolvedRoot.path) + } + guard fm.fileExists(atPath: parent.path) else { + throw StorageLocationError.destinationMissing(parent.path) + } + let destination = parent.appendingPathComponent(link.lastPathComponent, isDirectory: true) + guard !fm.fileExists(atPath: destination.path) else { + throw StorageLocationError.destinationExists(destination.path) + } + let backupURL = link.deletingLastPathComponent() + .appendingPathComponent(link.lastPathComponent + ".bak", isDirectory: true) + guard !fm.fileExists(atPath: backupURL.path) else { + throw StorageLocationError.backupExists(backupURL.path) + } + + let source = link + // Sparse disk images copy as sparse files, so the room needed is the + // allocated size, not the logical one. + let needed = Self.allocatedSize(source) + let available = availableCapacity(parent) + // A safety margin: the copy also needs room for metadata. + let required = needed + needed / 10 + guard available >= required else { + throw StorageLocationError.notEnoughSpace(needed: required, available: available) + } + // The copy may use up to 1.5x the source's allocated space, plus 64 MB. + let allocationLimit = needed + needed / 2 + 64_000_000 + + do { + try copy(source, destination, allocationLimit) + } catch { + try? fm.removeItem(at: destination) + throw StorageLocationError.copyFailed(error.localizedDescription) + } + guard Self.allocatedSize(destination) <= allocationLimit else { + try? fm.removeItem(at: destination) + throw StorageLocationError.copyFailed("the copy occupies more space than expected") + } + + // Compare logical sizes on both sides: the copy preserves them exactly. + let expected = Self.directorySize(source) + let copied = Self.directorySize(destination) + guard copied == expected else { + try? fm.removeItem(at: destination) + throw StorageLocationError.sizeMismatch(expected: expected, actual: copied) + } + + do { + try fm.moveItem(at: source, to: backupURL) + } catch { + try? fm.removeItem(at: destination) + throw StorageLocationError.copyFailed("could not set the original aside: \(error.localizedDescription)") + } + do { + try fm.createSymbolicLink(at: link, withDestinationURL: destination) + } catch { + // Put the original back so the runtime still finds its data. + try? fm.moveItem(at: backupURL, to: source) + try? fm.removeItem(at: destination) + throw StorageLocationError.copyFailed("could not create the link: \(error.localizedDescription)") + } + return StorageMoveResult(newRoot: destination, backup: backupURL) + } + + /// Puts the original back: removes the symlink and renames the backup to the + /// default path. Does nothing when the store is not redirected. + public func restoreBackup() throws { + guard isRedirected, let backup else { return } + try FileManager.default.removeItem(at: link) + try FileManager.default.moveItem(at: backup, to: link) + } + + /// The environment variable the runtime reads for its root folder. + public static let environmentName = "CONTAINER_APP_ROOT" + + /// Deletes the backup left by `move`. Only call this after the moved storage + /// has been checked. + public func deleteBackup() throws { + guard let backup else { return } + try FileManager.default.removeItem(at: backup) + } + + /// Copies a folder with `tar | tar`. bsdtar finds the holes in sparse files + /// with SEEK_HOLE and does not read them, so a 550 GB sparse image with 40 MB + /// of data copies in seconds. The copy is watched while it runs: if the space + /// it occupies passes `allocationLimit`, the copy is stopped and an error is + /// thrown, so a copy that expands sparse files cannot fill the disk. + public static func sparseCopy(from source: URL, to destination: URL, allocationLimit: UInt64) throws { + try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + // Paths are passed as arguments, never pasted into the script. + process.arguments = [ + "-c", + "set -o pipefail; /usr/bin/tar -cf - -C \"$1\" . | /usr/bin/tar -xf - -C \"$2\"", + "sparse-copy", source.path, destination.path, + ] + let errors = Pipe() + process.standardError = errors + process.standardOutput = FileHandle.nullDevice + try process.run() + + var exceeded = false + while process.isRunning { + Thread.sleep(forTimeInterval: 1) + if allocatedSize(destination) > allocationLimit { + exceeded = true + stopCopy(destinationPath: destination.path, process: process) + break + } + } + process.waitUntilExit() + let stderr = String(decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + if exceeded { + throw StorageLocationError.copyFailed("the copy grew past the expected size and was stopped") + } + guard process.terminationStatus == 0 else { + throw StorageLocationError.copyFailed(stderr.trimmingCharacters(in: .whitespacesAndNewlines)) + } + } + + /// Stops a copy: the shell, and the tar writing to this destination (the + /// destination path is unique to this move, so the match is exact). + private static func stopCopy(destinationPath: String, process: Process) { + process.terminate() + let killer = Process() + killer.executableURL = URL(fileURLWithPath: "/usr/bin/pkill") + killer.arguments = ["-f", "-- -C \(destinationPath)"] + try? killer.run() + killer.waitUntilExit() + } + + /// Space occupied on disk by the files under `url`. + static func allocatedSize(_ url: URL) -> UInt64 { + totalSize(url, keys: [.totalFileAllocatedSizeKey, .isRegularFileKey]) { values in + UInt64(values.totalFileAllocatedSize ?? 0) + } + } + + /// Logical size of the files under `url`, in bytes. Used to check a copy, + /// since both sides of the comparison are counted the same way. + static func directorySize(_ url: URL) -> UInt64 { + totalSize(url, keys: [.fileSizeKey, .isRegularFileKey]) { values in + UInt64(values.fileSize ?? 0) + } + } + + private static func totalSize( + _ url: URL, + keys: Set, + bytes: (URLResourceValues) -> UInt64 + ) -> UInt64 { + let fm = FileManager.default + var isDirectory: ObjCBool = false + guard fm.fileExists(atPath: url.path, isDirectory: &isDirectory) else { return 0 } + if !isDirectory.boolValue { + return bytes((try? url.resourceValues(forKeys: keys)) ?? URLResourceValues()) + } + guard let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: Array(keys)) else { + return 0 + } + var total: UInt64 = 0 + for case let file as URL in enumerator { + guard let values = try? file.resourceValues(forKeys: keys), values.isRegularFile == true else { continue } + total += bytes(values) + } + return total + } + + /// Free space on the volume that holds `url`, in bytes. + public static func availableCapacity(at url: URL) -> UInt64 { + let values = try? url.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]) + return UInt64(values?.volumeAvailableCapacityForImportantUsage ?? 0) + } +} diff --git a/Tests/ContainerBackendTests/StorageLocationTests.swift b/Tests/ContainerBackendTests/StorageLocationTests.swift new file mode 100644 index 0000000..dde9516 --- /dev/null +++ b/Tests/ContainerBackendTests/StorageLocationTests.swift @@ -0,0 +1,165 @@ +//===----------------------------------------------------------------------===// +// StorageLocationTests — moving the runtime store, with temporary folders only. +//===----------------------------------------------------------------------===// + +import XCTest +@testable import ContainerBackend + +final class StorageLocationTests: XCTestCase { + private var root: URL! + + override func setUpWithError() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("storage-test-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: root) + } + + /// A store at `root/home/com.apple.container` with a few files. + private func makeStore() throws -> StorageLocation { + let link = root.appendingPathComponent("home/com.apple.container", isDirectory: true) + try FileManager.default.createDirectory(at: link.appendingPathComponent("content"), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: link.appendingPathComponent("snapshots"), withIntermediateDirectories: true) + try Data(repeating: 1, count: 4_000).write(to: link.appendingPathComponent("content/blob")) + try Data(repeating: 2, count: 2_000).write(to: link.appendingPathComponent("snapshots/layer")) + return StorageLocation(link: link) + } + + private func makeDestinationParent() throws -> URL { + let parent = root.appendingPathComponent("external", isDirectory: true) + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true) + return parent + } + + func testMoveCopiesVerifiesAndLeavesSymlinkAndBackup() throws { + let store = try makeStore() + let parent = try makeDestinationParent() + + let result = try store.move(toParent: parent) + + XCTAssertTrue(store.isRedirected) + XCTAssertEqual(store.resolvedRoot.standardizedFileURL, result.newRoot.standardizedFileURL) + XCTAssertEqual( + try Data(contentsOf: store.link.appendingPathComponent("content/blob")).count, 4_000 + ) + XCTAssertNotNil(store.backup) + XCTAssertTrue(FileManager.default.fileExists(atPath: result.backup.path)) + } + + func testDeleteBackupRemovesOnlyTheBackup() throws { + let store = try makeStore() + let parent = try makeDestinationParent() + _ = try store.move(toParent: parent) + + try store.deleteBackup() + + XCTAssertNil(store.backup) + XCTAssertTrue(store.isRedirected) + XCTAssertTrue(FileManager.default.fileExists(atPath: store.link.appendingPathComponent("content/blob").path)) + } + + func testFailedCopyRestoresTheOriginal() throws { + let store = try makeStore() + let parent = try makeDestinationParent() + struct Boom: Error {} + + XCTAssertThrowsError(try store.move(toParent: parent, copy: { _, _, _ in throw Boom() })) { error in + guard case StorageLocationError.copyFailed = error else { + return XCTFail("expected copyFailed, got \(error)") + } + } + + XCTAssertFalse(store.isRedirected) + XCTAssertNil(store.backup) + XCTAssertTrue(FileManager.default.fileExists(atPath: store.link.appendingPathComponent("content/blob").path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: parent.appendingPathComponent("com.apple.container").path)) + } + + func testIncompleteCopyIsRejectedAndCleanedUp() throws { + let store = try makeStore() + let parent = try makeDestinationParent() + + // A copy that drops one file. + let partial: (URL, URL) throws -> Void = { source, destination in + try FileManager.default.createDirectory(at: destination.appendingPathComponent("content"), withIntermediateDirectories: true) + try Data(repeating: 1, count: 10).write(to: destination.appendingPathComponent("content/blob")) + } + + XCTAssertThrowsError(try store.move(toParent: parent, copy: { source, destination, _ in try partial(source, destination) })) { error in + guard case StorageLocationError.sizeMismatch = error else { + return XCTFail("expected sizeMismatch, got \(error)") + } + } + XCTAssertFalse(store.isRedirected) + XCTAssertFalse(FileManager.default.fileExists(atPath: parent.appendingPathComponent("com.apple.container").path)) + } + + func testSparseImagesNeedOnlyTheirAllocatedSpace() throws { + let store = try makeStore() + let parent = try makeDestinationParent() + // A sparse 10 GB image that occupies almost nothing on disk. + let image = store.link.appendingPathComponent("snapshots/sparse.img") + FileManager.default.createFile(atPath: image.path, contents: nil) + let handle = try FileHandle(forWritingTo: image) + try handle.truncate(atOffset: 10_000_000_000) + try handle.close() + + // Only 1 MB free: enough for the allocated bytes, far less than the logical size. + let result = try store.move(toParent: parent, availableCapacity: { _ in 1_000_000 }) + + XCTAssertTrue(store.isRedirected) + XCTAssertTrue(FileManager.default.fileExists(atPath: result.newRoot.appendingPathComponent("snapshots/sparse.img").path)) + } + + func testMissingDestinationIsRefused() throws { + let store = try makeStore() + let missing = root.appendingPathComponent("not-mounted", isDirectory: true) + + XCTAssertThrowsError(try store.move(toParent: missing)) { error in + guard case StorageLocationError.destinationMissing = error else { + return XCTFail("expected destinationMissing, got \(error)") + } + } + XCTAssertFalse(store.isRedirected) + } + + func testAlreadyRedirectedIsRefused() throws { + let store = try makeStore() + let parent = try makeDestinationParent() + _ = try store.move(toParent: parent) + let second = root.appendingPathComponent("second", isDirectory: true) + try FileManager.default.createDirectory(at: second, withIntermediateDirectories: true) + + XCTAssertThrowsError(try store.move(toParent: second)) { error in + guard case StorageLocationError.alreadyRedirected = error else { + return XCTFail("expected alreadyRedirected, got \(error)") + } + } + } + + func testRestoreBackupPutsTheOriginalBack() throws { + let store = try makeStore() + let parent = try makeDestinationParent() + _ = try store.move(toParent: parent) + + try store.restoreBackup() + + XCTAssertFalse(store.isRedirected) + XCTAssertNil(store.backup) + XCTAssertTrue(FileManager.default.fileExists(atPath: store.link.appendingPathComponent("content/blob").path)) + } + + func testSizesReportAllocatedSpacePerFolder() throws { + let store = try makeStore() + + let sizes = Dictionary(uniqueKeysWithValues: store.sizes().map { ($0.name, $0.bytes) }) + + // Allocated space is at least the logical size, and comes in whole blocks. + XCTAssertGreaterThanOrEqual(sizes["content"] ?? 0, 4_000) + XCTAssertGreaterThanOrEqual(sizes["snapshots"] ?? 0, 2_000) + XCTAssertEqual(Set(sizes.keys), ["content", "snapshots"]) + } +}