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 548a50e8b0317ca9fe9e618b8a8c432a87c695fc
parent 03cc7a6486122d7674e1fc32b15989cb20c2dc03
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 02:10:27 -0700

kit: add document interchange contracts

- add reusable document import, export, and share payload models
- validate filenames, media types, public urls, and share text
- normalize unsigned byte counts for inline and staged export sources
- cover document interchange contracts with Swift package tests

Diffstat:
ASources/RadrootsKit/RadrootsDocumentInterchange.swift | 328+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsDocumentInterchangeTests.swift | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 494 insertions(+), 0 deletions(-)

diff --git a/Sources/RadrootsKit/RadrootsDocumentInterchange.swift b/Sources/RadrootsKit/RadrootsDocumentInterchange.swift @@ -0,0 +1,328 @@ +import Foundation + +public enum RadrootsDocumentContentKind: String, Sendable, Equatable, Hashable, CaseIterable { + case json + case plainText + case url + case file + case stagedBlob +} + +public enum RadrootsDocumentInterchangeError: Error, Equatable, Sendable { + case invalidRequest(String) + case notFound(String) + case userCancelled(String) + case permissionDenied(String) + case transientFailure(String) + case permanentFailure(String) +} + +extension RadrootsDocumentInterchangeError: LocalizedError { + public var errorDescription: String? { + switch self { + case .invalidRequest(let message): + message + case .notFound(let message): + message + case .userCancelled(let message): + message + case .permissionDenied(let message): + message + case .transientFailure(let message): + message + case .permanentFailure(let message): + message + } + } +} + +public struct RadrootsDocumentImportRequest: Sendable, Equatable, Hashable { + public let allowedContentKinds: [RadrootsDocumentContentKind] + public let allowsMultipleSelection: Bool + public let destinationScope: RadrootsFileScope + + public init( + allowedContentKinds: [RadrootsDocumentContentKind], + allowsMultipleSelection: Bool = false, + destinationScope: RadrootsFileScope = .temporary + ) throws { + let normalizedKinds = try Self.normalizedContentKinds(allowedContentKinds) + self.allowedContentKinds = normalizedKinds + self.allowsMultipleSelection = allowsMultipleSelection + self.destinationScope = destinationScope + } + + public static func normalizedContentKinds( + _ allowedContentKinds: [RadrootsDocumentContentKind] + ) throws -> [RadrootsDocumentContentKind] { + var seen = Set<RadrootsDocumentContentKind>() + let normalized = allowedContentKinds.filter { kind in + if seen.contains(kind) { + return false + } + seen.insert(kind) + return true + } + guard !normalized.isEmpty else { + throw RadrootsDocumentInterchangeError.invalidRequest("document import must allow at least one content kind") + } + return normalized + } +} + +public struct RadrootsImportedDocument: Sendable, Equatable, Hashable { + public let file: RadrootsFileReference + public let originalURL: URL? + public let suggestedFilename: String + public let mediaType: String? + public let sizeBytes: UInt64 + + public init( + file: RadrootsFileReference, + originalURL: URL?, + suggestedFilename: String, + mediaType: String?, + sizeBytes: UInt64 + ) throws { + self.file = file + self.originalURL = try Self.normalizedOriginalURL(originalURL) + self.suggestedFilename = try RadrootsDocumentInterchangeValidation.normalizedFilename(suggestedFilename) + self.mediaType = try RadrootsDocumentInterchangeValidation.normalizedMediaType(mediaType) + self.sizeBytes = sizeBytes + } + + public static func normalizedOriginalURL(_ originalURL: URL?) throws -> URL? { + guard let originalURL else { + return nil + } + guard originalURL.isFileURL else { + throw RadrootsDocumentInterchangeError.invalidRequest("imported document original url must be a file url") + } + return originalURL.standardizedFileURL + } +} + +public struct RadrootsDocumentImportResult: Sendable, Equatable, Hashable { + public let documents: [RadrootsImportedDocument] + + public init(documents: [RadrootsImportedDocument]) throws { + guard !documents.isEmpty else { + throw RadrootsDocumentInterchangeError.invalidRequest("document import result cannot be empty") + } + self.documents = documents + } +} + +public enum RadrootsShareItem: Sendable, Equatable, Hashable { + case text(String) + case url(URL) + case file(RadrootsFileReference, suggestedFilename: String?, mediaType: String?, sizeBytes: UInt64?) + case stagedBlob(RadrootsStagedBlobReference, suggestedFilename: String?) + + public static func validatedText(_ value: String) throws -> Self { + .text(try RadrootsDocumentInterchangeValidation.normalizedPublicText(value, field: "share text")) + } + + public static func validatedURL(_ value: URL) throws -> Self { + .url(try RadrootsDocumentInterchangeValidation.normalizedPublicURL(value)) + } + + public static func validatedFile( + _ file: RadrootsFileReference, + suggestedFilename: String? = nil, + mediaType: String? = nil, + sizeBytes: UInt64? = nil + ) throws -> Self { + .file( + file, + suggestedFilename: try RadrootsDocumentInterchangeValidation.normalizedOptionalFilename(suggestedFilename), + mediaType: try RadrootsDocumentInterchangeValidation.normalizedMediaType(mediaType), + sizeBytes: sizeBytes + ) + } + + public static func validatedStagedBlob( + _ stagedBlob: RadrootsStagedBlobReference, + suggestedFilename: String? = nil + ) throws -> Self { + .stagedBlob( + stagedBlob, + suggestedFilename: try RadrootsDocumentInterchangeValidation.normalizedOptionalFilename(suggestedFilename) + ) + } + + public var normalized: Self { + get throws { + switch self { + case .text(let text): + try Self.validatedText(text) + case .url(let url): + try Self.validatedURL(url) + case .file(let file, let suggestedFilename, let mediaType, let sizeBytes): + try Self.validatedFile(file, suggestedFilename: suggestedFilename, mediaType: mediaType, sizeBytes: sizeBytes) + case .stagedBlob(let stagedBlob, let suggestedFilename): + try Self.validatedStagedBlob(stagedBlob, suggestedFilename: suggestedFilename) + } + } + } +} + +public struct RadrootsShareRequest: Sendable, Equatable, Hashable { + public let items: [RadrootsShareItem] + public let subject: String? + + public init(items: [RadrootsShareItem], subject: String? = nil) throws { + let normalizedItems = try items.map { try $0.normalized } + guard !normalizedItems.isEmpty else { + throw RadrootsDocumentInterchangeError.invalidRequest("share request must contain at least one item") + } + self.items = normalizedItems + self.subject = try RadrootsDocumentInterchangeValidation.normalizedOptionalPublicText(subject, field: "share subject") + } +} + +public struct RadrootsShareResult: Sendable, Equatable, Hashable { + public let completed: Bool + + public init(completed: Bool) { + self.completed = completed + } +} + +public enum RadrootsExportDocumentSource: Sendable, Equatable, Hashable { + case inlineData(Data) + case file(RadrootsFileReference) + case stagedBlob(RadrootsStagedBlobReference) +} + +public struct RadrootsExportDocumentRequest: Sendable, Equatable, Hashable { + public let source: RadrootsExportDocumentSource + public let suggestedFilename: String + public let mediaType: String? + public let sizeBytes: UInt64? + + public init( + source: RadrootsExportDocumentSource, + suggestedFilename: String, + mediaType: String?, + sizeBytes: UInt64? = nil + ) throws { + self.source = source + self.suggestedFilename = try RadrootsDocumentInterchangeValidation.normalizedFilename(suggestedFilename) + self.mediaType = try RadrootsDocumentInterchangeValidation.normalizedMediaType(mediaType) + self.sizeBytes = try Self.normalizedSizeBytes(source: source, requestedSizeBytes: sizeBytes) + } + + public static func normalizedSizeBytes( + source: RadrootsExportDocumentSource, + requestedSizeBytes: UInt64? + ) throws -> UInt64? { + switch source { + case .inlineData(let data): + let actualSize = UInt64(data.count) + if let requestedSizeBytes, requestedSizeBytes != actualSize { + throw RadrootsDocumentInterchangeError.invalidRequest("inline export byte count does not match data size") + } + return actualSize + case .file: + return requestedSizeBytes + case .stagedBlob(let stagedBlob): + let actualSize = UInt64(stagedBlob.sizeBytes) + if let requestedSizeBytes, requestedSizeBytes != actualSize { + throw RadrootsDocumentInterchangeError.invalidRequest("staged blob export byte count does not match reference size") + } + return actualSize + } + } +} + +public struct RadrootsExportDocumentResult: Sendable, Equatable, Hashable { + public let exportedFilename: String + public let mediaType: String? + public let sizeBytes: UInt64? + + public init( + exportedFilename: String, + mediaType: String?, + sizeBytes: UInt64? + ) throws { + self.exportedFilename = try RadrootsDocumentInterchangeValidation.normalizedFilename(exportedFilename) + self.mediaType = try RadrootsDocumentInterchangeValidation.normalizedMediaType(mediaType) + self.sizeBytes = sizeBytes + } +} + +public enum RadrootsDocumentInterchangeValidation { + public static func normalizedFilename(_ filename: String) throws -> String { + let trimmed = filename.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw RadrootsDocumentInterchangeError.invalidRequest("document filename cannot be empty") + } + guard trimmed != "." && trimmed != ".." else { + throw RadrootsDocumentInterchangeError.invalidRequest("document filename cannot be a path segment") + } + guard !NSString(string: trimmed).isAbsolutePath else { + throw RadrootsDocumentInterchangeError.invalidRequest("document filename cannot be absolute") + } + guard !trimmed.contains("/") && !trimmed.contains("\\") && !trimmed.contains("\0") else { + throw RadrootsDocumentInterchangeError.invalidRequest("document filename cannot contain path separators") + } + guard trimmed.rangeOfCharacter(from: .controlCharacters) == nil else { + throw RadrootsDocumentInterchangeError.invalidRequest("document filename cannot contain control characters") + } + guard trimmed.utf8.count <= 255 else { + throw RadrootsDocumentInterchangeError.invalidRequest("document filename is too long") + } + return trimmed + } + + public static func normalizedOptionalFilename(_ filename: String?) throws -> String? { + guard let filename else { + return nil + } + return try normalizedFilename(filename) + } + + 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 RadrootsDocumentInterchangeError.invalidRequest("document media type cannot be empty") + } + guard trimmed.rangeOfCharacter(from: .whitespacesAndNewlines.union(.controlCharacters)) == nil else { + throw RadrootsDocumentInterchangeError.invalidRequest("document media type cannot contain whitespace") + } + let parts = trimmed.split(separator: "/", omittingEmptySubsequences: false) + guard parts.count == 2, parts.allSatisfy({ !$0.isEmpty }) else { + throw RadrootsDocumentInterchangeError.invalidRequest("document media type must be type/subtype") + } + return trimmed.lowercased() + } + + public static func normalizedPublicText(_ text: String, field: String) throws -> String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw RadrootsDocumentInterchangeError.invalidRequest("\(field) cannot be empty") + } + return trimmed + } + + public static func normalizedOptionalPublicText(_ text: String?, field: String) throws -> String? { + guard let text else { + return nil + } + return try normalizedPublicText(text, field: field) + } + + public static func normalizedPublicURL(_ url: URL) throws -> URL { + guard let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http" else { + throw RadrootsDocumentInterchangeError.invalidRequest("share url must be http or https") + } + guard url.host != nil else { + throw RadrootsDocumentInterchangeError.invalidRequest("share url must include a host") + } + return url + } +} diff --git a/Tests/RadrootsKitTests/RadrootsDocumentInterchangeTests.swift b/Tests/RadrootsKitTests/RadrootsDocumentInterchangeTests.swift @@ -0,0 +1,166 @@ +import Foundation +import Testing +@testable import RadrootsKit + +@Test func documentImportRequestNormalizesContentKinds() throws { + let request = try RadrootsDocumentImportRequest( + allowedContentKinds: [.json, .json, .plainText], + allowsMultipleSelection: true, + destinationScope: .cache + ) + + #expect(request.allowedContentKinds == [.json, .plainText]) + #expect(request.allowsMultipleSelection) + #expect(request.destinationScope == .cache) + + #expect(throws: RadrootsDocumentInterchangeError.self) { + _ = try RadrootsDocumentImportRequest(allowedContentKinds: []) + } +} + +@Test func documentImportResultRejectsEmptyResultsAndUnsafeMetadata() throws { + let file = RadrootsFileReference(scope: .temporary, relativePath: "imports/config.json") + let document = try RadrootsImportedDocument( + file: file, + originalURL: URL(fileURLWithPath: "/tmp/config.json"), + suggestedFilename: " config.json ", + mediaType: " Application/JSON ", + sizeBytes: 12 + ) + + #expect(document.originalURL == URL(fileURLWithPath: "/tmp/config.json")) + #expect(document.suggestedFilename == "config.json") + #expect(document.mediaType == "application/json") + #expect(try RadrootsDocumentImportResult(documents: [document]).documents == [document]) + + #expect(throws: RadrootsDocumentInterchangeError.self) { + _ = try RadrootsDocumentImportResult(documents: []) + } + #expect(throws: RadrootsDocumentInterchangeError.self) { + _ = try RadrootsImportedDocument( + file: file, + originalURL: URL(string: "https://radroots.org/config.json"), + suggestedFilename: "config.json", + mediaType: "application/json", + sizeBytes: 1 + ) + } + #expect(throws: RadrootsDocumentInterchangeError.self) { + _ = try RadrootsImportedDocument( + file: file, + originalURL: nil, + suggestedFilename: "../config.json", + mediaType: "application/json", + sizeBytes: 1 + ) + } +} + +@Test func shareRequestValidatesPublicItems() throws { + let file = RadrootsFileReference(scope: .cache, relativePath: "exports/diagnostics.json") + let stagedBlob = try RadrootsStagedBlobReference( + blobID: "diagnostics", + sizeBytes: 24, + mediaType: "application/json", + filenameHint: "diagnostics.json" + ) + let request = try RadrootsShareRequest( + items: [ + .text(" public post "), + .url(URL(string: "https://radroots.org/posts/1")!), + .file(file, suggestedFilename: " diagnostics.json ", mediaType: " Application/JSON ", sizeBytes: 24), + .stagedBlob(stagedBlob, suggestedFilename: " staged.json ") + ], + subject: " Radroots " + ) + + #expect(request.subject == "Radroots") + #expect(request.items.count == 4) + #expect(request.items[0] == .text("public post")) + #expect(request.items[1] == .url(URL(string: "https://radroots.org/posts/1")!)) + #expect(request.items[2] == .file(file, suggestedFilename: "diagnostics.json", mediaType: "application/json", sizeBytes: 24)) + #expect(request.items[3] == .stagedBlob(stagedBlob, suggestedFilename: "staged.json")) + + #expect(throws: RadrootsDocumentInterchangeError.self) { + _ = try RadrootsShareRequest(items: []) + } + #expect(throws: RadrootsDocumentInterchangeError.self) { + _ = try RadrootsShareRequest(items: [.text(" ")]) + } + #expect(throws: RadrootsDocumentInterchangeError.self) { + _ = try RadrootsShareRequest(items: [.url(URL(string: "file:///tmp/private.txt")!)]) + } +} + +@Test func exportDocumentRequestValidatesFilenameMediaTypeAndByteCounts() throws { + let inlineData = Data(#"{"relay":"wss://radroots.org"}"#.utf8) + let inlineRequest = try RadrootsExportDocumentRequest( + source: .inlineData(inlineData), + suggestedFilename: " relays.json ", + mediaType: " Application/JSON " + ) + + #expect(inlineRequest.suggestedFilename == "relays.json") + #expect(inlineRequest.mediaType == "application/json") + #expect(inlineRequest.sizeBytes == UInt64(inlineData.count)) + + let stagedBlob = try RadrootsStagedBlobReference(blobID: "relay-export", sizeBytes: 64) + let stagedRequest = try RadrootsExportDocumentRequest( + source: .stagedBlob(stagedBlob), + suggestedFilename: "relay-export.json", + mediaType: "application/json", + sizeBytes: 64 + ) + #expect(stagedRequest.sizeBytes == 64) + + #expect(throws: RadrootsDocumentInterchangeError.self) { + _ = try RadrootsExportDocumentRequest( + source: .inlineData(inlineData), + suggestedFilename: "/tmp/relays.json", + mediaType: "application/json" + ) + } + #expect(throws: RadrootsDocumentInterchangeError.self) { + _ = try RadrootsExportDocumentRequest( + source: .inlineData(inlineData), + suggestedFilename: "relays.json", + mediaType: "application json" + ) + } + #expect(throws: RadrootsDocumentInterchangeError.self) { + _ = try RadrootsExportDocumentRequest( + source: .inlineData(inlineData), + suggestedFilename: "relays.json", + mediaType: "application/json", + sizeBytes: 1 + ) + } + #expect(throws: RadrootsDocumentInterchangeError.self) { + _ = try RadrootsExportDocumentRequest( + source: .stagedBlob(stagedBlob), + suggestedFilename: "relay-export.json", + mediaType: "application/json", + sizeBytes: 1 + ) + } +} + +@Test func exportDocumentResultNormalizesPublicMetadata() throws { + let result = try RadrootsExportDocumentResult( + exportedFilename: " diagnostics.json ", + mediaType: " Application/JSON ", + sizeBytes: 99 + ) + + #expect(result.exportedFilename == "diagnostics.json") + #expect(result.mediaType == "application/json") + #expect(result.sizeBytes == 99) + + #expect(throws: RadrootsDocumentInterchangeError.self) { + _ = try RadrootsExportDocumentResult( + exportedFilename: "bad/name.json", + mediaType: "application/json", + sizeBytes: nil + ) + } +}