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 acfb31baaa6a3364b46e29c2776149e66bebfd16
parent 548a50e8b0317ca9fe9e618b8a8c432a87c695fc
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 02:13:12 -0700

kit: add file based document staging

- add external file import and staging primitives to file access
- prepare export documents under temporary AppleKit roots
- copy staged and large scoped files without public inline data transfer
- cover file staging, import, export, cleanup, and invalid requests

Diffstat:
MSources/RadrootsKit/RadrootsDocumentInterchange.swift | 41+++++++++++++++++++++++++++++++++++++++++
MSources/RadrootsKit/RadrootsFileAccess.swift | 186+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
MTests/RadrootsKitTests/RadrootsAppleFileAccessTests.swift | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 335 insertions(+), 8 deletions(-)

diff --git a/Sources/RadrootsKit/RadrootsDocumentInterchange.swift b/Sources/RadrootsKit/RadrootsDocumentInterchange.swift @@ -252,6 +252,47 @@ public struct RadrootsExportDocumentResult: Sendable, Equatable, Hashable { } } +public struct RadrootsPreparedExportDocument: Sendable, Equatable, Hashable { + public let preparedID: String + public let fileURL: URL + public let suggestedFilename: String + public let mediaType: String? + public let sizeBytes: UInt64? + + public init( + preparedID: String, + fileURL: URL, + suggestedFilename: String, + mediaType: String?, + sizeBytes: UInt64? + ) throws { + self.preparedID = try Self.normalizedPreparedID(preparedID) + self.fileURL = try Self.normalizedFileURL(fileURL) + self.suggestedFilename = try RadrootsDocumentInterchangeValidation.normalizedFilename(suggestedFilename) + self.mediaType = try RadrootsDocumentInterchangeValidation.normalizedMediaType(mediaType) + self.sizeBytes = sizeBytes + } + + public static func normalizedPreparedID(_ preparedID: String) throws -> String { + let trimmed = preparedID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw RadrootsDocumentInterchangeError.invalidRequest("prepared export id cannot be empty") + } + let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") + guard trimmed.rangeOfCharacter(from: allowed.inverted) == nil else { + throw RadrootsDocumentInterchangeError.invalidRequest("prepared export id contains invalid characters") + } + return trimmed + } + + public static func normalizedFileURL(_ fileURL: URL) throws -> URL { + guard fileURL.isFileURL else { + throw RadrootsDocumentInterchangeError.invalidRequest("prepared export url must be a file url") + } + return fileURL.standardizedFileURL + } +} + public enum RadrootsDocumentInterchangeValidation { public static func normalizedFilename(_ filename: String) throws -> String { let trimmed = filename.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/RadrootsKit/RadrootsFileAccess.swift b/Sources/RadrootsKit/RadrootsFileAccess.swift @@ -125,8 +125,18 @@ public protocol RadrootsFileAccess { func list(_ directory: RadrootsFileReference) throws -> [RadrootsFileEntry] func reset(scope: RadrootsFileScope) throws @discardableResult func stageBlob(_ data: Data, mediaType: String?, filenameHint: String?) throws -> RadrootsStagedBlobReference + @discardableResult func stageFile(_ file: RadrootsFileReference, mediaType: String?, filenameHint: String?) throws -> RadrootsStagedBlobReference + @discardableResult func stageExternalFile(_ sourceURL: URL, mediaType: String?, filenameHint: String?) throws -> RadrootsStagedBlobReference + @discardableResult func copyExternalFile( + _ sourceURL: URL, + to file: RadrootsFileReference, + mediaType: String?, + suggestedFilename: String? + ) throws -> RadrootsImportedDocument + @discardableResult func prepareExport(_ request: RadrootsExportDocumentRequest) throws -> RadrootsPreparedExportDocument func readStagedBlob(_ blob: RadrootsStagedBlobReference) throws -> Data func releaseStagedBlob(_ blob: RadrootsStagedBlobReference) throws + func releasePreparedExport(_ preparedExport: RadrootsPreparedExportDocument) throws @discardableResult func sweepStagedBlobs(olderThan cutoff: Date) throws -> [RadrootsStagedBlobReference] func resetStagedBlobs() throws } @@ -141,16 +151,14 @@ public final class RadrootsAppleFileAccess: RadrootsFileAccess { } public func write(_ payload: RadrootsFilePayload, to file: RadrootsFileReference) throws { - let data: Data + let url = try roots.resolvedURL(for: file) + try createParentDirectory(for: url) switch payload { case .inline(let inlineData): - data = inlineData + try inlineData.write(to: url, options: [.atomic]) case .stagedBlob(let stagedBlob): - data = try readStagedBlob(stagedBlob) + try copyReplacingItem(from: try stagedBlobURL(for: stagedBlob), to: url) } - 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 { @@ -169,10 +177,10 @@ public final class RadrootsAppleFileAccess: RadrootsFileAccess { if size <= maxBytes { return .inline(try Data(contentsOf: url)) } - let staged = try stageBlob(try Data(contentsOf: url), mediaType: nil, filenameHint: url.lastPathComponent) + let staged = try stageFile(file, mediaType: nil, filenameHint: url.lastPathComponent) return .stagedBlob(staged) case .stagedBlob: - let staged = try stageBlob(try Data(contentsOf: url), mediaType: nil, filenameHint: url.lastPathComponent) + let staged = try stageFile(file, mediaType: nil, filenameHint: url.lastPathComponent) return .stagedBlob(staged) } } @@ -243,6 +251,89 @@ public final class RadrootsAppleFileAccess: RadrootsFileAccess { return blob } + @discardableResult + public func stageFile( + _ file: RadrootsFileReference, + mediaType: String? = nil, + filenameHint: String? = nil + ) throws -> RadrootsStagedBlobReference { + let sourceURL = try roots.resolvedURL(for: file) + guard fileManager.fileExists(atPath: sourceURL.path) else { + throw RadrootsAppleFileError.notFound("file not found") + } + return try stageFileURL( + sourceURL, + mediaType: mediaType, + filenameHint: filenameHint ?? sourceURL.lastPathComponent + ) + } + + @discardableResult + public func stageExternalFile( + _ sourceURL: URL, + mediaType: String? = nil, + filenameHint: String? = nil + ) throws -> RadrootsStagedBlobReference { + try withSecurityScopedFile(sourceURL) { scopedURL in + try stageFileURL( + scopedURL, + mediaType: mediaType, + filenameHint: filenameHint ?? scopedURL.lastPathComponent + ) + } + } + + @discardableResult + public func copyExternalFile( + _ sourceURL: URL, + to file: RadrootsFileReference, + mediaType: String? = nil, + suggestedFilename: String? = nil + ) throws -> RadrootsImportedDocument { + try withSecurityScopedFile(sourceURL) { scopedURL in + let destinationURL = try roots.resolvedURL(for: file) + try createParentDirectory(for: destinationURL) + try copyReplacingItem(from: scopedURL, to: destinationURL) + let sizeBytes = try fileSizeUInt64(at: destinationURL) + return try RadrootsImportedDocument( + file: file, + originalURL: scopedURL, + suggestedFilename: suggestedFilename ?? scopedURL.lastPathComponent, + mediaType: mediaType, + sizeBytes: sizeBytes + ) + } + } + + @discardableResult + public func prepareExport(_ request: RadrootsExportDocumentRequest) throws -> RadrootsPreparedExportDocument { + let preparedID = UUID().uuidString.lowercased() + let directoryURL = preparedExportsRoot.appendingPathComponent(preparedID, isDirectory: true) + let fileURL = directoryURL.appendingPathComponent(request.suggestedFilename).standardizedFileURL + try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) + switch request.source { + case .inlineData(let data): + try data.write(to: fileURL, options: [.atomic]) + case .file(let file): + try copyReplacingItem(from: try roots.resolvedURL(for: file), to: fileURL) + case .stagedBlob(let stagedBlob): + try copyReplacingItem(from: try stagedBlobURL(for: stagedBlob), to: fileURL) + } + let sizeBytes: UInt64 + if let requestSizeBytes = request.sizeBytes { + sizeBytes = requestSizeBytes + } else { + sizeBytes = try fileSizeUInt64(at: fileURL) + } + return try RadrootsPreparedExportDocument( + preparedID: preparedID, + fileURL: fileURL, + suggestedFilename: request.suggestedFilename, + mediaType: request.mediaType, + sizeBytes: sizeBytes + ) + } + public func readStagedBlob(_ blob: RadrootsStagedBlobReference) throws -> Data { let url = try stagedBlobURL(for: blob) guard fileManager.fileExists(atPath: url.path) else { @@ -262,6 +353,13 @@ public final class RadrootsAppleFileAccess: RadrootsFileAccess { } } + public func releasePreparedExport(_ preparedExport: RadrootsPreparedExportDocument) throws { + let directoryURL = try preparedExportDirectoryURL(for: preparedExport) + if fileManager.fileExists(atPath: directoryURL.path) { + try fileManager.removeItem(at: directoryURL) + } + } + @discardableResult public func sweepStagedBlobs(olderThan cutoff: Date) throws -> [RadrootsStagedBlobReference] { guard fileManager.fileExists(atPath: roots.stagedBlobsRoot.path) else { @@ -312,11 +410,79 @@ public final class RadrootsAppleFileAccess: RadrootsFileAccess { return roots.stagedBlobsRoot.appendingPathComponent(normalizedBlobID).standardizedFileURL } + private var preparedExportsRoot: URL { + roots.temporaryRoot.appendingPathComponent("prepared_exports", isDirectory: true).standardizedFileURL + } + + private func preparedExportDirectoryURL(for preparedExport: RadrootsPreparedExportDocument) throws -> URL { + let normalizedPreparedID = try RadrootsPreparedExportDocument.normalizedPreparedID(preparedExport.preparedID) + let directoryURL = preparedExportsRoot.appendingPathComponent(normalizedPreparedID, isDirectory: true).standardizedFileURL + guard preparedExport.fileURL.standardizedFileURL.path.hasPrefix(directoryURL.path + "/") else { + throw RadrootsAppleFileError.invalidRequest("prepared export file escaped its directory") + } + return directoryURL + } + + private func stageFileURL( + _ sourceURL: URL, + mediaType: String?, + filenameHint: String? + ) throws -> RadrootsStagedBlobReference { + let sizeBytes = try fileSizeInt(at: sourceURL) + let blobID = UUID().uuidString.lowercased() + let blob = try RadrootsStagedBlobReference( + blobID: blobID, + sizeBytes: sizeBytes, + mediaType: mediaType, + filenameHint: filenameHint + ) + let destinationURL = try stagedBlobURL(for: blob) + try fileManager.createDirectory(at: roots.stagedBlobsRoot, withIntermediateDirectories: true) + try copyReplacingItem(from: sourceURL, to: destinationURL) + return blob + } + + private func withSecurityScopedFile<T>(_ sourceURL: URL, _ body: (URL) throws -> T) throws -> T { + guard sourceURL.isFileURL else { + throw RadrootsAppleFileError.invalidRequest("external file url must be a file url") + } + let scopedURL = sourceURL.standardizedFileURL + var isDirectory = ObjCBool(false) + guard fileManager.fileExists(atPath: scopedURL.path, isDirectory: &isDirectory) else { + throw RadrootsAppleFileError.notFound("external file not found") + } + guard !isDirectory.boolValue else { + throw RadrootsAppleFileError.invalidRequest("external file url must reference a file") + } + let didStartScope = scopedURL.startAccessingSecurityScopedResource() + defer { + if didStartScope { + scopedURL.stopAccessingSecurityScopedResource() + } + } + return try body(scopedURL) + } + + private func copyReplacingItem(from sourceURL: URL, to destinationURL: URL) throws { + guard sourceURL.isFileURL, destinationURL.isFileURL else { + throw RadrootsAppleFileError.invalidRequest("copy source and destination must be file urls") + } + try createParentDirectory(for: destinationURL) + if fileManager.fileExists(atPath: destinationURL.path) { + try fileManager.removeItem(at: destinationURL) + } + try fileManager.copyItem(at: sourceURL, to: destinationURL) + } + private func createParentDirectory(for url: URL) throws { try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) } private func fileSize(at url: URL) throws -> Int { + try fileSizeInt(at: url) + } + + private func fileSizeInt(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") @@ -324,6 +490,10 @@ public final class RadrootsAppleFileAccess: RadrootsFileAccess { return size } + private func fileSizeUInt64(at url: URL) throws -> UInt64 { + UInt64(try fileSizeInt(at: url)) + } + private func relativePath(for url: URL, under rootURL: URL) throws -> String { let rootPath = rootURL.standardizedFileURL.path let filePath = url.standardizedFileURL.path diff --git a/Tests/RadrootsKitTests/RadrootsAppleFileAccessTests.swift b/Tests/RadrootsKitTests/RadrootsAppleFileAccessTests.swift @@ -53,6 +53,98 @@ import Testing #expect(try access.readStagedBlob(staged) == data) } +@Test func appleFileAccessStagesScopedFilesWithoutChangingTheSource() throws { + let access = try testFileAccess() + let file = RadrootsFileReference(scope: .data, relativePath: "events/large.json") + let data = Data(repeating: 7, count: 1_048_576) + + try access.write(.inline(data), to: file) + let staged = try access.stageFile(file, mediaType: "application/json", filenameHint: "large.json") + + try access.delete(file) + + #expect(staged.mediaType == "application/json") + #expect(staged.filenameHint == "large.json") + #expect(staged.sizeBytes == data.count) + #expect(try access.readStagedBlob(staged) == data) +} + +@Test func appleFileAccessStagesExternalFiles() throws { + let access = try testFileAccess() + let externalURL = try writeExternalTestFile(name: "selected.txt", data: Data("external".utf8)) + + let staged = try access.stageExternalFile(externalURL, mediaType: "text/plain", filenameHint: nil) + + #expect(staged.mediaType == "text/plain") + #expect(staged.filenameHint == "selected.txt") + #expect(try access.readStagedBlob(staged) == Data("external".utf8)) +} + +@Test func appleFileAccessCopiesExternalFilesIntoScopedStorage() throws { + let access = try testFileAccess() + let externalURL = try writeExternalTestFile(name: "relays.json", data: Data(#"{"relays":[]}"#.utf8)) + let destination = RadrootsFileReference(scope: .data, relativePath: "imports/relays.json") + + let imported = try access.copyExternalFile( + externalURL, + to: destination, + mediaType: "application/json", + suggestedFilename: nil + ) + + #expect(imported.file == destination) + #expect(imported.originalURL == externalURL.standardizedFileURL) + #expect(imported.suggestedFilename == "relays.json") + #expect(imported.mediaType == "application/json") + #expect(imported.sizeBytes == UInt64(Data(#"{"relays":[]}"#.utf8).count)) + #expect(try access.read(destination, mode: .inline) == .inline(Data(#"{"relays":[]}"#.utf8))) +} + +@Test func appleFileAccessPreparesAndReleasesExportDocuments() throws { + let access = try testFileAccess() + let file = RadrootsFileReference(scope: .data, relativePath: "exports/diagnostics.json") + let data = Data(#"{"status":"ok"}"#.utf8) + try access.write(.inline(data), to: file) + let staged = try access.stageFile(file, mediaType: "application/json", filenameHint: "diagnostics.json") + + let filePrepared = try access.prepareExport( + try RadrootsExportDocumentRequest( + source: .file(file), + suggestedFilename: "diagnostics.json", + mediaType: "application/json" + ) + ) + let stagedPrepared = try access.prepareExport( + try RadrootsExportDocumentRequest( + source: .stagedBlob(staged), + suggestedFilename: "staged-diagnostics.json", + mediaType: "application/json" + ) + ) + let inlinePrepared = try access.prepareExport( + try RadrootsExportDocumentRequest( + source: .inlineData(data), + suggestedFilename: "inline-diagnostics.json", + mediaType: "application/json" + ) + ) + + #expect(try Data(contentsOf: filePrepared.fileURL) == data) + #expect(try Data(contentsOf: stagedPrepared.fileURL) == data) + #expect(try Data(contentsOf: inlinePrepared.fileURL) == data) + #expect(filePrepared.sizeBytes == UInt64(data.count)) + #expect(stagedPrepared.sizeBytes == UInt64(data.count)) + #expect(inlinePrepared.sizeBytes == UInt64(data.count)) + + try access.releasePreparedExport(filePrepared) + try access.releasePreparedExport(stagedPrepared) + try access.releasePreparedExport(inlinePrepared) + + #expect(!FileManager.default.fileExists(atPath: filePrepared.fileURL.path)) + #expect(!FileManager.default.fileExists(atPath: stagedPrepared.fileURL.path)) + #expect(!FileManager.default.fileExists(atPath: inlinePrepared.fileURL.path)) +} + @Test func appleFileAccessKeepsSmallReadsInlineWhenLimitAllowsIt() throws { let access = try testFileAccess() let file = RadrootsFileReference(scope: .logs, relativePath: "radroots.log") @@ -75,6 +167,21 @@ import Testing #expect(throws: RadrootsAppleFileError.self) { _ = try access.read(RadrootsFileReference(scope: .data, relativePath: "missing.json"), mode: .inline) } + #expect(throws: RadrootsAppleFileError.self) { + _ = try access.stageExternalFile(URL(string: "https://radroots.org/file.json")!, mediaType: nil, filenameHint: nil) + } + #expect(throws: RadrootsAppleFileError.self) { + _ = try access.stageExternalFile(try writeExternalTestFile(name: "bad.txt", data: Data("bad".utf8)), mediaType: nil, filenameHint: "../bad.txt") + } + #expect(throws: RadrootsDocumentInterchangeError.self) { + _ = try access.prepareExport( + try RadrootsExportDocumentRequest( + source: .inlineData(Data("bad".utf8)), + suggestedFilename: "../bad.txt", + mediaType: "text/plain" + ) + ) + } } @Test func appleFileAccessSweepsOnlyExpiredStagedBlobs() throws { @@ -128,3 +235,12 @@ private func testFileAccess() throws -> RadrootsAppleFileAccess { ) return RadrootsAppleFileAccess(roots: roots) } + +private func writeExternalTestFile(name: String, data: Data) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("radroots-file-access-external-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent(name) + try data.write(to: url) + return url.standardizedFileURL +}