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

RadrootsAppleFileRoots.swift (7303B)


      1 import Darwin
      2 import Foundation
      3 
      4 public struct RadrootsAppleFileRoots: Sendable, Equatable {
      5     public let appIdentifier: String
      6     public let dataRoot: URL
      7     public let cacheRoot: URL
      8     public let temporaryRoot: URL
      9     public let logsRoot: URL
     10     public let stagedBlobsRoot: URL
     11 
     12     public init(
     13         appIdentifier: String,
     14         dataRoot: URL,
     15         cacheRoot: URL,
     16         temporaryRoot: URL,
     17         logsRoot: URL? = nil,
     18         stagedBlobsRoot: URL? = nil
     19     ) throws {
     20         let normalizedAppIdentifier = try Self.normalizedAppIdentifier(appIdentifier)
     21         let normalizedDataRoot = try Self.normalizedRootURL(dataRoot, field: "dataRoot")
     22         let normalizedCacheRoot = try Self.normalizedRootURL(cacheRoot, field: "cacheRoot")
     23         let normalizedTemporaryRoot = try Self.normalizedRootURL(temporaryRoot, field: "temporaryRoot")
     24         self.appIdentifier = normalizedAppIdentifier
     25         self.dataRoot = normalizedDataRoot
     26         self.cacheRoot = normalizedCacheRoot
     27         self.temporaryRoot = normalizedTemporaryRoot
     28         self.logsRoot = try Self.normalizedRootURL(
     29             logsRoot ?? normalizedCacheRoot.appendingPathComponent("Logs", isDirectory: true),
     30             field: "logsRoot"
     31         )
     32         self.stagedBlobsRoot = try Self.normalizedRootURL(
     33             stagedBlobsRoot
     34                 ?? normalizedTemporaryRoot.appendingPathComponent("staged_blobs", isDirectory: true),
     35             field: "stagedBlobsRoot"
     36         )
     37     }
     38 
     39     public static func appContainer(
     40         appIdentifier: String,
     41         fileManager: FileManager = .default
     42     ) throws -> Self {
     43         do {
     44             let normalizedAppIdentifier = try normalizedAppIdentifier(appIdentifier)
     45             let dataBaseURL = try canonicalExistingDirectory(
     46                 fileManager.url(
     47                     for: .applicationSupportDirectory,
     48                     in: .userDomainMask,
     49                     appropriateFor: nil,
     50                     create: true
     51                 )
     52             )
     53             let cacheBaseURL = try canonicalExistingDirectory(
     54                 fileManager.url(
     55                     for: .cachesDirectory,
     56                     in: .userDomainMask,
     57                     appropriateFor: nil,
     58                     create: true
     59                 )
     60             )
     61             let dataRoot = dataBaseURL.appendingPathComponent(normalizedAppIdentifier, isDirectory: true)
     62             let cacheRoot = cacheBaseURL.appendingPathComponent(
     63                 normalizedAppIdentifier, isDirectory: true
     64             )
     65             let temporaryRoot = try canonicalExistingDirectory(fileManager.temporaryDirectory)
     66                 .appendingPathComponent(normalizedAppIdentifier, isDirectory: true)
     67             return try Self(
     68                 appIdentifier: normalizedAppIdentifier,
     69                 dataRoot: dataRoot,
     70                 cacheRoot: cacheRoot,
     71                 temporaryRoot: temporaryRoot
     72             )
     73         } catch let error as RadrootsAppleFileError {
     74             throw error
     75         } catch {
     76             throw RadrootsAppleFileError.permanentFailure
     77         }
     78     }
     79 
     80     public func root(for scope: RadrootsFileScope) -> URL {
     81         switch scope {
     82         case .data:
     83             dataRoot
     84         case .cache:
     85             cacheRoot
     86         case .temporary:
     87             temporaryRoot
     88         case .logs:
     89             logsRoot
     90         }
     91     }
     92 
     93     public func resolvedURL(
     94         for file: RadrootsFileReference,
     95         allowRootDirectory: Bool = false
     96     ) throws -> URL {
     97         let rootURL = root(for: file.scope)
     98         let trimmedPath = file.relativePath.trimmingCharacters(in: .whitespacesAndNewlines)
     99         if trimmedPath.isEmpty {
    100             if allowRootDirectory {
    101                 return rootURL
    102             }
    103             throw RadrootsAppleFileError.invalidRequest
    104         }
    105         if NSString(string: trimmedPath).isAbsolutePath {
    106             throw RadrootsAppleFileError.invalidRequest
    107         }
    108 
    109         let components = try Self.normalizedRelativeComponents(trimmedPath)
    110         let candidateURL = components.isEmpty
    111             ? rootURL : rootURL.appendingPathComponent(components.joined(separator: "/"))
    112         if candidateURL.path == rootURL.path {
    113             if allowRootDirectory {
    114                 return candidateURL
    115             }
    116             throw RadrootsAppleFileError.invalidRequest
    117         }
    118         guard candidateURL.path.hasPrefix(rootURL.path + "/") else {
    119             throw RadrootsAppleFileError.invalidRequest
    120         }
    121         return candidateURL
    122     }
    123 
    124     private static func normalizedRelativeComponents(_ trimmedPath: String) throws -> [String] {
    125         var components: [String] = []
    126         for component in trimmedPath.split(separator: "/", omittingEmptySubsequences: true) {
    127             if component == "." {
    128                 continue
    129             }
    130             if component == ".." {
    131                 guard !components.isEmpty else { throw RadrootsAppleFileError.invalidRequest }
    132                 components.removeLast()
    133             } else {
    134                 guard !component.utf8.contains(0) else { throw RadrootsAppleFileError.invalidRequest }
    135                 components.append(String(component))
    136             }
    137         }
    138         return components
    139     }
    140 
    141     public func stagedBlobURL(for blob: RadrootsStagedBlobReference) throws -> URL {
    142         let normalizedBlobID = try RadrootsStagedBlobReference.normalizedBlobID(blob.blobID)
    143         // Foundation standardization can rewrite an existing /private/var path
    144         // back to the /var symlink alias. Keep the already-admitted root bytes.
    145         return stagedBlobsRoot.appendingPathComponent(normalizedBlobID, isDirectory: false)
    146     }
    147 
    148     public static func normalizedAppIdentifier(_ appIdentifier: String) throws -> String {
    149         let trimmed = appIdentifier.trimmingCharacters(in: .whitespacesAndNewlines)
    150         guard !trimmed.isEmpty else {
    151             throw RadrootsAppleFileError.invalidRequest
    152         }
    153         return trimmed
    154     }
    155 
    156     public static func normalizedRootURL(_ rootURL: URL, field _: String) throws -> URL {
    157         guard rootURL.isFileURL else {
    158             throw RadrootsAppleFileError.invalidRequest
    159         }
    160         guard rootURL.path.hasPrefix("/"), !rootURL.path.utf8.contains(0) else {
    161             throw RadrootsAppleFileError.invalidRequest
    162         }
    163         var components: [String] = []
    164         for component in rootURL.path.split(separator: "/", omittingEmptySubsequences: true) {
    165             if component == "." {
    166                 continue
    167             }
    168             if component == ".." {
    169                 if !components.isEmpty {
    170                     components.removeLast()
    171                 }
    172             } else {
    173                 components.append(String(component))
    174             }
    175         }
    176         return URL(fileURLWithPath: "/" + components.joined(separator: "/"), isDirectory: true)
    177     }
    178 
    179     private static func canonicalExistingDirectory(_ directory: URL) throws -> URL {
    180         guard directory.isFileURL,
    181               let pointer = directory.path.withCString({ Darwin.realpath($0, nil) })
    182         else {
    183             throw RadrootsAppleFileError.permanentFailure
    184         }
    185         defer { Darwin.free(pointer) }
    186         return URL(fileURLWithPath: String(cString: pointer), isDirectory: true)
    187     }
    188 }