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

RadrootsAppleMobileStore.swift (5402B)


      1 import Foundation
      2 
      3 #if canImport(UIKit)
      4     import UIKit
      5 #endif
      6 
      7 public enum RadrootsAppleProtectedDataAvailability: Sendable, Equatable {
      8     case available
      9     case unavailable
     10 
     11     @MainActor
     12     public static var current: Self {
     13         #if canImport(UIKit)
     14             UIApplication.shared.isProtectedDataAvailable ? .available : .unavailable
     15         #else
     16             .available
     17         #endif
     18     }
     19 }
     20 
     21 public struct RadrootsAppleMobileStoreConfiguration: Sendable, Equatable {
     22     public let applicationSupportDirectory: URL
     23     public let ownerDirectory: URL
     24     public let protectedDataAvailability: RadrootsAppleProtectedDataAvailability
     25 }
     26 
     27 public enum RadrootsAppleMobileStoreError: Error, Sendable, Equatable {
     28     case invalidPublicKey
     29     case protectedDataUnavailable
     30     case invalidDirectoryLayout
     31     case fileSystemFailure
     32 }
     33 
     34 extension RadrootsAppleMobileStoreError: LocalizedError {
     35     public var errorDescription: String? {
     36         switch self {
     37         case .invalidPublicKey: "The public key is invalid."
     38         case .protectedDataUnavailable: "Protected data is unavailable."
     39         case .invalidDirectoryLayout: "The mobile-store directory layout is invalid."
     40         case .fileSystemFailure: "The mobile store could not be prepared."
     41         }
     42     }
     43 }
     44 
     45 public enum RadrootsAppleMobileStore {
     46     private static let productDirectory = "radroots"
     47     private static let userDirectory = "users"
     48 
     49     /// Prepares the Apple-owned directory consumed by the Rust mobile runtime.
     50     ///
     51     /// The returned Application Support directory is passed to Rust, which
     52     /// independently derives and validates the same identity-scoped suffix.
     53     public static func prepare(
     54         roots: RadrootsAppleFileRoots,
     55         publicKeyHex: String,
     56         protectedDataAvailability: RadrootsAppleProtectedDataAvailability,
     57         fileManager: FileManager = .default
     58     ) throws -> RadrootsAppleMobileStoreConfiguration {
     59         guard protectedDataAvailability == .available else {
     60             throw RadrootsAppleMobileStoreError.protectedDataUnavailable
     61         }
     62         guard isCanonicalPublicKey(publicKeyHex) else {
     63             throw RadrootsAppleMobileStoreError.invalidPublicKey
     64         }
     65 
     66         let applicationSupportDirectory = roots.dataRoot
     67         let productRoot =
     68             applicationSupportDirectory
     69                 .appendingPathComponent(productDirectory, isDirectory: true)
     70         let userRoot =
     71             productRoot
     72                 .appendingPathComponent(userDirectory, isDirectory: true)
     73         let ownerRoot =
     74             userRoot
     75                 .appendingPathComponent(publicKeyHex, isDirectory: true)
     76 
     77         do {
     78             // swiftlint:disable trailing_comma
     79             for directory in [
     80                 applicationSupportDirectory,
     81                 productRoot,
     82                 userRoot,
     83                 ownerRoot,
     84             ] {
     85                 try createOrValidateDirectory(directory, fileManager: fileManager)
     86             }
     87             // swiftlint:enable trailing_comma
     88             try excludeFromBackup(ownerRoot)
     89             try applyFileProtection(ownerRoot, fileManager: fileManager)
     90         } catch let error as RadrootsAppleMobileStoreError {
     91             throw error
     92         } catch {
     93             throw RadrootsAppleMobileStoreError.fileSystemFailure
     94         }
     95 
     96         return RadrootsAppleMobileStoreConfiguration(
     97             applicationSupportDirectory: applicationSupportDirectory,
     98             ownerDirectory: ownerRoot,
     99             protectedDataAvailability: protectedDataAvailability
    100         )
    101     }
    102 
    103     private static func isCanonicalPublicKey(_ value: String) -> Bool {
    104         value.utf8.count == 64
    105             && value.utf8.allSatisfy {
    106                 ($0 >= 48 && $0 <= 57) || ($0 >= 97 && $0 <= 102)
    107             }
    108     }
    109 
    110     private static func createOrValidateDirectory(
    111         _ directory: URL,
    112         fileManager: FileManager
    113     ) throws {
    114         var isDirectory: ObjCBool = false
    115         if fileManager.fileExists(atPath: directory.path, isDirectory: &isDirectory) {
    116             let values = try directory.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey])
    117             guard isDirectory.boolValue, values.isDirectory == true, values.isSymbolicLink != true else {
    118                 throw RadrootsAppleMobileStoreError.invalidDirectoryLayout
    119             }
    120             return
    121         }
    122         try fileManager.createDirectory(
    123             at: directory,
    124             withIntermediateDirectories: false,
    125             attributes: nil
    126         )
    127         let values = try directory.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey])
    128         guard values.isDirectory == true, values.isSymbolicLink != true else {
    129             throw RadrootsAppleMobileStoreError.invalidDirectoryLayout
    130         }
    131     }
    132 
    133     private static func excludeFromBackup(_ directory: URL) throws {
    134         var directory = directory
    135         var values = URLResourceValues()
    136         values.isExcludedFromBackup = true
    137         try directory.setResourceValues(values)
    138     }
    139 
    140     private static func applyFileProtection(
    141         _ directory: URL,
    142         fileManager: FileManager
    143     ) throws {
    144         #if os(iOS)
    145             try fileManager.setAttributes(
    146                 [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
    147                 ofItemAtPath: directory.path
    148             )
    149         #endif
    150     }
    151 }