krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 0fbe46cc00

0fbe46cc004be39be23edfd187e8ad4896d7866c

parent: eab72a4e5c

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 00:02 UTC

Add save path with recovery and fault-injection tests

Layout: unified · split

Sources/OrgDocument/FileStorage.swift added +92
@@ -0,0 +1,92 @@
1import CryptoKit
2import Foundation
3
4/// Reads and replaces files under `NSFileCoordinator`, so iCloud and other coordinating
5/// writers see a consistent file.
6public 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>/`.
57public 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 @@
1import Foundation
2import OrgCore
3
4public 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
12public protocol RecoveryStore: Sendable {
13 func keep(_ bytes: [UInt8], for url: URL, label: String) throws
14}
15
16public 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
32public 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.
39public 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 @@
1import Foundation
2import OrgCore
3import 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.
7final 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
35final 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
45let url = URL(fileURLWithPath: "/notes/a.org")
46
47/// A buffer loaded from "a\nb\nc\n" with its first line changed to "A".
48func 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
54struct 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
155struct 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}