RadrootsAppleFileAccessTests.swift (13496B)
1 import Darwin 2 import Foundation 3 @testable import RadrootsKit 4 import Testing 5 6 @Test func appleFileAccessWritesReadsListsAndDeletesInlineFiles() throws { 7 let access = try testFileAccess() 8 let file = RadrootsFileReference(scope: .data, relativePath: "identity/public.json") 9 let data = Data(#"{"npub":"npub1test"}"#.utf8) 10 11 try access.write(.inline(data), to: file) 12 13 #expect(try access.read(file, mode: .inline(maxBytes: data.count)) == .inline(data)) 14 let entries = try access.list(RadrootsFileReference(scope: .data, relativePath: "identity")) 15 #expect(entries.map(\.file.relativePath) == ["identity/public.json"]) 16 #expect(entries.first?.name == "public.json") 17 #expect(entries.first?.sizeBytes == data.count) 18 19 try access.delete(file) 20 try access.delete(file) 21 #expect(try access.list(RadrootsFileReference(scope: .data, relativePath: "identity")).isEmpty) 22 } 23 24 @Test func appleFileAccessWritesFilesFromStagedBlobsAndReleasesThem() throws { 25 let access = try testFileAccess() 26 let data = Data("hello staged blob".utf8) 27 let staged = try access.stageBlob(data, mediaType: "text/plain", filenameHint: "note.txt") 28 let file = RadrootsFileReference(scope: .cache, relativePath: "outbox/note.txt") 29 30 #expect(try access.readStagedBlob(staged) == data) 31 32 try access.write(.stagedBlob(staged), to: file) 33 try access.releaseStagedBlob(staged) 34 35 #expect(try access.read(file, mode: .inline(maxBytes: data.count)) == .inline(data)) 36 #expect(throws: RadrootsAppleFileError.self) { 37 _ = try access.readStagedBlob(staged) 38 } 39 } 40 41 @Test func appleFileAccessStagesLargeReadsWhenInlineLimitIsExceeded() throws { 42 let access = try testFileAccess() 43 let file = RadrootsFileReference(scope: .data, relativePath: "events/large.json") 44 let data = Data("large payload".utf8) 45 46 try access.write(.inline(data), to: file) 47 48 guard case let .stagedBlob(staged) = try access.read(file, mode: .preferInline(maxBytes: 4)) else { 49 Issue.record("expected staged blob result") 50 return 51 } 52 53 #expect(staged.filenameHint == "large.json") 54 #expect(try access.readStagedBlob(staged) == data) 55 } 56 57 @Test func appleFileAccessStagesScopedFilesWithoutChangingTheSource() throws { 58 let access = try testFileAccess() 59 let file = RadrootsFileReference(scope: .data, relativePath: "events/large.json") 60 let data = Data(repeating: 7, count: 1_048_576) 61 62 try access.write(.inline(data), to: file) 63 let staged = try access.stageFile(file, mediaType: "application/json", filenameHint: "large.json") 64 65 try access.delete(file) 66 67 #expect(staged.mediaType == "application/json") 68 #expect(staged.filenameHint == "large.json") 69 #expect(staged.sizeBytes == data.count) 70 #expect(try access.readStagedBlob(staged) == data) 71 } 72 73 @Test func appleFileAccessStagesExternalFiles() throws { 74 let access = try testFileAccess() 75 let externalURL = try writeExternalTestFile(name: "selected.txt", data: Data("external".utf8)) 76 77 let staged = try access.stageExternalFile(externalURL, mediaType: "text/plain", filenameHint: nil) 78 79 #expect(staged.mediaType == "text/plain") 80 #expect(staged.filenameHint == "selected.txt") 81 #expect(try access.readStagedBlob(staged) == Data("external".utf8)) 82 } 83 84 @Test func appleFileAccessCopiesExternalFilesIntoScopedStorage() throws { 85 let access = try testFileAccess() 86 let externalURL = try writeExternalTestFile(name: "relays.json", data: Data(#"{"relays":[]}"#.utf8)) 87 let destination = RadrootsFileReference(scope: .data, relativePath: "imports/relays.json") 88 89 let imported = try access.copyExternalFile( 90 externalURL, 91 to: destination, 92 mediaType: "application/json", 93 suggestedFilename: nil 94 ) 95 96 #expect(imported.file == destination) 97 #expect(imported.originalURL == externalURL.standardizedFileURL) 98 #expect(imported.suggestedFilename == "relays.json") 99 #expect(imported.mediaType == "application/json") 100 #expect(imported.sizeBytes == UInt64(Data(#"{"relays":[]}"#.utf8).count)) 101 #expect( 102 try access.read(destination, mode: .inline(maxBytes: Data(#"{"relays":[]}"#.utf8).count)) 103 == .inline(Data(#"{"relays":[]}"#.utf8)) 104 ) 105 } 106 107 @Test func appleFileAccessPreparesAndReleasesExportDocuments() throws { 108 let access = try testFileAccess() 109 let file = RadrootsFileReference(scope: .data, relativePath: "exports/diagnostics.json") 110 let data = Data(#"{"status":"ok"}"#.utf8) 111 try access.write(.inline(data), to: file) 112 let staged = try access.stageFile(file, mediaType: "application/json", filenameHint: "diagnostics.json") 113 114 let filePrepared = try access.prepareExport( 115 RadrootsExportDocumentRequest( 116 source: .file(file), 117 suggestedFilename: "diagnostics.json", 118 mediaType: "application/json" 119 ) 120 ) 121 let stagedPrepared = try access.prepareExport( 122 RadrootsExportDocumentRequest( 123 source: .stagedBlob(staged), 124 suggestedFilename: "staged-diagnostics.json", 125 mediaType: "application/json" 126 ) 127 ) 128 let inlinePrepared = try access.prepareExport( 129 RadrootsExportDocumentRequest( 130 source: .inlineData(data), 131 suggestedFilename: "inline-diagnostics.json", 132 mediaType: "application/json" 133 ) 134 ) 135 136 #expect(try Data(contentsOf: filePrepared.fileURL) == data) 137 #expect(try Data(contentsOf: stagedPrepared.fileURL) == data) 138 #expect(try Data(contentsOf: inlinePrepared.fileURL) == data) 139 #expect(try access.preparedExportExists(filePrepared)) 140 #expect(try access.preparedExportExists(stagedPrepared)) 141 #expect(try access.preparedExportExists(inlinePrepared)) 142 #expect(filePrepared.sizeBytes == UInt64(data.count)) 143 #expect(stagedPrepared.sizeBytes == UInt64(data.count)) 144 #expect(inlinePrepared.sizeBytes == UInt64(data.count)) 145 146 try access.releasePreparedExport(filePrepared) 147 try access.releasePreparedExport(stagedPrepared) 148 try access.releasePreparedExport(inlinePrepared) 149 150 #expect(try !(access.preparedExportExists(filePrepared))) 151 #expect(try !(access.preparedExportExists(stagedPrepared))) 152 #expect(try !(access.preparedExportExists(inlinePrepared))) 153 } 154 155 @Test func appleFileAccessKeepsSmallReadsInlineWhenLimitAllowsIt() throws { 156 let access = try testFileAccess() 157 let file = RadrootsFileReference(scope: .logs, relativePath: "radroots.log") 158 let data = Data("log".utf8) 159 160 try access.write(.inline(data), to: file) 161 162 #expect(try access.read(file, mode: .preferInline(maxBytes: data.count)) == .inline(data)) 163 } 164 165 @Test func appleFileAccessRejectsInvalidStagedBlobMetadata() throws { 166 let access = try testFileAccess() 167 168 #expect(throws: RadrootsAppleFileError.self) { 169 _ = try RadrootsStagedBlobReference(blobID: "../escape", sizeBytes: 1) 170 } 171 #expect(throws: RadrootsAppleFileError.self) { 172 _ = try access.stageBlob(Data("bad".utf8), mediaType: "text/plain", filenameHint: "../secret.txt") 173 } 174 #expect(throws: RadrootsAppleFileError.self) { 175 _ = try access.read( 176 RadrootsFileReference(scope: .data, relativePath: "missing.json"), 177 mode: .inline(maxBytes: 1) 178 ) 179 } 180 #expect(throws: RadrootsAppleFileError.self) { 181 _ = try access.stageExternalFile(#require(URL(string: "https://radroots.org/file.json")), mediaType: nil, filenameHint: nil) 182 } 183 #expect(throws: RadrootsAppleFileError.self) { 184 _ = try access.stageExternalFile(writeExternalTestFile(name: "bad.txt", data: Data("bad".utf8)), mediaType: nil, filenameHint: "../bad.txt") 185 } 186 #expect(throws: RadrootsDocumentInterchangeError.self) { 187 _ = try access.prepareExport( 188 RadrootsExportDocumentRequest( 189 source: .inlineData(Data("bad".utf8)), 190 suggestedFilename: "../bad.txt", 191 mediaType: "text/plain" 192 ) 193 ) 194 } 195 } 196 197 @Test func appleFileAccessSweepsOnlyExpiredStagedBlobs() throws { 198 let access = try testFileAccess() 199 let oldBlob = try access.stageBlob(Data("old".utf8), mediaType: nil, filenameHint: nil) 200 let newBlob = try access.stageBlob(Data("new".utf8), mediaType: nil, filenameHint: nil) 201 let oldURL = access.roots.stagedBlobsRoot.appendingPathComponent(oldBlob.blobID) 202 let oldDate = Date(timeIntervalSince1970: 10) 203 let cutoff = Date(timeIntervalSince1970: 20) 204 205 try FileManager.default.setAttributes([.modificationDate: oldDate], ofItemAtPath: oldURL.path) 206 207 let swept = try access.sweepStagedBlobs(olderThan: cutoff) 208 209 #expect(swept.map(\.blobID) == [oldBlob.blobID]) 210 #expect(throws: RadrootsAppleFileError.self) { 211 _ = try access.readStagedBlob(oldBlob) 212 } 213 #expect(try access.readStagedBlob(newBlob) == Data("new".utf8)) 214 } 215 216 @Test func appleFileAccessResetsFileRootsAndStagedBlobs() throws { 217 let access = try testFileAccess() 218 let dataFile = RadrootsFileReference(scope: .data, relativePath: "state.json") 219 let cacheFile = RadrootsFileReference(scope: .cache, relativePath: "cache.json") 220 let staged = try access.stageBlob(Data("blob".utf8), mediaType: nil, filenameHint: nil) 221 222 try access.write(.inline(Data("data".utf8)), to: dataFile) 223 try access.write(.inline(Data("cache".utf8)), to: cacheFile) 224 225 try access.reset(scope: .data) 226 227 #expect(try access.list(RadrootsFileReference(scope: .data, relativePath: "")).isEmpty) 228 #expect( 229 try access.read(cacheFile, mode: .inline(maxBytes: Data("cache".utf8).count)) 230 == .inline(Data("cache".utf8)) 231 ) 232 233 try access.resetStagedBlobs() 234 235 #expect(throws: RadrootsAppleFileError.self) { 236 _ = try access.readStagedBlob(staged) 237 } 238 } 239 240 @Test func appleFileAccessRejectsUnboundedAndUnsafeGovernedReads() throws { 241 let access = try testFileAccess() 242 let file = RadrootsFileReference(scope: .data, relativePath: "governed/source.txt") 243 let data = Data("bounded".utf8) 244 try access.write(.inline(data), to: file) 245 246 #expect(throws: RadrootsAppleFileError.self) { 247 _ = try access.read(file, mode: .inline(maxBytes: -1)) 248 } 249 #expect(throws: RadrootsAppleFileError.self) { 250 _ = try access.read(file, mode: .inline(maxBytes: data.count - 1)) 251 } 252 253 let dataRoot = access.roots.dataRoot 254 let outside = dataRoot.deletingLastPathComponent().appendingPathComponent("outside", isDirectory: true) 255 try FileManager.default.createDirectory(at: outside, withIntermediateDirectories: true) 256 try data.write(to: outside.appendingPathComponent("source.txt")) 257 let link = dataRoot.appendingPathComponent("linked", isDirectory: true) 258 try FileManager.default.createSymbolicLink(at: link, withDestinationURL: outside) 259 #expect(throws: RadrootsAppleFileError.self) { 260 _ = try access.read( 261 RadrootsFileReference(scope: .data, relativePath: "linked/source.txt"), 262 mode: .inline(maxBytes: data.count) 263 ) 264 } 265 266 let fifo = dataRoot.appendingPathComponent("governed/source.fifo") 267 #expect(Darwin.mkfifo(fifo.path, 0o600) == 0) 268 #expect(throws: RadrootsAppleFileError.self) { 269 _ = try access.read( 270 RadrootsFileReference(scope: .data, relativePath: "governed/source.fifo"), 271 mode: .inline(maxBytes: data.count) 272 ) 273 } 274 } 275 276 @Test func appleFileAccessRejectsAReplacedStagedBlob() throws { 277 let access = try testFileAccess() 278 let data = Data("staged".utf8) 279 let staged = try access.stageBlob(data, mediaType: "text/plain", filenameHint: "staged.txt") 280 let stagedURL = access.roots.stagedBlobsRoot.appendingPathComponent(staged.blobID) 281 let replacement = stagedURL.appendingPathExtension("replacement") 282 try FileManager.default.moveItem(at: stagedURL, to: replacement) 283 try FileManager.default.createSymbolicLink(at: stagedURL, withDestinationURL: replacement) 284 285 #expect(throws: RadrootsAppleFileError.self) { 286 _ = try access.readStagedBlob(staged) 287 } 288 } 289 290 private func testFileAccess() throws -> RadrootsAppleFileAccess { 291 let unresolvedRoot = FileManager.default.temporaryDirectory 292 .appendingPathComponent("radroots-file-access-\(UUID().uuidString)", isDirectory: true) 293 try FileManager.default.createDirectory(at: unresolvedRoot, withIntermediateDirectories: true) 294 let root = try canonicalTestDirectory(unresolvedRoot) 295 let roots = try RadrootsAppleFileRoots( 296 appIdentifier: "org.radroots.tests", 297 dataRoot: root.appendingPathComponent("data", isDirectory: true), 298 cacheRoot: root.appendingPathComponent("cache", isDirectory: true), 299 temporaryRoot: root.appendingPathComponent("tmp", isDirectory: true) 300 ) 301 return RadrootsAppleFileAccess(roots: roots) 302 } 303 304 private func canonicalTestDirectory(_ directory: URL) throws -> URL { 305 let pointer = try #require(directory.path.withCString { Darwin.realpath($0, nil) }) 306 defer { Darwin.free(pointer) } 307 return URL(fileURLWithPath: String(cString: pointer), isDirectory: true) 308 } 309 310 private func writeExternalTestFile(name: String, data: Data) throws -> URL { 311 let directory = FileManager.default.temporaryDirectory 312 .appendingPathComponent("radroots-file-access-external-\(UUID().uuidString)", isDirectory: true) 313 try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) 314 let url = directory.appendingPathComponent(name) 315 try data.write(to: url) 316 return url.standardizedFileURL 317 }