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