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:
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)
+ }
+}