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