apple_kit

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

commit a782d61ce2b03be1f4b3613766c75284d1b55a66
parent ee71a39735e55e1cd5ea2fd40ab2fcf3e9ef8636
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 00:49:50 -0700

kit: add file root contract

- add reusable file scopes and references
- add deterministic Apple file root derivation
- validate scoped path resolution and root config
- cover the contract with Swift package tests

Diffstat:
ASources/RadrootsKit/RadrootsAppleFileError.swift | 26++++++++++++++++++++++++++
ASources/RadrootsKit/RadrootsFileAccess.swift | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsAppleFileRootsTests.swift | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 264 insertions(+), 0 deletions(-)

diff --git a/Sources/RadrootsKit/RadrootsAppleFileError.swift b/Sources/RadrootsKit/RadrootsAppleFileError.swift @@ -0,0 +1,26 @@ +import Foundation + +public enum RadrootsAppleFileError: Error, Equatable, Sendable { + case invalidRequest(String) + case notFound(String) + case permissionDenied(String) + case transientFailure(String) + case permanentFailure(String) +} + +extension RadrootsAppleFileError: LocalizedError { + public var errorDescription: String? { + switch self { + case .invalidRequest(let message): + message + case .notFound(let message): + message + case .permissionDenied(let message): + message + case .transientFailure(let message): + message + case .permanentFailure(let message): + message + } + } +} diff --git a/Sources/RadrootsKit/RadrootsFileAccess.swift b/Sources/RadrootsKit/RadrootsFileAccess.swift @@ -0,0 +1,143 @@ +import Foundation + +public enum RadrootsFileScope: Sendable, Equatable, CaseIterable { + case data + case cache + case temporary + case logs +} + +public struct RadrootsFileReference: Sendable, Equatable, Hashable { + public let scope: RadrootsFileScope + public let relativePath: String + + public init(scope: RadrootsFileScope, relativePath: String) { + self.scope = scope + self.relativePath = relativePath + } +} + +public struct RadrootsAppleFileRoots: Sendable, Equatable { + public let appIdentifier: String + public let dataRoot: URL + public let cacheRoot: URL + public let temporaryRoot: URL + public let logsRoot: URL + public let stagedBlobsRoot: URL + + public init( + appIdentifier: String, + dataRoot: URL, + cacheRoot: URL, + temporaryRoot: URL, + logsRoot: URL? = nil, + stagedBlobsRoot: URL? = nil + ) throws { + let normalizedAppIdentifier = try Self.normalizedAppIdentifier(appIdentifier) + let normalizedDataRoot = try Self.normalizedRootURL(dataRoot, field: "dataRoot") + let normalizedCacheRoot = try Self.normalizedRootURL(cacheRoot, field: "cacheRoot") + let normalizedTemporaryRoot = try Self.normalizedRootURL(temporaryRoot, field: "temporaryRoot") + self.appIdentifier = normalizedAppIdentifier + self.dataRoot = normalizedDataRoot + self.cacheRoot = normalizedCacheRoot + self.temporaryRoot = normalizedTemporaryRoot + self.logsRoot = try Self.normalizedRootURL( + logsRoot ?? normalizedCacheRoot.appendingPathComponent("Logs", isDirectory: true), + field: "logsRoot" + ) + self.stagedBlobsRoot = try Self.normalizedRootURL( + stagedBlobsRoot ?? normalizedTemporaryRoot.appendingPathComponent("staged_blobs", isDirectory: true), + field: "stagedBlobsRoot" + ) + } + + public static func appContainer( + appIdentifier: String, + fileManager: FileManager = .default + ) throws -> Self { + let normalizedAppIdentifier = try normalizedAppIdentifier(appIdentifier) + let dataBaseURL = try fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let cacheBaseURL = try fileManager.url( + for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let dataRoot = dataBaseURL.appendingPathComponent(normalizedAppIdentifier, isDirectory: true) + let cacheRoot = cacheBaseURL.appendingPathComponent(normalizedAppIdentifier, isDirectory: true) + let temporaryRoot = fileManager.temporaryDirectory + .appendingPathComponent(normalizedAppIdentifier, isDirectory: true) + return try Self( + appIdentifier: normalizedAppIdentifier, + dataRoot: dataRoot, + cacheRoot: cacheRoot, + temporaryRoot: temporaryRoot + ) + } + + public func root(for scope: RadrootsFileScope) -> URL { + switch scope { + case .data: + dataRoot + case .cache: + cacheRoot + case .temporary: + temporaryRoot + case .logs: + logsRoot + } + } + + public func resolvedURL( + for file: RadrootsFileReference, + allowRootDirectory: Bool = false + ) throws -> URL { + let rootURL = root(for: file.scope).standardizedFileURL + let trimmedPath = file.relativePath.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedPath.isEmpty { + if allowRootDirectory { + return rootURL + } + throw RadrootsAppleFileError.invalidRequest("file relative path cannot be empty") + } + if NSString(string: trimmedPath).isAbsolutePath { + throw RadrootsAppleFileError.invalidRequest("file relative path must not be absolute") + } + + let candidateURL = rootURL.appendingPathComponent(trimmedPath).standardizedFileURL + if candidateURL.path == rootURL.path { + if allowRootDirectory { + return candidateURL + } + throw RadrootsAppleFileError.invalidRequest("file relative path cannot resolve to its root") + } + guard candidateURL.path.hasPrefix(rootURL.path + "/") else { + throw RadrootsAppleFileError.invalidRequest("file relative path must not escape its scope") + } + return candidateURL + } + + public static func normalizedAppIdentifier(_ appIdentifier: String) throws -> String { + let trimmed = appIdentifier.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw RadrootsAppleFileError.invalidRequest("app identifier cannot be empty") + } + return trimmed + } + + public static func normalizedRootURL(_ rootURL: URL, field: String) throws -> URL { + guard rootURL.isFileURL else { + throw RadrootsAppleFileError.invalidRequest("\(field) must be a file URL") + } + let standardized = rootURL.standardizedFileURL + guard standardized.path.hasPrefix("/") else { + throw RadrootsAppleFileError.invalidRequest("\(field) must be absolute") + } + return standardized + } +} diff --git a/Tests/RadrootsKitTests/RadrootsAppleFileRootsTests.swift b/Tests/RadrootsKitTests/RadrootsAppleFileRootsTests.swift @@ -0,0 +1,95 @@ +import Foundation +import Testing +@testable import RadrootsKit + +@Test func fileRootsDeriveDefaultLogsAndStagedBlobRoots() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("radroots-file-roots-\(UUID().uuidString)", isDirectory: true) + let 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) + ) + + #expect(roots.appIdentifier == "org.radroots.tests") + #expect(roots.logsRoot == roots.cacheRoot.appendingPathComponent("Logs", isDirectory: true).standardizedFileURL) + #expect(roots.stagedBlobsRoot == roots.temporaryRoot.appendingPathComponent("staged_blobs", isDirectory: true).standardizedFileURL) +} + +@Test func fileRootsRejectBlankAppIdentifier() throws { + let root = FileManager.default.temporaryDirectory + #expect(throws: RadrootsAppleFileError.self) { + _ = try RadrootsAppleFileRoots( + appIdentifier: " ", + dataRoot: root, + cacheRoot: root, + temporaryRoot: root + ) + } +} + +@Test func fileRootsRejectNonFileURLs() throws { + let root = FileManager.default.temporaryDirectory + #expect(throws: RadrootsAppleFileError.self) { + _ = try RadrootsAppleFileRoots( + appIdentifier: "org.radroots.tests", + dataRoot: URL(string: "https://radroots.org/data")!, + cacheRoot: root, + temporaryRoot: root + ) + } +} + +@Test func fileReferenceResolvesInsideSelectedScope() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("radroots-file-roots-\(UUID().uuidString)", isDirectory: true) + let 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) + ) + let file = RadrootsFileReference(scope: .data, relativePath: "identity/public.json") + + #expect(try roots.resolvedURL(for: file) == roots.dataRoot.appendingPathComponent("identity/public.json").standardizedFileURL) +} + +@Test func fileReferenceRejectsAbsolutePath() throws { + let roots = try testFileRoots() + let file = RadrootsFileReference(scope: .cache, relativePath: "/tmp/escape") + + #expect(throws: RadrootsAppleFileError.self) { + _ = try roots.resolvedURL(for: file) + } +} + +@Test func fileReferenceRejectsPathTraversal() throws { + let roots = try testFileRoots() + let file = RadrootsFileReference(scope: .data, relativePath: "../escape") + + #expect(throws: RadrootsAppleFileError.self) { + _ = try roots.resolvedURL(for: file) + } +} + +@Test func fileReferenceAllowsRootOnlyWhenRequested() throws { + let roots = try testFileRoots() + let rootFile = RadrootsFileReference(scope: .logs, relativePath: " ") + + #expect(try roots.resolvedURL(for: rootFile, allowRootDirectory: true) == roots.logsRoot) + #expect(throws: RadrootsAppleFileError.self) { + _ = try roots.resolvedURL(for: rootFile) + } +} + +private func testFileRoots() throws -> RadrootsAppleFileRoots { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("radroots-file-roots-\(UUID().uuidString)", isDirectory: true) + return try RadrootsAppleFileRoots( + appIdentifier: "org.radroots.tests", + dataRoot: root.appendingPathComponent("data", isDirectory: true), + cacheRoot: root.appendingPathComponent("cache", isDirectory: true), + temporaryRoot: root.appendingPathComponent("tmp", isDirectory: true) + ) +}