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

commit 5cf21747f61a0a70d2da6ed994fb5246b59b8841
parent fdcf377466e1c25945ef561042ebc250eb68cd3a
Author: triesap <tyson@radroots.org>
Date:   Fri,  7 Aug 2026 10:23:02 +0000

Add protected mobile store host contract

- expose current protected-data availability for Apple hosts
- prepare the exact authenticated Application Support directory tree
- reject unsafe filesystem layouts and apply persistence attributes
- cover protected state, path integrity, and backup exclusion

Diffstat:
ASources/RadrootsKit/RadrootsAppleMobileStore.swift | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsAppleMobileStoreTests.swift | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 274 insertions(+), 0 deletions(-)

diff --git a/Sources/RadrootsKit/RadrootsAppleMobileStore.swift b/Sources/RadrootsKit/RadrootsAppleMobileStore.swift @@ -0,0 +1,140 @@ +import Foundation + +#if canImport(UIKit) + import UIKit +#endif + +public enum RadrootsAppleProtectedDataAvailability: Sendable, Equatable { + case available + case unavailable + + @MainActor + public static var current: Self { + #if canImport(UIKit) + UIApplication.shared.isProtectedDataAvailable ? .available : .unavailable + #else + .available + #endif + } +} + +public struct RadrootsAppleMobileStoreConfiguration: Sendable, Equatable { + public let applicationSupportDirectory: URL + public let ownerDirectory: URL + public let protectedDataAvailability: RadrootsAppleProtectedDataAvailability +} + +public enum RadrootsAppleMobileStoreError: Error, Sendable, Equatable { + case invalidPublicKey + case protectedDataUnavailable + case invalidDirectoryLayout + case fileSystemFailure +} + +public enum RadrootsAppleMobileStore { + private static let productDirectory = "radroots" + private static let userDirectory = "users" + + /// Prepares the Apple-owned directory consumed by the Rust mobile runtime. + /// + /// The returned Application Support directory is passed to Rust, which + /// independently derives and validates the same identity-scoped suffix. + public static func prepare( + roots: RadrootsAppleFileRoots, + publicKeyHex: String, + protectedDataAvailability: RadrootsAppleProtectedDataAvailability, + fileManager: FileManager = .default + ) throws -> RadrootsAppleMobileStoreConfiguration { + guard protectedDataAvailability == .available else { + throw RadrootsAppleMobileStoreError.protectedDataUnavailable + } + guard isCanonicalPublicKey(publicKeyHex) else { + throw RadrootsAppleMobileStoreError.invalidPublicKey + } + + let applicationSupportDirectory = roots.dataRoot.standardizedFileURL + let productRoot = + applicationSupportDirectory + .appendingPathComponent(productDirectory, isDirectory: true) + let userRoot = + productRoot + .appendingPathComponent(userDirectory, isDirectory: true) + let ownerRoot = + userRoot + .appendingPathComponent(publicKeyHex, isDirectory: true) + + do { + // swiftlint:disable trailing_comma + for directory in [ + applicationSupportDirectory, + productRoot, + userRoot, + ownerRoot, + ] { + try createOrValidateDirectory(directory, fileManager: fileManager) + } + // swiftlint:enable trailing_comma + try excludeFromBackup(ownerRoot) + try applyFileProtection(ownerRoot, fileManager: fileManager) + } catch let error as RadrootsAppleMobileStoreError { + throw error + } catch { + throw RadrootsAppleMobileStoreError.fileSystemFailure + } + + return RadrootsAppleMobileStoreConfiguration( + applicationSupportDirectory: applicationSupportDirectory, + ownerDirectory: ownerRoot, + protectedDataAvailability: protectedDataAvailability + ) + } + + private static func isCanonicalPublicKey(_ value: String) -> Bool { + value.utf8.count == 64 + && value.utf8.allSatisfy { + ($0 >= 48 && $0 <= 57) || ($0 >= 97 && $0 <= 102) + } + } + + private static func createOrValidateDirectory( + _ directory: URL, + fileManager: FileManager + ) throws { + var isDirectory: ObjCBool = false + if fileManager.fileExists(atPath: directory.path, isDirectory: &isDirectory) { + let values = try directory.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + guard isDirectory.boolValue, values.isDirectory == true, values.isSymbolicLink != true else { + throw RadrootsAppleMobileStoreError.invalidDirectoryLayout + } + return + } + try fileManager.createDirectory( + at: directory, + withIntermediateDirectories: false, + attributes: nil + ) + let values = try directory.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + guard values.isDirectory == true, values.isSymbolicLink != true else { + throw RadrootsAppleMobileStoreError.invalidDirectoryLayout + } + } + + private static func excludeFromBackup(_ directory: URL) throws { + var directory = directory + var values = URLResourceValues() + values.isExcludedFromBackup = true + try directory.setResourceValues(values) + } + + private static func applyFileProtection( + _ directory: URL, + fileManager: FileManager + ) throws { + #if os(iOS) + try fileManager.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: directory.path + ) + #endif + } +} diff --git a/Tests/RadrootsKitTests/RadrootsAppleMobileStoreTests.swift b/Tests/RadrootsKitTests/RadrootsAppleMobileStoreTests.swift @@ -0,0 +1,134 @@ +import Foundation +import Testing + +@testable import RadrootsKit + +private let publicKey = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + +@Test func mobileStorePreparesTheExactIdentityScopedDirectory() throws { + let fixture = try MobileStoreFixture() + defer { fixture.remove() } + + let configuration = try RadrootsAppleMobileStore.prepare( + roots: fixture.roots, + publicKeyHex: publicKey, + protectedDataAvailability: .available + ) + + let expectedOwner = fixture.roots.dataRoot + .appendingPathComponent("radroots", isDirectory: true) + .appendingPathComponent("users", isDirectory: true) + .appendingPathComponent(publicKey, isDirectory: true) + .standardizedFileURL + #expect(configuration.applicationSupportDirectory == fixture.roots.dataRoot) + #expect(configuration.ownerDirectory == expectedOwner) + #expect(configuration.protectedDataAvailability == .available) + + // swiftlint:disable trailing_comma + let values = try expectedOwner.resourceValues(forKeys: [ + .isDirectoryKey, + .isSymbolicLinkKey, + .isExcludedFromBackupKey, + ]) + // swiftlint:enable trailing_comma + #expect(values.isDirectory == true) + #expect(values.isSymbolicLink != true) + #expect(values.isExcludedFromBackup == true) +} + +@Test func mobileStoreDoesNotTouchDiskWhileProtectedDataIsUnavailable() throws { + let fixture = try MobileStoreFixture() + defer { fixture.remove() } + + #expect(throws: RadrootsAppleMobileStoreError.protectedDataUnavailable) { + _ = try RadrootsAppleMobileStore.prepare( + roots: fixture.roots, + publicKeyHex: publicKey, + protectedDataAvailability: .unavailable + ) + } + #expect(!FileManager.default.fileExists(atPath: fixture.roots.dataRoot.path)) +} + +@Test func mobileStoreRejectsNonCanonicalPublicKeysBeforeTouchingDisk() throws { + let fixture = try MobileStoreFixture() + defer { fixture.remove() } + + // swiftlint:disable trailing_comma + for invalidKey in [ + "", "79BE" + String(publicKey.dropFirst(4)), String(repeating: "g", count: 64), + ] { + #expect(throws: RadrootsAppleMobileStoreError.invalidPublicKey) { + _ = try RadrootsAppleMobileStore.prepare( + roots: fixture.roots, + publicKeyHex: invalidKey, + protectedDataAvailability: .available + ) + } + } + // swiftlint:enable trailing_comma + #expect(!FileManager.default.fileExists(atPath: fixture.roots.dataRoot.path)) +} + +@Test func mobileStoreRejectsAFileInTheDirectoryChain() throws { + let fixture = try MobileStoreFixture() + defer { fixture.remove() } + try FileManager.default.createDirectory( + at: fixture.roots.dataRoot, + withIntermediateDirectories: true + ) + let productPath = fixture.roots.dataRoot.appendingPathComponent("radroots") + try Data("not a directory".utf8).write(to: productPath) + + #expect(throws: RadrootsAppleMobileStoreError.invalidDirectoryLayout) { + _ = try RadrootsAppleMobileStore.prepare( + roots: fixture.roots, + publicKeyHex: publicKey, + protectedDataAvailability: .available + ) + } +} + +@Test func mobileStoreRejectsASymlinkInTheDirectoryChain() throws { + let fixture = try MobileStoreFixture() + defer { fixture.remove() } + try FileManager.default.createDirectory( + at: fixture.roots.dataRoot, + withIntermediateDirectories: true + ) + let target = fixture.root.appendingPathComponent("target", isDirectory: true) + try FileManager.default.createDirectory(at: target, withIntermediateDirectories: false) + try FileManager.default.createSymbolicLink( + at: fixture.roots.dataRoot.appendingPathComponent("radroots"), + withDestinationURL: target + ) + + #expect(throws: RadrootsAppleMobileStoreError.invalidDirectoryLayout) { + _ = try RadrootsAppleMobileStore.prepare( + roots: fixture.roots, + publicKeyHex: publicKey, + protectedDataAvailability: .available + ) + } +} + +private struct MobileStoreFixture { + let root: URL + let roots: RadrootsAppleFileRoots + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("radroots-mobile-store-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false) + roots = try RadrootsAppleFileRoots( + appIdentifier: "org.radroots.tests", + dataRoot: root.appendingPathComponent("data", isDirectory: true), + cacheRoot: root.appendingPathComponent("cache", isDirectory: true), + temporaryRoot: root.appendingPathComponent("tmp", isDirectory: true) + ) + } + + func remove() { + try? FileManager.default.removeItem(at: root) + } +}