Commit 0fbe46cc00
Verified · cmc
Layout: unified · split
Sources/OrgDocument/FileStorage.swift added +92
| @@ -0,0 +1,92 @@ | |||
| 1 | import CryptoKit | ||
| 2 | import Foundation | ||
| 3 | |||
| 4 | /// Reads and replaces files under `NSFileCoordinator`, so iCloud and other coordinating | ||
| 5 | /// writers see a consistent file. | ||
| 6 | public struct CoordinatedFileSystem: FileSystem { | ||
| 7 | public init() {} | ||
| 8 | |||
| 9 | public func read(_ url: URL) throws -> [UInt8]? { | ||
| 10 | try coordinate(reading: url) { url in | ||
| 11 | FileManager.default.fileExists(atPath: url.path) ? [UInt8](try Data(contentsOf: url)) : nil | ||
| 12 | } | ||
| 13 | } | ||
| 14 | |||
| 15 | public func replace(_ url: URL, with bytes: [UInt8]) throws -> [UInt8]? { | ||
| 16 | try coordinate(writing: url) { url in | ||
| 17 | let manager = FileManager.default | ||
| 18 | let folder = url.deletingLastPathComponent() | ||
| 19 | let temporary = folder.appendingPathComponent(".\(url.lastPathComponent).orgstar-\(UUID().uuidString)") | ||
| 20 | try Data(bytes).write(to: temporary) | ||
| 21 | guard manager.fileExists(atPath: url.path) else { | ||
| 22 | try manager.moveItem(at: temporary, to: url) | ||
| 23 | return nil | ||
| 24 | } | ||
| 25 | // The backup is the file as it was at the moment of replacement, including any | ||
| 26 | // write that landed after our last check. | ||
| 27 | let backupName = ".\(url.lastPathComponent).orgstar-backup-\(UUID().uuidString)" | ||
| 28 | _ = try manager.replaceItemAt(url, withItemAt: temporary, backupItemName: backupName, options: .withoutDeletingBackupItem) | ||
| 29 | let backup = folder.appendingPathComponent(backupName) | ||
| 30 | defer { try? manager.removeItem(at: backup) } | ||
| 31 | return [UInt8](try Data(contentsOf: backup)) | ||
| 32 | } | ||
| 33 | } | ||
| 34 | |||
| 35 | private func coordinate<T>(reading url: URL, _ body: (URL) throws -> T) throws -> T { | ||
| 36 | var coordinationError: NSError? | ||
| 37 | var result: Result<T, Error>? | ||
| 38 | NSFileCoordinator(filePresenter: nil).coordinate(readingItemAt: url, options: [], error: &coordinationError) { url in | ||
| 39 | result = Result { try body(url) } | ||
| 40 | } | ||
| 41 | if let coordinationError { throw coordinationError } | ||
| 42 | return try result!.get() | ||
| 43 | } | ||
| 44 | |||
| 45 | private func coordinate<T>(writing url: URL, _ body: (URL) throws -> T) throws -> T { | ||
| 46 | var coordinationError: NSError? | ||
| 47 | var result: Result<T, Error>? | ||
| 48 | NSFileCoordinator(filePresenter: nil).coordinate(writingItemAt: url, options: .forReplacing, error: &coordinationError) { url in | ||
| 49 | result = Result { try body(url) } | ||
| 50 | } | ||
| 51 | if let coordinationError { throw coordinationError } | ||
| 52 | return try result!.get() | ||
| 53 | } | ||
| 54 | } | ||
| 55 | |||
| 56 | /// Keeps the last `limit` displaced versions per file in `directory/<hash of path>/`. | ||
| 57 | public struct FileRecoveryStore: RecoveryStore { | ||
| 58 | public let directory: URL | ||
| 59 | public let limit: Int | ||
| 60 | |||
| 61 | public init(directory: URL, limit: Int = 20) { | ||
| 62 | self.directory = directory | ||
| 63 | self.limit = limit | ||
| 64 | } | ||
| 65 | |||
| 66 | public func folder(for url: URL) -> URL { | ||
| 67 | let digest = SHA256.hash(data: Data(url.standardizedFileURL.path.utf8)) | ||
| 68 | let name = digest.prefix(8).map { String(format: "%02x", $0) }.joined() | ||
| 69 | return directory.appendingPathComponent(name, isDirectory: true) | ||
| 70 | } | ||
| 71 | |||
| 72 | public func keep(_ bytes: [UInt8], for url: URL, label: String) throws { | ||
| 73 | let folder = folder(for: url) | ||
| 74 | let manager = FileManager.default | ||
| 75 | try manager.createDirectory(at: folder, withIntermediateDirectories: true) | ||
| 76 | // Zero-padded wall-clock nanoseconds sort by time; the UUID keeps same-instant names apart. | ||
| 77 | let stamp = String(format: "%020llu", UInt64(Date().timeIntervalSince1970 * 1_000_000_000)) | ||
| 78 | let name = "\(stamp)-\(label)-\(UUID().uuidString.prefix(8))-\(url.lastPathComponent)" | ||
| 79 | try Data(bytes).write(to: folder.appendingPathComponent(name)) | ||
| 80 | let kept = try manager.contentsOfDirectory(atPath: folder.path).sorted() | ||
| 81 | for old in kept.dropLast(limit) { | ||
| 82 | try manager.removeItem(at: folder.appendingPathComponent(old)) | ||
| 83 | } | ||
| 84 | } | ||
| 85 | |||
| 86 | /// Kept versions, oldest first. | ||
| 87 | public func versions(for url: URL) throws -> [URL] { | ||
| 88 | let folder = folder(for: url) | ||
| 89 | guard FileManager.default.fileExists(atPath: folder.path) else { return [] } | ||
| 90 | return try FileManager.default.contentsOfDirectory(atPath: folder.path).sorted().map { folder.appendingPathComponent($0) } | ||
| 91 | } | ||
| 92 | } | ||
Sources/OrgDocument/Saving.swift added +81
| @@ -0,0 +1,81 @@ | |||
| 1 | import Foundation | ||
| 2 | import OrgCore | ||
| 3 | |||
| 4 | public protocol FileSystem: Sendable { | ||
| 5 | /// The file's bytes, or nil if it doesn't exist. | ||
| 6 | func read(_ url: URL) throws -> [UInt8]? | ||
| 7 | /// Replaces the file through a temporary file in the same folder. Returns the bytes that | ||
| 8 | /// were replaced, read from the replaced file itself, or nil if there was none. | ||
| 9 | func replace(_ url: URL, with bytes: [UInt8]) throws -> [UInt8]? | ||
| 10 | } | ||
| 11 | |||
| 12 | public protocol RecoveryStore: Sendable { | ||
| 13 | func keep(_ bytes: [UInt8], for url: URL, label: String) throws | ||
| 14 | } | ||
| 15 | |||
| 16 | public enum SaveOutcome: Sendable, Equatable { | ||
| 17 | case saved | ||
| 18 | /// The file had changed since it was read; the change merged cleanly into the buffer and | ||
| 19 | /// the merge was written. | ||
| 20 | case mergedAndSaved([TextEdit]) | ||
| 21 | /// The file had changed and the change conflicts. Nothing was written; the buffer is | ||
| 22 | /// unchanged. | ||
| 23 | case conflict([MergeConflict]) | ||
| 24 | /// Another writer replaced the file between our last check and our write. Ours is on disk; | ||
| 25 | /// theirs is in recovery and was merged into the buffer where possible. | ||
| 26 | case overwroteExternalChange(DocumentState.ExternalChange) | ||
| 27 | /// Another writer changed the file right after our write. Theirs is on disk; ours is in | ||
| 28 | /// recovery. | ||
| 29 | case changedAfterWrite(DocumentState.ExternalChange) | ||
| 30 | } | ||
| 31 | |||
| 32 | public enum SaveError: Error, Equatable { | ||
| 33 | case fileKeepsChanging | ||
| 34 | } | ||
| 35 | |||
| 36 | /// The save sequence from the design: read, merge if the file moved, check again, replace, | ||
| 37 | /// read back. Emacs and Syncthing don't coordinate, so the sequence can't lock them out; it | ||
| 38 | /// narrows the window and makes sure every version it displaces lands in recovery. | ||
| 39 | public struct Saver: Sendable { | ||
| 40 | public let fileSystem: FileSystem | ||
| 41 | public let recovery: RecoveryStore | ||
| 42 | public var maxAttempts = 3 | ||
| 43 | |||
| 44 | public init(fileSystem: FileSystem, recovery: RecoveryStore) { | ||
| 45 | self.fileSystem = fileSystem | ||
| 46 | self.recovery = recovery | ||
| 47 | } | ||
| 48 | |||
| 49 | public func save(_ state: inout DocumentState, to url: URL) throws -> SaveOutcome { | ||
| 50 | guard state.isEditable else { throw DocumentState.EditError.readOnly } | ||
| 51 | for _ in 0..<maxAttempts { | ||
| 52 | let disk = try fileSystem.read(url) | ||
| 53 | var merged: [TextEdit]? | ||
| 54 | if let disk, disk != state.mergeBase { | ||
| 55 | try recovery.keep(disk, for: url, label: "external") | ||
| 56 | try recovery.keep(state.encodedText(), for: url, label: "local") | ||
| 57 | switch state.diskChanged(to: disk) { | ||
| 58 | case .conflict(let conflicts): return .conflict(conflicts) | ||
| 59 | case .merged(let edits), .reloaded(let edits): merged = edits | ||
| 60 | case .unchanged: break | ||
| 61 | } | ||
| 62 | } | ||
| 63 | let bytes = try state.encodedText() | ||
| 64 | guard try fileSystem.read(url) == disk else { continue } | ||
| 65 | |||
| 66 | let replaced = try fileSystem.replace(url, with: bytes) | ||
| 67 | state.didWrite(bytes) | ||
| 68 | if replaced != disk, let replaced { | ||
| 69 | try recovery.keep(replaced, for: url, label: "external") | ||
| 70 | return .overwroteExternalChange(state.mergeOverwritten(replaced, base: disk ?? [])) | ||
| 71 | } | ||
| 72 | |||
| 73 | if let after = try fileSystem.read(url), after != bytes { | ||
| 74 | try recovery.keep(bytes, for: url, label: "local") | ||
| 75 | return .changedAfterWrite(state.diskChanged(to: after)) | ||
| 76 | } | ||
| 77 | return merged.map { .mergedAndSaved($0) } ?? .saved | ||
| 78 | } | ||
| 79 | throw SaveError.fileKeepsChanging | ||
| 80 | } | ||
| 81 | } | ||
Tests/OrgDocumentTests/SaveTests.swift added +194
| @@ -0,0 +1,194 @@ | |||
| 1 | import Foundation | ||
| 2 | import OrgCore | ||
| 3 | import Testing | ||
| 4 | @testable import OrgDocument | ||
| 5 | |||
| 6 | /// An in-memory file with hooks that let another writer change it at each step of a save. | ||
| 7 | final class FaultyFileSystem: FileSystem, @unchecked Sendable { | ||
| 8 | var file: [UInt8]? | ||
| 9 | var reads = 0 | ||
| 10 | /// Content another writer puts in place just before the n-th read (1-based). | ||
| 11 | var beforeRead: [Int: [UInt8]] = [:] | ||
| 12 | /// Content another writer puts in place just before our replace. | ||
| 13 | var beforeReplace: [UInt8]? | ||
| 14 | |||
| 15 | init(_ text: String?) { | ||
| 16 | file = text.map { Array($0.utf8) } | ||
| 17 | } | ||
| 18 | |||
| 19 | func read(_ url: URL) throws -> [UInt8]? { | ||
| 20 | reads += 1 | ||
| 21 | if let injected = beforeRead[reads] { file = injected } | ||
| 22 | return file | ||
| 23 | } | ||
| 24 | |||
| 25 | func replace(_ url: URL, with bytes: [UInt8]) throws -> [UInt8]? { | ||
| 26 | if let injected = beforeReplace { file = injected } | ||
| 27 | let replaced = file | ||
| 28 | file = bytes | ||
| 29 | return replaced | ||
| 30 | } | ||
| 31 | |||
| 32 | var text: String? { file.map { String(decoding: $0, as: UTF8.self) } } | ||
| 33 | } | ||
| 34 | |||
| 35 | final class MemoryRecovery: RecoveryStore, @unchecked Sendable { | ||
| 36 | var kept: [(label: String, text: String)] = [] | ||
| 37 | |||
| 38 | func keep(_ bytes: [UInt8], for url: URL, label: String) throws { | ||
| 39 | kept.append((label, String(decoding: bytes, as: UTF8.self))) | ||
| 40 | } | ||
| 41 | |||
| 42 | func contains(_ text: String) -> Bool { kept.contains { $0.text == text } } | ||
| 43 | } | ||
| 44 | |||
| 45 | let url = URL(fileURLWithPath: "/notes/a.org") | ||
| 46 | |||
| 47 | /// A buffer loaded from "a\nb\nc\n" with its first line changed to "A". | ||
| 48 | func editedState() throws -> DocumentState { | ||
| 49 | var doc = state("a\nb\nc\n") | ||
| 50 | try doc.apply([TextEdit(range: 0..<1, replacement: "A")], baseRevision: 0) | ||
| 51 | return doc | ||
| 52 | } | ||
| 53 | |||
| 54 | struct SaveTests { | ||
| 55 | @Test func plainSave() throws { | ||
| 56 | let files = FaultyFileSystem("a\nb\nc\n") | ||
| 57 | let recovery = MemoryRecovery() | ||
| 58 | var doc = try editedState() | ||
| 59 | #expect(try Saver(fileSystem: files, recovery: recovery).save(&doc, to: url) == .saved) | ||
| 60 | #expect(files.text == "A\nb\nc\n") | ||
| 61 | #expect(!doc.isDirty) | ||
| 62 | #expect(recovery.kept.isEmpty) | ||
| 63 | } | ||
| 64 | |||
| 65 | @Test func missingFileIsCreated() throws { | ||
| 66 | let files = FaultyFileSystem(nil) | ||
| 67 | var doc = try editedState() | ||
| 68 | #expect(try Saver(fileSystem: files, recovery: MemoryRecovery()).save(&doc, to: url) == .saved) | ||
| 69 | #expect(files.text == "A\nb\nc\n") | ||
| 70 | } | ||
| 71 | |||
| 72 | @Test func readOnlyDocumentsAreNotSaved() { | ||
| 73 | var doc = DocumentState(bytes: [0x61, 0xFF]) | ||
| 74 | #expect(throws: DocumentState.EditError.readOnly) { | ||
| 75 | try Saver(fileSystem: FaultyFileSystem("x"), recovery: MemoryRecovery()).save(&doc, to: url) | ||
| 76 | } | ||
| 77 | } | ||
| 78 | |||
| 79 | // MARK: - Another writer at each step | ||
| 80 | |||
| 81 | @Test func changedBeforeSaveMerges() throws { | ||
| 82 | let files = FaultyFileSystem("a\nb\nc\n") | ||
| 83 | files.beforeRead[1] = Array("a\nb\nC\n".utf8) | ||
| 84 | let recovery = MemoryRecovery() | ||
| 85 | var doc = try editedState() | ||
| 86 | guard case .mergedAndSaved = try Saver(fileSystem: files, recovery: recovery).save(&doc, to: url) else { | ||
| 87 | Issue.record("expected a merge") | ||
| 88 | return | ||
| 89 | } | ||
| 90 | #expect(files.text == "A\nb\nC\n") | ||
| 91 | #expect(recovery.contains("a\nb\nC\n") && recovery.contains("A\nb\nc\n")) | ||
| 92 | } | ||
| 93 | |||
| 94 | @Test func conflictingChangeWritesNothing() throws { | ||
| 95 | let files = FaultyFileSystem("a\nb\nc\n") | ||
| 96 | files.beforeRead[1] = Array("Z\nb\nc\n".utf8) | ||
| 97 | var doc = try editedState() | ||
| 98 | guard case .conflict = try Saver(fileSystem: files, recovery: MemoryRecovery()).save(&doc, to: url) else { | ||
| 99 | Issue.record("expected a conflict") | ||
| 100 | return | ||
| 101 | } | ||
| 102 | #expect(files.text == "Z\nb\nc\n") | ||
| 103 | #expect(doc.text == "A\nb\nc\n") | ||
| 104 | } | ||
| 105 | |||
| 106 | @Test func changedBetweenReadAndCheckRetries() throws { | ||
| 107 | let files = FaultyFileSystem("a\nb\nc\n") | ||
| 108 | files.beforeRead[2] = Array("a\nb\nC\n".utf8) | ||
| 109 | var doc = try editedState() | ||
| 110 | guard case .mergedAndSaved = try Saver(fileSystem: files, recovery: MemoryRecovery()).save(&doc, to: url) else { | ||
| 111 | Issue.record("expected a merge on the second attempt") | ||
| 112 | return | ||
| 113 | } | ||
| 114 | #expect(files.text == "A\nb\nC\n") | ||
| 115 | } | ||
| 116 | |||
| 117 | @Test func changedJustBeforeReplaceIsRecovered() throws { | ||
| 118 | let files = FaultyFileSystem("a\nb\nc\n") | ||
| 119 | files.beforeReplace = Array("a\nb\nC\n".utf8) | ||
| 120 | let recovery = MemoryRecovery() | ||
| 121 | var doc = try editedState() | ||
| 122 | guard case .overwroteExternalChange(.merged) = try Saver(fileSystem: files, recovery: recovery).save(&doc, to: url) else { | ||
| 123 | Issue.record("expected their change merged into the buffer") | ||
| 124 | return | ||
| 125 | } | ||
| 126 | #expect(files.text == "A\nb\nc\n") | ||
| 127 | #expect(recovery.contains("a\nb\nC\n")) | ||
| 128 | #expect(doc.text == "A\nb\nC\n") | ||
| 129 | #expect(doc.isDirty) | ||
| 130 | } | ||
| 131 | |||
| 132 | @Test func changedRightAfterWriteKeepsOursInRecovery() throws { | ||
| 133 | let files = FaultyFileSystem("a\nb\nc\n") | ||
| 134 | files.beforeRead[3] = Array("A\nb\nc\nD\n".utf8) | ||
| 135 | let recovery = MemoryRecovery() | ||
| 136 | var doc = try editedState() | ||
| 137 | guard case .changedAfterWrite(.reloaded) = try Saver(fileSystem: files, recovery: recovery).save(&doc, to: url) else { | ||
| 138 | Issue.record("expected a reload of their version") | ||
| 139 | return | ||
| 140 | } | ||
| 141 | #expect(recovery.contains("A\nb\nc\n")) | ||
| 142 | #expect(doc.text == "A\nb\nc\nD\n") | ||
| 143 | } | ||
| 144 | |||
| 145 | @Test func keepsChangingGivesUp() throws { | ||
| 146 | let files = FaultyFileSystem("a\nb\nc\n") | ||
| 147 | for n in stride(from: 2, through: 6, by: 2) { files.beforeRead[n] = Array("a\nb\nc\n\(n)\n".utf8) } | ||
| 148 | var doc = try editedState() | ||
| 149 | #expect(throws: SaveError.fileKeepsChanging) { | ||
| 150 | try Saver(fileSystem: files, recovery: MemoryRecovery()).save(&doc, to: url) | ||
| 151 | } | ||
| 152 | } | ||
| 153 | } | ||
| 154 | |||
| 155 | struct FileStorageTests { | ||
| 156 | func temporaryFolder() throws -> URL { | ||
| 157 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-\(UUID().uuidString)") | ||
| 158 | try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) | ||
| 159 | return folder | ||
| 160 | } | ||
| 161 | |||
| 162 | @Test func readAndReplace() throws { | ||
| 163 | let folder = try temporaryFolder() | ||
| 164 | defer { try? FileManager.default.removeItem(at: folder) } | ||
| 165 | let file = folder.appendingPathComponent("a.org") | ||
| 166 | let files = CoordinatedFileSystem() | ||
| 167 | #expect(try files.read(file) == nil) | ||
| 168 | #expect(try files.replace(file, with: Array("one\n".utf8)) == nil) | ||
| 169 | #expect(try files.replace(file, with: Array("two\n".utf8)) == Array("one\n".utf8)) | ||
| 170 | #expect(try files.read(file) == Array("two\n".utf8)) | ||
| 171 | #expect(try FileManager.default.contentsOfDirectory(atPath: folder.path) == ["a.org"]) | ||
| 172 | } | ||
| 173 | |||
| 174 | @Test func savesThroughTheRealFileSystem() throws { | ||
| 175 | let folder = try temporaryFolder() | ||
| 176 | defer { try? FileManager.default.removeItem(at: folder) } | ||
| 177 | let file = folder.appendingPathComponent("a.org") | ||
| 178 | try Data("a\nb\nc\n".utf8).write(to: file) | ||
| 179 | let recovery = FileRecoveryStore(directory: folder.appendingPathComponent("recovery")) | ||
| 180 | var doc = try editedState() | ||
| 181 | #expect(try Saver(fileSystem: CoordinatedFileSystem(), recovery: recovery).save(&doc, to: file) == .saved) | ||
| 182 | #expect(try String(contentsOf: file, encoding: .utf8) == "A\nb\nc\n") | ||
| 183 | } | ||
| 184 | |||
| 185 | @Test func recoveryKeepsTheNewestVersions() throws { | ||
| 186 | let folder = try temporaryFolder() | ||
| 187 | defer { try? FileManager.default.removeItem(at: folder) } | ||
| 188 | let store = FileRecoveryStore(directory: folder, limit: 3) | ||
| 189 | for n in 0..<5 { try store.keep(Array("v\(n)".utf8), for: url, label: "local") } | ||
| 190 | let versions = try store.versions(for: url) | ||
| 191 | #expect(versions.count == 3) | ||
| 192 | #expect(try versions.map { try String(contentsOf: $0, encoding: .utf8) } == ["v2", "v3", "v4"]) | ||
| 193 | } | ||
| 194 | } | ||