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 03cc7a6486122d7674e1fc32b15989cb20c2dc03
parent a782d61ce2b03be1f4b3613766c75284d1b55a66
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 00:55:21 -0700

kit: add staged file access

- add the reusable Radroots file access protocol

- implement Apple-backed inline and staged-blob storage

- add reset, release, list, and sweep helpers

- cover staged blob behavior with Swift package tests

Diffstat:
MSources/RadrootsKit/RadrootsFileAccess.swift | 317+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsAppleFileAccessTests.swift | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 447 insertions(+), 0 deletions(-)

diff --git a/Sources/RadrootsKit/RadrootsFileAccess.swift b/Sources/RadrootsKit/RadrootsFileAccess.swift @@ -17,6 +17,323 @@ public struct RadrootsFileReference: Sendable, Equatable, Hashable { } } +public struct RadrootsFileEntry: Sendable, Equatable, Hashable { + public let file: RadrootsFileReference + public let name: String + public let isDirectory: Bool + public let sizeBytes: Int? + public let modifiedAt: Date? + + public init( + file: RadrootsFileReference, + name: String, + isDirectory: Bool, + sizeBytes: Int?, + modifiedAt: Date? + ) { + self.file = file + self.name = name + self.isDirectory = isDirectory + self.sizeBytes = sizeBytes + self.modifiedAt = modifiedAt + } +} + +public struct RadrootsStagedBlobReference: Sendable, Equatable, Hashable { + public let blobID: String + public let sizeBytes: Int + public let mediaType: String? + public let filenameHint: String? + + public init( + blobID: String, + sizeBytes: Int, + mediaType: String? = nil, + filenameHint: String? = nil + ) throws { + let normalizedBlobID = try Self.normalizedBlobID(blobID) + guard sizeBytes >= 0 else { + throw RadrootsAppleFileError.invalidRequest("staged blob size cannot be negative") + } + self.blobID = normalizedBlobID + self.sizeBytes = sizeBytes + self.mediaType = try Self.normalizedMediaType(mediaType) + self.filenameHint = try Self.normalizedFilenameHint(filenameHint) + } + + public static func normalizedBlobID(_ blobID: String) throws -> String { + let trimmed = blobID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw RadrootsAppleFileError.invalidRequest("staged blob id cannot be empty") + } + let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") + guard trimmed.rangeOfCharacter(from: allowed.inverted) == nil else { + throw RadrootsAppleFileError.invalidRequest("staged blob id contains invalid characters") + } + return trimmed + } + + public static func normalizedMediaType(_ mediaType: String?) throws -> String? { + guard let mediaType else { + return nil + } + let trimmed = mediaType.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw RadrootsAppleFileError.invalidRequest("staged blob media type cannot be empty") + } + guard trimmed.rangeOfCharacter(from: .newlines) == nil else { + throw RadrootsAppleFileError.invalidRequest("staged blob media type cannot contain newlines") + } + return trimmed + } + + public static func normalizedFilenameHint(_ filenameHint: String?) throws -> String? { + guard let filenameHint else { + return nil + } + let trimmed = filenameHint.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw RadrootsAppleFileError.invalidRequest("staged blob filename hint cannot be empty") + } + guard !trimmed.contains("/") && !trimmed.contains("\\") && !trimmed.contains("\0") else { + throw RadrootsAppleFileError.invalidRequest("staged blob filename hint cannot contain path separators") + } + return trimmed + } +} + +public enum RadrootsFilePayload: Sendable, Equatable { + case inline(Data) + case stagedBlob(RadrootsStagedBlobReference) +} + +public enum RadrootsFileReadMode: Sendable, Equatable { + case inline + case preferInline(maxBytes: Int) + case stagedBlob +} + +public enum RadrootsFileReadResult: Sendable, Equatable { + case inline(Data) + case stagedBlob(RadrootsStagedBlobReference) +} + +public protocol RadrootsFileAccess { + func write(_ payload: RadrootsFilePayload, to file: RadrootsFileReference) throws + func read(_ file: RadrootsFileReference, mode: RadrootsFileReadMode) throws -> RadrootsFileReadResult + func delete(_ file: RadrootsFileReference) throws + func list(_ directory: RadrootsFileReference) throws -> [RadrootsFileEntry] + func reset(scope: RadrootsFileScope) throws + @discardableResult func stageBlob(_ data: Data, mediaType: String?, filenameHint: String?) throws -> RadrootsStagedBlobReference + func readStagedBlob(_ blob: RadrootsStagedBlobReference) throws -> Data + func releaseStagedBlob(_ blob: RadrootsStagedBlobReference) throws + @discardableResult func sweepStagedBlobs(olderThan cutoff: Date) throws -> [RadrootsStagedBlobReference] + func resetStagedBlobs() throws +} + +public final class RadrootsAppleFileAccess: RadrootsFileAccess { + public let roots: RadrootsAppleFileRoots + private let fileManager: FileManager + + public init(roots: RadrootsAppleFileRoots, fileManager: FileManager = .default) { + self.roots = roots + self.fileManager = fileManager + } + + public func write(_ payload: RadrootsFilePayload, to file: RadrootsFileReference) throws { + let data: Data + switch payload { + case .inline(let inlineData): + data = inlineData + case .stagedBlob(let stagedBlob): + data = try readStagedBlob(stagedBlob) + } + let url = try roots.resolvedURL(for: file) + try createParentDirectory(for: url) + try data.write(to: url, options: [.atomic]) + } + + public func read(_ file: RadrootsFileReference, mode: RadrootsFileReadMode) throws -> RadrootsFileReadResult { + let url = try roots.resolvedURL(for: file) + guard fileManager.fileExists(atPath: url.path) else { + throw RadrootsAppleFileError.notFound("file not found") + } + switch mode { + case .inline: + return .inline(try Data(contentsOf: url)) + case .preferInline(let maxBytes): + guard maxBytes >= 0 else { + throw RadrootsAppleFileError.invalidRequest("inline byte limit cannot be negative") + } + let size = try fileSize(at: url) + if size <= maxBytes { + return .inline(try Data(contentsOf: url)) + } + let staged = try stageBlob(try Data(contentsOf: url), mediaType: nil, filenameHint: url.lastPathComponent) + return .stagedBlob(staged) + case .stagedBlob: + let staged = try stageBlob(try Data(contentsOf: url), mediaType: nil, filenameHint: url.lastPathComponent) + return .stagedBlob(staged) + } + } + + public func delete(_ file: RadrootsFileReference) throws { + let url = try roots.resolvedURL(for: file) + guard fileManager.fileExists(atPath: url.path) else { + return + } + try fileManager.removeItem(at: url) + } + + public func list(_ directory: RadrootsFileReference) throws -> [RadrootsFileEntry] { + let rootURL = roots.root(for: directory.scope).standardizedFileURL + let directoryURL = try roots.resolvedURL(for: directory, allowRootDirectory: true) + var isDirectory = ObjCBool(false) + guard fileManager.fileExists(atPath: directoryURL.path, isDirectory: &isDirectory) else { + return [] + } + guard isDirectory.boolValue else { + throw RadrootsAppleFileError.invalidRequest("file list target must be a directory") + } + let urls = try fileManager.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey], + options: [] + ) + return try urls.map { url in + let values = try url.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey]) + let relativePath = try relativePath(for: url.standardizedFileURL, under: rootURL) + return RadrootsFileEntry( + file: RadrootsFileReference(scope: directory.scope, relativePath: relativePath), + name: url.lastPathComponent, + isDirectory: values.isDirectory ?? false, + sizeBytes: values.fileSize, + modifiedAt: values.contentModificationDate + ) + } + .sorted { left, right in + left.file.relativePath < right.file.relativePath + } + } + + public func reset(scope: RadrootsFileScope) throws { + let url = roots.root(for: scope) + if fileManager.fileExists(atPath: url.path) { + try fileManager.removeItem(at: url) + } + try fileManager.createDirectory(at: url, withIntermediateDirectories: true) + } + + @discardableResult + public func stageBlob( + _ data: Data, + mediaType: String? = nil, + filenameHint: String? = nil + ) throws -> RadrootsStagedBlobReference { + let blobID = UUID().uuidString.lowercased() + let blob = try RadrootsStagedBlobReference( + blobID: blobID, + sizeBytes: data.count, + mediaType: mediaType, + filenameHint: filenameHint + ) + let url = try stagedBlobURL(for: blob) + try fileManager.createDirectory(at: roots.stagedBlobsRoot, withIntermediateDirectories: true) + try data.write(to: url, options: [.atomic]) + return blob + } + + public func readStagedBlob(_ blob: RadrootsStagedBlobReference) throws -> Data { + let url = try stagedBlobURL(for: blob) + guard fileManager.fileExists(atPath: url.path) else { + throw RadrootsAppleFileError.notFound("staged blob not found") + } + let data = try Data(contentsOf: url) + guard data.count == blob.sizeBytes else { + throw RadrootsAppleFileError.permanentFailure("staged blob size does not match reference") + } + return data + } + + public func releaseStagedBlob(_ blob: RadrootsStagedBlobReference) throws { + let url = try stagedBlobURL(for: blob) + if fileManager.fileExists(atPath: url.path) { + try fileManager.removeItem(at: url) + } + } + + @discardableResult + public func sweepStagedBlobs(olderThan cutoff: Date) throws -> [RadrootsStagedBlobReference] { + guard fileManager.fileExists(atPath: roots.stagedBlobsRoot.path) else { + return [] + } + let urls = try fileManager.contentsOfDirectory( + at: roots.stagedBlobsRoot, + includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey], + options: [] + ) + var released: [RadrootsStagedBlobReference] = [] + for url in urls { + let values = try url.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey]) + guard values.isDirectory != true else { + continue + } + guard let modifiedAt = values.contentModificationDate, modifiedAt < cutoff else { + continue + } + let blob = try RadrootsStagedBlobReference( + blobID: url.lastPathComponent, + sizeBytes: values.fileSize ?? 0 + ) + try fileManager.removeItem(at: url) + released.append(blob) + } + return released.sorted { left, right in + left.blobID < right.blobID + } + } + + public func resetStagedBlobs() throws { + if fileManager.fileExists(atPath: roots.stagedBlobsRoot.path) { + try fileManager.removeItem(at: roots.stagedBlobsRoot) + } + try fileManager.createDirectory(at: roots.stagedBlobsRoot, withIntermediateDirectories: true) + } + + public func resetFileRoots() throws { + for scope in RadrootsFileScope.allCases { + try reset(scope: scope) + } + try resetStagedBlobs() + } + + private func stagedBlobURL(for blob: RadrootsStagedBlobReference) throws -> URL { + let normalizedBlobID = try RadrootsStagedBlobReference.normalizedBlobID(blob.blobID) + return roots.stagedBlobsRoot.appendingPathComponent(normalizedBlobID).standardizedFileURL + } + + private func createParentDirectory(for url: URL) throws { + try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + } + + private func fileSize(at url: URL) throws -> Int { + let values = try url.resourceValues(forKeys: [.fileSizeKey]) + guard let size = values.fileSize else { + throw RadrootsAppleFileError.permanentFailure("file size is unavailable") + } + return size + } + + private func relativePath(for url: URL, under rootURL: URL) throws -> String { + let rootPath = rootURL.standardizedFileURL.path + let filePath = url.standardizedFileURL.path + guard filePath.hasPrefix(rootPath + "/") else { + throw RadrootsAppleFileError.invalidRequest("file list entry escaped its scope") + } + return String(filePath.dropFirst(rootPath.count + 1)) + } +} + public struct RadrootsAppleFileRoots: Sendable, Equatable { public let appIdentifier: String public let dataRoot: URL diff --git a/Tests/RadrootsKitTests/RadrootsAppleFileAccessTests.swift b/Tests/RadrootsKitTests/RadrootsAppleFileAccessTests.swift @@ -0,0 +1,130 @@ +import Foundation +import Testing +@testable import RadrootsKit + +@Test func appleFileAccessWritesReadsListsAndDeletesInlineFiles() throws { + let access = try testFileAccess() + let file = RadrootsFileReference(scope: .data, relativePath: "identity/public.json") + let data = Data(#"{"npub":"npub1test"}"#.utf8) + + try access.write(.inline(data), to: file) + + #expect(try access.read(file, mode: .inline) == .inline(data)) + let entries = try access.list(RadrootsFileReference(scope: .data, relativePath: "identity")) + #expect(entries.map(\.file.relativePath) == ["identity/public.json"]) + #expect(entries.first?.name == "public.json") + #expect(entries.first?.sizeBytes == data.count) + + try access.delete(file) + try access.delete(file) + #expect(try access.list(RadrootsFileReference(scope: .data, relativePath: "identity")).isEmpty) +} + +@Test func appleFileAccessWritesFilesFromStagedBlobsAndReleasesThem() throws { + let access = try testFileAccess() + let data = Data("hello staged blob".utf8) + let staged = try access.stageBlob(data, mediaType: "text/plain", filenameHint: "note.txt") + let file = RadrootsFileReference(scope: .cache, relativePath: "outbox/note.txt") + + #expect(try access.readStagedBlob(staged) == data) + + try access.write(.stagedBlob(staged), to: file) + try access.releaseStagedBlob(staged) + + #expect(try access.read(file, mode: .inline) == .inline(data)) + #expect(throws: RadrootsAppleFileError.self) { + _ = try access.readStagedBlob(staged) + } +} + +@Test func appleFileAccessStagesLargeReadsWhenInlineLimitIsExceeded() throws { + let access = try testFileAccess() + let file = RadrootsFileReference(scope: .data, relativePath: "events/large.json") + let data = Data("large payload".utf8) + + try access.write(.inline(data), to: file) + + guard case .stagedBlob(let staged) = try access.read(file, mode: .preferInline(maxBytes: 4)) else { + Issue.record("expected staged blob result") + return + } + + #expect(staged.filenameHint == "large.json") + #expect(try access.readStagedBlob(staged) == data) +} + +@Test func appleFileAccessKeepsSmallReadsInlineWhenLimitAllowsIt() throws { + let access = try testFileAccess() + let file = RadrootsFileReference(scope: .logs, relativePath: "radroots.log") + let data = Data("log".utf8) + + try access.write(.inline(data), to: file) + + #expect(try access.read(file, mode: .preferInline(maxBytes: data.count)) == .inline(data)) +} + +@Test func appleFileAccessRejectsInvalidStagedBlobMetadata() throws { + let access = try testFileAccess() + + #expect(throws: RadrootsAppleFileError.self) { + _ = try RadrootsStagedBlobReference(blobID: "../escape", sizeBytes: 1) + } + #expect(throws: RadrootsAppleFileError.self) { + _ = try access.stageBlob(Data("bad".utf8), mediaType: "text/plain", filenameHint: "../secret.txt") + } + #expect(throws: RadrootsAppleFileError.self) { + _ = try access.read(RadrootsFileReference(scope: .data, relativePath: "missing.json"), mode: .inline) + } +} + +@Test func appleFileAccessSweepsOnlyExpiredStagedBlobs() throws { + let access = try testFileAccess() + let oldBlob = try access.stageBlob(Data("old".utf8), mediaType: nil, filenameHint: nil) + let newBlob = try access.stageBlob(Data("new".utf8), mediaType: nil, filenameHint: nil) + let oldURL = access.roots.stagedBlobsRoot.appendingPathComponent(oldBlob.blobID) + let oldDate = Date(timeIntervalSince1970: 10) + let cutoff = Date(timeIntervalSince1970: 20) + + try FileManager.default.setAttributes([.modificationDate: oldDate], ofItemAtPath: oldURL.path) + + let swept = try access.sweepStagedBlobs(olderThan: cutoff) + + #expect(swept.map(\.blobID) == [oldBlob.blobID]) + #expect(throws: RadrootsAppleFileError.self) { + _ = try access.readStagedBlob(oldBlob) + } + #expect(try access.readStagedBlob(newBlob) == Data("new".utf8)) +} + +@Test func appleFileAccessResetsFileRootsAndStagedBlobs() throws { + let access = try testFileAccess() + let dataFile = RadrootsFileReference(scope: .data, relativePath: "state.json") + let cacheFile = RadrootsFileReference(scope: .cache, relativePath: "cache.json") + let staged = try access.stageBlob(Data("blob".utf8), mediaType: nil, filenameHint: nil) + + try access.write(.inline(Data("data".utf8)), to: dataFile) + try access.write(.inline(Data("cache".utf8)), to: cacheFile) + + try access.reset(scope: .data) + + #expect(try access.list(RadrootsFileReference(scope: .data, relativePath: "")).isEmpty) + #expect(try access.read(cacheFile, mode: .inline) == .inline(Data("cache".utf8))) + + try access.resetStagedBlobs() + + #expect(throws: RadrootsAppleFileError.self) { + _ = try access.readStagedBlob(staged) + } +} + +private func testFileAccess() throws -> RadrootsAppleFileAccess { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("radroots-file-access-\(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) + ) + return RadrootsAppleFileAccess(roots: roots) +}