apple_kit

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

RadrootsFileAccess.swift (26103B)


      1 import Foundation
      2 
      3 public enum RadrootsFileScope: Sendable, Equatable, CaseIterable, Codable {
      4     case data
      5     case cache
      6     case temporary
      7     case logs
      8 }
      9 
     10 public struct RadrootsFileReference: Sendable, Equatable, Hashable, Codable {
     11     public let scope: RadrootsFileScope
     12     public let relativePath: String
     13 
     14     public init(scope: RadrootsFileScope, relativePath: String) {
     15         self.scope = scope
     16         self.relativePath = relativePath
     17     }
     18 }
     19 
     20 public struct RadrootsFileEntry: Sendable, Equatable, Hashable {
     21     public let file: RadrootsFileReference
     22     public let name: String
     23     public let isDirectory: Bool
     24     public let sizeBytes: Int?
     25     public let modifiedAt: Date?
     26 
     27     public init(
     28         file: RadrootsFileReference,
     29         name: String,
     30         isDirectory: Bool,
     31         sizeBytes: Int?,
     32         modifiedAt: Date?
     33     ) {
     34         self.file = file
     35         self.name = name
     36         self.isDirectory = isDirectory
     37         self.sizeBytes = sizeBytes
     38         self.modifiedAt = modifiedAt
     39     }
     40 }
     41 
     42 public struct RadrootsStagedBlobReference: Sendable, Equatable, Hashable, Codable {
     43     public let blobID: String
     44     public let sizeBytes: Int
     45     public let mediaType: String?
     46     public let filenameHint: String?
     47 
     48     public init(
     49         blobID: String,
     50         sizeBytes: Int,
     51         mediaType: String? = nil,
     52         filenameHint: String? = nil
     53     ) throws {
     54         let normalizedBlobID = try Self.normalizedBlobID(blobID)
     55         guard sizeBytes >= 0 else {
     56             throw RadrootsAppleFileError.invalidRequest("staged blob size cannot be negative")
     57         }
     58         self.blobID = normalizedBlobID
     59         self.sizeBytes = sizeBytes
     60         self.mediaType = try Self.normalizedMediaType(mediaType)
     61         self.filenameHint = try Self.normalizedFilenameHint(filenameHint)
     62     }
     63 
     64     public static func normalizedBlobID(_ blobID: String) throws -> String {
     65         let trimmed = blobID.trimmingCharacters(in: .whitespacesAndNewlines)
     66         guard !trimmed.isEmpty else {
     67             throw RadrootsAppleFileError.invalidRequest("staged blob id cannot be empty")
     68         }
     69         let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_")
     70         guard trimmed.rangeOfCharacter(from: allowed.inverted) == nil else {
     71             throw RadrootsAppleFileError.invalidRequest("staged blob id contains invalid characters")
     72         }
     73         return trimmed
     74     }
     75 
     76     public static func normalizedMediaType(_ mediaType: String?) throws -> String? {
     77         guard let mediaType else {
     78             return nil
     79         }
     80         let trimmed = mediaType.trimmingCharacters(in: .whitespacesAndNewlines)
     81         guard !trimmed.isEmpty else {
     82             throw RadrootsAppleFileError.invalidRequest("staged blob media type cannot be empty")
     83         }
     84         guard trimmed.rangeOfCharacter(from: .newlines) == nil else {
     85             throw RadrootsAppleFileError.invalidRequest("staged blob media type cannot contain newlines")
     86         }
     87         return trimmed
     88     }
     89 
     90     public static func normalizedFilenameHint(_ filenameHint: String?) throws -> String? {
     91         guard let filenameHint else {
     92             return nil
     93         }
     94         let trimmed = filenameHint.trimmingCharacters(in: .whitespacesAndNewlines)
     95         guard !trimmed.isEmpty else {
     96             throw RadrootsAppleFileError.invalidRequest("staged blob filename hint cannot be empty")
     97         }
     98         guard !trimmed.contains("/") && !trimmed.contains("\\") && !trimmed.contains("\0") else {
     99             throw RadrootsAppleFileError.invalidRequest("staged blob filename hint cannot contain path separators")
    100         }
    101         return trimmed
    102     }
    103 }
    104 
    105 public enum RadrootsFilePayload: Sendable, Equatable {
    106     case inline(Data)
    107     case stagedBlob(RadrootsStagedBlobReference)
    108 }
    109 
    110 public enum RadrootsFileReadMode: Sendable, Equatable {
    111     case inline
    112     case preferInline(maxBytes: Int)
    113     case stagedBlob
    114 }
    115 
    116 public enum RadrootsFileReadResult: Sendable, Equatable {
    117     case inline(Data)
    118     case stagedBlob(RadrootsStagedBlobReference)
    119 }
    120 
    121 public protocol RadrootsFileAccess {
    122     func write(_ payload: RadrootsFilePayload, to file: RadrootsFileReference) throws
    123     func read(_ file: RadrootsFileReference, mode: RadrootsFileReadMode) throws -> RadrootsFileReadResult
    124     func delete(_ file: RadrootsFileReference) throws
    125     func list(_ directory: RadrootsFileReference) throws -> [RadrootsFileEntry]
    126     func reset(scope: RadrootsFileScope) throws
    127     @discardableResult func stageBlob(_ data: Data, mediaType: String?, filenameHint: String?) throws -> RadrootsStagedBlobReference
    128     @discardableResult func stageFile(_ file: RadrootsFileReference, mediaType: String?, filenameHint: String?) throws -> RadrootsStagedBlobReference
    129     @discardableResult func stageExternalFile(_ sourceURL: URL, mediaType: String?, filenameHint: String?) throws -> RadrootsStagedBlobReference
    130     @discardableResult func copyExternalFile(
    131         _ sourceURL: URL,
    132         to file: RadrootsFileReference,
    133         mediaType: String?,
    134         suggestedFilename: String?
    135     ) throws -> RadrootsImportedDocument
    136     @discardableResult func prepareExport(_ request: RadrootsExportDocumentRequest) throws -> RadrootsPreparedExportDocument
    137     func preparedExportExists(_ preparedExport: RadrootsPreparedExportDocument) throws -> Bool
    138     func readStagedBlob(_ blob: RadrootsStagedBlobReference) throws -> Data
    139     func releaseStagedBlob(_ blob: RadrootsStagedBlobReference) throws
    140     func releasePreparedExport(_ preparedExport: RadrootsPreparedExportDocument) throws
    141     @discardableResult func sweepStagedBlobs(olderThan cutoff: Date) throws -> [RadrootsStagedBlobReference]
    142     func resetStagedBlobs() throws
    143 }
    144 
    145 public final class RadrootsAppleFileAccess: RadrootsFileAccess {
    146     public let roots: RadrootsAppleFileRoots
    147     private let fileManager: FileManager
    148 
    149     public init(roots: RadrootsAppleFileRoots, fileManager: FileManager = .default) {
    150         self.roots = roots
    151         self.fileManager = fileManager
    152     }
    153 
    154     public func write(_ payload: RadrootsFilePayload, to file: RadrootsFileReference) throws {
    155         let url = try roots.resolvedURL(for: file)
    156         try createParentDirectory(for: url)
    157         switch payload {
    158         case .inline(let inlineData):
    159             try inlineData.write(to: url, options: [.atomic])
    160         case .stagedBlob(let stagedBlob):
    161             try copyReplacingItem(from: try stagedBlobURL(for: stagedBlob), to: url)
    162         }
    163     }
    164 
    165     public func read(_ file: RadrootsFileReference, mode: RadrootsFileReadMode) throws -> RadrootsFileReadResult {
    166         let url = try roots.resolvedURL(for: file)
    167         guard fileManager.fileExists(atPath: url.path) else {
    168             throw RadrootsAppleFileError.notFound("file not found")
    169         }
    170         switch mode {
    171         case .inline:
    172             return .inline(try Data(contentsOf: url))
    173         case .preferInline(let maxBytes):
    174             guard maxBytes >= 0 else {
    175                 throw RadrootsAppleFileError.invalidRequest("inline byte limit cannot be negative")
    176             }
    177             let size = try fileSize(at: url)
    178             if size <= maxBytes {
    179                 return .inline(try Data(contentsOf: url))
    180             }
    181             let staged = try stageFile(file, mediaType: nil, filenameHint: url.lastPathComponent)
    182             return .stagedBlob(staged)
    183         case .stagedBlob:
    184             let staged = try stageFile(file, mediaType: nil, filenameHint: url.lastPathComponent)
    185             return .stagedBlob(staged)
    186         }
    187     }
    188 
    189     public func delete(_ file: RadrootsFileReference) throws {
    190         let url = try roots.resolvedURL(for: file)
    191         guard fileManager.fileExists(atPath: url.path) else {
    192             return
    193         }
    194         try fileManager.removeItem(at: url)
    195     }
    196 
    197     public func list(_ directory: RadrootsFileReference) throws -> [RadrootsFileEntry] {
    198         let rootURL = roots.root(for: directory.scope).standardizedFileURL
    199         let directoryURL = try roots.resolvedURL(for: directory, allowRootDirectory: true)
    200         var isDirectory = ObjCBool(false)
    201         guard fileManager.fileExists(atPath: directoryURL.path, isDirectory: &isDirectory) else {
    202             return []
    203         }
    204         guard isDirectory.boolValue else {
    205             throw RadrootsAppleFileError.invalidRequest("file list target must be a directory")
    206         }
    207         let urls = try fileManager.contentsOfDirectory(
    208             at: directoryURL,
    209             includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey],
    210             options: []
    211         )
    212         return try urls.map { url in
    213             let values = try url.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey])
    214             let relativePath = try relativePath(for: url.standardizedFileURL, under: rootURL)
    215             return RadrootsFileEntry(
    216                 file: RadrootsFileReference(scope: directory.scope, relativePath: relativePath),
    217                 name: url.lastPathComponent,
    218                 isDirectory: values.isDirectory ?? false,
    219                 sizeBytes: values.fileSize,
    220                 modifiedAt: values.contentModificationDate
    221             )
    222         }
    223         .sorted { left, right in
    224             left.file.relativePath < right.file.relativePath
    225         }
    226     }
    227 
    228     public func reset(scope: RadrootsFileScope) throws {
    229         let url = roots.root(for: scope)
    230         if fileManager.fileExists(atPath: url.path) {
    231             try fileManager.removeItem(at: url)
    232         }
    233         try fileManager.createDirectory(at: url, withIntermediateDirectories: true)
    234     }
    235 
    236     @discardableResult
    237     public func stageBlob(
    238         _ data: Data,
    239         mediaType: String? = nil,
    240         filenameHint: String? = nil
    241     ) throws -> RadrootsStagedBlobReference {
    242         let blobID = UUID().uuidString.lowercased()
    243         let blob = try RadrootsStagedBlobReference(
    244             blobID: blobID,
    245             sizeBytes: data.count,
    246             mediaType: mediaType,
    247             filenameHint: filenameHint
    248         )
    249         let url = try stagedBlobURL(for: blob)
    250         try fileManager.createDirectory(at: roots.stagedBlobsRoot, withIntermediateDirectories: true)
    251         try data.write(to: url, options: [.atomic])
    252         return blob
    253     }
    254 
    255     @discardableResult
    256     public func stageFile(
    257         _ file: RadrootsFileReference,
    258         mediaType: String? = nil,
    259         filenameHint: String? = nil
    260     ) throws -> RadrootsStagedBlobReference {
    261         let sourceURL = try roots.resolvedURL(for: file)
    262         guard fileManager.fileExists(atPath: sourceURL.path) else {
    263             throw RadrootsAppleFileError.notFound("file not found")
    264         }
    265         return try stageFileURL(
    266             sourceURL,
    267             mediaType: mediaType,
    268             filenameHint: filenameHint ?? sourceURL.lastPathComponent
    269         )
    270     }
    271 
    272     @discardableResult
    273     public func stageExternalFile(
    274         _ sourceURL: URL,
    275         mediaType: String? = nil,
    276         filenameHint: String? = nil
    277     ) throws -> RadrootsStagedBlobReference {
    278         try withSecurityScopedFile(sourceURL) { scopedURL in
    279             try stageFileURL(
    280                 scopedURL,
    281                 mediaType: mediaType,
    282                 filenameHint: filenameHint ?? scopedURL.lastPathComponent
    283             )
    284         }
    285     }
    286 
    287     @discardableResult
    288     public func copyExternalFile(
    289         _ sourceURL: URL,
    290         to file: RadrootsFileReference,
    291         mediaType: String? = nil,
    292         suggestedFilename: String? = nil
    293     ) throws -> RadrootsImportedDocument {
    294         try withSecurityScopedFile(sourceURL) { scopedURL in
    295             let destinationURL = try roots.resolvedURL(for: file)
    296             try createParentDirectory(for: destinationURL)
    297             try copyReplacingItem(from: scopedURL, to: destinationURL)
    298             let sizeBytes = try fileSizeUInt64(at: destinationURL)
    299             return try RadrootsImportedDocument(
    300                 file: file,
    301                 originalURL: scopedURL,
    302                 suggestedFilename: suggestedFilename ?? scopedURL.lastPathComponent,
    303                 mediaType: mediaType,
    304                 sizeBytes: sizeBytes
    305             )
    306         }
    307     }
    308 
    309     @discardableResult
    310     public func prepareExport(_ request: RadrootsExportDocumentRequest) throws -> RadrootsPreparedExportDocument {
    311         let preparedID = UUID().uuidString.lowercased()
    312         let directoryURL = preparedExportsRoot.appendingPathComponent(preparedID, isDirectory: true)
    313         let fileURL = directoryURL.appendingPathComponent(request.suggestedFilename).standardizedFileURL
    314         try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
    315         switch request.source {
    316         case .inlineData(let data):
    317             try data.write(to: fileURL, options: [.atomic])
    318         case .file(let file):
    319             try copyReplacingItem(from: try roots.resolvedURL(for: file), to: fileURL)
    320         case .stagedBlob(let stagedBlob):
    321             try copyReplacingItem(from: try stagedBlobURL(for: stagedBlob), to: fileURL)
    322         }
    323         let sizeBytes: UInt64
    324         if let requestSizeBytes = request.sizeBytes {
    325             sizeBytes = requestSizeBytes
    326         } else {
    327             sizeBytes = try fileSizeUInt64(at: fileURL)
    328         }
    329         return try RadrootsPreparedExportDocument(
    330             preparedID: preparedID,
    331             fileURL: fileURL,
    332             suggestedFilename: request.suggestedFilename,
    333             mediaType: request.mediaType,
    334             sizeBytes: sizeBytes
    335         )
    336     }
    337 
    338     public func readStagedBlob(_ blob: RadrootsStagedBlobReference) throws -> Data {
    339         let url = try stagedBlobURL(for: blob)
    340         guard fileManager.fileExists(atPath: url.path) else {
    341             throw RadrootsAppleFileError.notFound("staged blob not found")
    342         }
    343         let data = try Data(contentsOf: url)
    344         guard data.count == blob.sizeBytes else {
    345             throw RadrootsAppleFileError.permanentFailure("staged blob size does not match reference")
    346         }
    347         return data
    348     }
    349 
    350     public func releaseStagedBlob(_ blob: RadrootsStagedBlobReference) throws {
    351         let url = try stagedBlobURL(for: blob)
    352         if fileManager.fileExists(atPath: url.path) {
    353             try fileManager.removeItem(at: url)
    354         }
    355     }
    356 
    357     public func preparedExportExists(_ preparedExport: RadrootsPreparedExportDocument) throws -> Bool {
    358         let directoryURL = try preparedExportDirectoryURL(for: preparedExport)
    359         return fileManager.fileExists(atPath: directoryURL.path) &&
    360             fileManager.fileExists(atPath: preparedExport.fileURL.path)
    361     }
    362 
    363     public func releasePreparedExport(_ preparedExport: RadrootsPreparedExportDocument) throws {
    364         let directoryURL = try preparedExportDirectoryURL(for: preparedExport)
    365         if fileManager.fileExists(atPath: directoryURL.path) {
    366             try fileManager.removeItem(at: directoryURL)
    367         }
    368     }
    369 
    370     @discardableResult
    371     public func sweepStagedBlobs(olderThan cutoff: Date) throws -> [RadrootsStagedBlobReference] {
    372         guard fileManager.fileExists(atPath: roots.stagedBlobsRoot.path) else {
    373             return []
    374         }
    375         let urls = try fileManager.contentsOfDirectory(
    376             at: roots.stagedBlobsRoot,
    377             includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey],
    378             options: []
    379         )
    380         var released: [RadrootsStagedBlobReference] = []
    381         for url in urls {
    382             let values = try url.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey])
    383             guard values.isDirectory != true else {
    384                 continue
    385             }
    386             guard let modifiedAt = values.contentModificationDate, modifiedAt < cutoff else {
    387                 continue
    388             }
    389             let blob = try RadrootsStagedBlobReference(
    390                 blobID: url.lastPathComponent,
    391                 sizeBytes: values.fileSize ?? 0
    392             )
    393             try fileManager.removeItem(at: url)
    394             released.append(blob)
    395         }
    396         return released.sorted { left, right in
    397             left.blobID < right.blobID
    398         }
    399     }
    400 
    401     public func resetStagedBlobs() throws {
    402         if fileManager.fileExists(atPath: roots.stagedBlobsRoot.path) {
    403             try fileManager.removeItem(at: roots.stagedBlobsRoot)
    404         }
    405         try fileManager.createDirectory(at: roots.stagedBlobsRoot, withIntermediateDirectories: true)
    406     }
    407 
    408     public func resetFileRoots() throws {
    409         for scope in RadrootsFileScope.allCases {
    410             try reset(scope: scope)
    411         }
    412         try resetStagedBlobs()
    413     }
    414 
    415     private func stagedBlobURL(for blob: RadrootsStagedBlobReference) throws -> URL {
    416         try roots.stagedBlobURL(for: blob)
    417     }
    418 
    419     private var preparedExportsRoot: URL {
    420         roots.temporaryRoot.appendingPathComponent("prepared_exports", isDirectory: true).standardizedFileURL
    421     }
    422 
    423     private func preparedExportDirectoryURL(for preparedExport: RadrootsPreparedExportDocument) throws -> URL {
    424         let normalizedPreparedID = try RadrootsPreparedExportDocument.normalizedPreparedID(preparedExport.preparedID)
    425         let directoryURL = preparedExportsRoot.appendingPathComponent(normalizedPreparedID, isDirectory: true).standardizedFileURL
    426         guard preparedExport.fileURL.standardizedFileURL.path.hasPrefix(directoryURL.path + "/") else {
    427             throw RadrootsAppleFileError.invalidRequest("prepared export file escaped its directory")
    428         }
    429         return directoryURL
    430     }
    431 
    432     private func stageFileURL(
    433         _ sourceURL: URL,
    434         mediaType: String?,
    435         filenameHint: String?
    436     ) throws -> RadrootsStagedBlobReference {
    437         let sizeBytes = try fileSizeInt(at: sourceURL)
    438         let blobID = UUID().uuidString.lowercased()
    439         let blob = try RadrootsStagedBlobReference(
    440             blobID: blobID,
    441             sizeBytes: sizeBytes,
    442             mediaType: mediaType,
    443             filenameHint: filenameHint
    444         )
    445         let destinationURL = try stagedBlobURL(for: blob)
    446         try fileManager.createDirectory(at: roots.stagedBlobsRoot, withIntermediateDirectories: true)
    447         try copyReplacingItem(from: sourceURL, to: destinationURL)
    448         return blob
    449     }
    450 
    451     private func withSecurityScopedFile<T>(_ sourceURL: URL, _ body: (URL) throws -> T) throws -> T {
    452         guard sourceURL.isFileURL else {
    453             throw RadrootsAppleFileError.invalidRequest("external file url must be a file url")
    454         }
    455         let scopedURL = sourceURL.standardizedFileURL
    456         var isDirectory = ObjCBool(false)
    457         guard fileManager.fileExists(atPath: scopedURL.path, isDirectory: &isDirectory) else {
    458             throw RadrootsAppleFileError.notFound("external file not found")
    459         }
    460         guard !isDirectory.boolValue else {
    461             throw RadrootsAppleFileError.invalidRequest("external file url must reference a file")
    462         }
    463         let didStartScope = scopedURL.startAccessingSecurityScopedResource()
    464         defer {
    465             if didStartScope {
    466                 scopedURL.stopAccessingSecurityScopedResource()
    467             }
    468         }
    469         return try body(scopedURL)
    470     }
    471 
    472     private func copyReplacingItem(from sourceURL: URL, to destinationURL: URL) throws {
    473         guard sourceURL.isFileURL, destinationURL.isFileURL else {
    474             throw RadrootsAppleFileError.invalidRequest("copy source and destination must be file urls")
    475         }
    476         try createParentDirectory(for: destinationURL)
    477         if fileManager.fileExists(atPath: destinationURL.path) {
    478             try fileManager.removeItem(at: destinationURL)
    479         }
    480         try fileManager.copyItem(at: sourceURL, to: destinationURL)
    481     }
    482 
    483     private func createParentDirectory(for url: URL) throws {
    484         try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
    485     }
    486 
    487     private func fileSize(at url: URL) throws -> Int {
    488         try fileSizeInt(at: url)
    489     }
    490 
    491     private func fileSizeInt(at url: URL) throws -> Int {
    492         let values = try url.resourceValues(forKeys: [.fileSizeKey])
    493         guard let size = values.fileSize else {
    494             throw RadrootsAppleFileError.permanentFailure("file size is unavailable")
    495         }
    496         return size
    497     }
    498 
    499     private func fileSizeUInt64(at url: URL) throws -> UInt64 {
    500         UInt64(try fileSizeInt(at: url))
    501     }
    502 
    503     private func relativePath(for url: URL, under rootURL: URL) throws -> String {
    504         let rootPath = rootURL.standardizedFileURL.path
    505         let filePath = url.standardizedFileURL.path
    506         guard filePath.hasPrefix(rootPath + "/") else {
    507             throw RadrootsAppleFileError.invalidRequest("file list entry escaped its scope")
    508         }
    509         return String(filePath.dropFirst(rootPath.count + 1))
    510     }
    511 }
    512 
    513 public struct RadrootsAppleFileRoots: Sendable, Equatable {
    514     public let appIdentifier: String
    515     public let dataRoot: URL
    516     public let cacheRoot: URL
    517     public let temporaryRoot: URL
    518     public let logsRoot: URL
    519     public let stagedBlobsRoot: URL
    520 
    521     public init(
    522         appIdentifier: String,
    523         dataRoot: URL,
    524         cacheRoot: URL,
    525         temporaryRoot: URL,
    526         logsRoot: URL? = nil,
    527         stagedBlobsRoot: URL? = nil
    528     ) throws {
    529         let normalizedAppIdentifier = try Self.normalizedAppIdentifier(appIdentifier)
    530         let normalizedDataRoot = try Self.normalizedRootURL(dataRoot, field: "dataRoot")
    531         let normalizedCacheRoot = try Self.normalizedRootURL(cacheRoot, field: "cacheRoot")
    532         let normalizedTemporaryRoot = try Self.normalizedRootURL(temporaryRoot, field: "temporaryRoot")
    533         self.appIdentifier = normalizedAppIdentifier
    534         self.dataRoot = normalizedDataRoot
    535         self.cacheRoot = normalizedCacheRoot
    536         self.temporaryRoot = normalizedTemporaryRoot
    537         self.logsRoot = try Self.normalizedRootURL(
    538             logsRoot ?? normalizedCacheRoot.appendingPathComponent("Logs", isDirectory: true),
    539             field: "logsRoot"
    540         )
    541         self.stagedBlobsRoot = try Self.normalizedRootURL(
    542             stagedBlobsRoot ?? normalizedTemporaryRoot.appendingPathComponent("staged_blobs", isDirectory: true),
    543             field: "stagedBlobsRoot"
    544         )
    545     }
    546 
    547     public static func appContainer(
    548         appIdentifier: String,
    549         fileManager: FileManager = .default
    550     ) throws -> Self {
    551         let normalizedAppIdentifier = try normalizedAppIdentifier(appIdentifier)
    552         let dataBaseURL = try fileManager.url(
    553             for: .applicationSupportDirectory,
    554             in: .userDomainMask,
    555             appropriateFor: nil,
    556             create: true
    557         )
    558         let cacheBaseURL = try fileManager.url(
    559             for: .cachesDirectory,
    560             in: .userDomainMask,
    561             appropriateFor: nil,
    562             create: true
    563         )
    564         let dataRoot = dataBaseURL.appendingPathComponent(normalizedAppIdentifier, isDirectory: true)
    565         let cacheRoot = cacheBaseURL.appendingPathComponent(normalizedAppIdentifier, isDirectory: true)
    566         let temporaryRoot = fileManager.temporaryDirectory
    567             .appendingPathComponent(normalizedAppIdentifier, isDirectory: true)
    568         return try Self(
    569             appIdentifier: normalizedAppIdentifier,
    570             dataRoot: dataRoot,
    571             cacheRoot: cacheRoot,
    572             temporaryRoot: temporaryRoot
    573         )
    574     }
    575 
    576     public func root(for scope: RadrootsFileScope) -> URL {
    577         switch scope {
    578         case .data:
    579             dataRoot
    580         case .cache:
    581             cacheRoot
    582         case .temporary:
    583             temporaryRoot
    584         case .logs:
    585             logsRoot
    586         }
    587     }
    588 
    589     public func resolvedURL(
    590         for file: RadrootsFileReference,
    591         allowRootDirectory: Bool = false
    592     ) throws -> URL {
    593         let rootURL = root(for: file.scope).standardizedFileURL
    594         let trimmedPath = file.relativePath.trimmingCharacters(in: .whitespacesAndNewlines)
    595         if trimmedPath.isEmpty {
    596             if allowRootDirectory {
    597                 return rootURL
    598             }
    599             throw RadrootsAppleFileError.invalidRequest("file relative path cannot be empty")
    600         }
    601         if NSString(string: trimmedPath).isAbsolutePath {
    602             throw RadrootsAppleFileError.invalidRequest("file relative path must not be absolute")
    603         }
    604 
    605         let candidateURL = rootURL.appendingPathComponent(trimmedPath).standardizedFileURL
    606         if candidateURL.path == rootURL.path {
    607             if allowRootDirectory {
    608                 return candidateURL
    609             }
    610             throw RadrootsAppleFileError.invalidRequest("file relative path cannot resolve to its root")
    611         }
    612         guard candidateURL.path.hasPrefix(rootURL.path + "/") else {
    613             throw RadrootsAppleFileError.invalidRequest("file relative path must not escape its scope")
    614         }
    615         return candidateURL
    616     }
    617 
    618     public func stagedBlobURL(for blob: RadrootsStagedBlobReference) throws -> URL {
    619         let normalizedBlobID = try RadrootsStagedBlobReference.normalizedBlobID(blob.blobID)
    620         return stagedBlobsRoot.appendingPathComponent(normalizedBlobID).standardizedFileURL
    621     }
    622 
    623     public static func normalizedAppIdentifier(_ appIdentifier: String) throws -> String {
    624         let trimmed = appIdentifier.trimmingCharacters(in: .whitespacesAndNewlines)
    625         guard !trimmed.isEmpty else {
    626             throw RadrootsAppleFileError.invalidRequest("app identifier cannot be empty")
    627         }
    628         return trimmed
    629     }
    630 
    631     public static func normalizedRootURL(_ rootURL: URL, field: String) throws -> URL {
    632         guard rootURL.isFileURL else {
    633             throw RadrootsAppleFileError.invalidRequest("\(field) must be a file URL")
    634         }
    635         let standardized = rootURL.standardizedFileURL
    636         guard standardized.path.hasPrefix("/") else {
    637             throw RadrootsAppleFileError.invalidRequest("\(field) must be absolute")
    638         }
    639         return standardized
    640     }
    641 }