apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README | LICENSE

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 }