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 fe759666a3e6165330e492eb7320d0b74f894473
parent c0177dda89e33cbf18179f53558105f6ae63f693
Author: triesap <tyson@radroots.org>
Date:   Thu, 18 Jun 2026 13:40:58 -0700

document: support file-backed share items

- validate scoped share files and secret-like material
- prepare scoped files through AppleKit file access
- prepare staged blobs as file-backed share exports
- cover text url file and staged share requests

Diffstat:
MSources/RadrootsKit/RadrootsDocumentInterchange.swift | 58+++++++++++++++++++++++++++++++++++++++++++++++++++++++---
MSources/RadrootsKit/RadrootsDocumentPresentation.swift | 170+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
MTests/RadrootsKitTests/RadrootsDocumentPresentationTests.swift | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 312 insertions(+), 16 deletions(-)

diff --git a/Sources/RadrootsKit/RadrootsDocumentInterchange.swift b/Sources/RadrootsKit/RadrootsDocumentInterchange.swift @@ -133,8 +133,9 @@ public enum RadrootsShareItem: Sendable, Equatable, Hashable { mediaType: String? = nil, sizeBytes: UInt64? = nil ) throws -> Self { - .file( - file, + let normalizedFile = try RadrootsDocumentInterchangeValidation.normalizedScopedFileReference(file) + return .file( + normalizedFile, suggestedFilename: try RadrootsDocumentInterchangeValidation.normalizedOptionalFilename(suggestedFilename), mediaType: try RadrootsDocumentInterchangeValidation.normalizedMediaType(mediaType), sizeBytes: sizeBytes @@ -145,7 +146,11 @@ public enum RadrootsShareItem: Sendable, Equatable, Hashable { _ stagedBlob: RadrootsStagedBlobReference, suggestedFilename: String? = nil ) throws -> Self { - .stagedBlob( + try RadrootsDocumentInterchangeValidation.validateNoSecretMaterial( + stagedBlob.filenameHint, + field: "staged blob filename hint" + ) + return .stagedBlob( stagedBlob, suggestedFilename: try RadrootsDocumentInterchangeValidation.normalizedOptionalFilename(suggestedFilename) ) @@ -314,6 +319,7 @@ public enum RadrootsDocumentInterchangeValidation { guard trimmed.utf8.count <= 255 else { throw RadrootsDocumentInterchangeError.invalidRequest("document filename is too long") } + try validateNoSecretMaterial(trimmed, field: "document filename") return trimmed } @@ -347,6 +353,7 @@ public enum RadrootsDocumentInterchangeValidation { guard !trimmed.isEmpty else { throw RadrootsDocumentInterchangeError.invalidRequest("\(field) cannot be empty") } + try validateNoSecretMaterial(trimmed, field: field) return trimmed } @@ -364,6 +371,51 @@ public enum RadrootsDocumentInterchangeValidation { guard url.host != nil else { throw RadrootsDocumentInterchangeError.invalidRequest("share url must include a host") } + try validateNoSecretMaterial(url.absoluteString, field: "share url") return url } + + public static func normalizedScopedFileReference(_ file: RadrootsFileReference) throws -> RadrootsFileReference { + let trimmed = file.relativePath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw RadrootsDocumentInterchangeError.invalidRequest("share file path cannot be empty") + } + guard !NSString(string: trimmed).isAbsolutePath else { + throw RadrootsDocumentInterchangeError.invalidRequest("share file path cannot be absolute") + } + guard !trimmed.contains("\\") && !trimmed.contains("\0") else { + throw RadrootsDocumentInterchangeError.invalidRequest("share file path cannot contain unsafe separators") + } + guard trimmed.rangeOfCharacter(from: .controlCharacters) == nil else { + throw RadrootsDocumentInterchangeError.invalidRequest("share file path cannot contain control characters") + } + let components = trimmed.split(separator: "/", omittingEmptySubsequences: false) + guard components.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." }) else { + throw RadrootsDocumentInterchangeError.invalidRequest("share file path cannot contain empty or parent segments") + } + try validateNoSecretMaterial(trimmed, field: "share file path") + return RadrootsFileReference(scope: file.scope, relativePath: trimmed) + } + + public static func validateNoSecretMaterial(_ value: String?, field: String) throws { + guard let value else { + return + } + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !normalized.isEmpty else { + return + } + let unsafeFragments = [ + "nsec", + "secret_hex", + "selected_secret", + "private_key", + "private key", + "secret_key", + "secret key" + ] + guard !unsafeFragments.contains(where: normalized.contains) else { + throw RadrootsDocumentInterchangeError.invalidRequest("\(field) cannot contain secret material") + } + } } diff --git a/Sources/RadrootsKit/RadrootsDocumentPresentation.swift b/Sources/RadrootsKit/RadrootsDocumentPresentation.swift @@ -46,34 +46,148 @@ public enum RadrootsDocumentPresentationAdapter { } public static func transferItem(for request: RadrootsShareRequest) throws -> RadrootsShareTransferItem { + try transferItem(for: request, fileAccess: nil) + } + + public static func transferItem( + for request: RadrootsShareRequest, + fileAccess: any RadrootsFileAccess + ) throws -> RadrootsShareTransferItem { + let optionalFileAccess: (any RadrootsFileAccess)? = fileAccess + return try transferItem(for: request, fileAccess: optionalFileAccess) + } + + private static func transferItem( + for request: RadrootsShareRequest, + fileAccess: (any RadrootsFileAccess)? + ) throws -> RadrootsShareTransferItem { for item in request.items { switch try item.normalized { case .text(let text): return try RadrootsShareTransferItem(text: text, subject: request.subject) case .url(let url): - return try RadrootsShareTransferItem(text: url.absoluteString, subject: request.subject) - case .file, .stagedBlob: - continue + return try RadrootsShareTransferItem(url: url, subject: request.subject) + case .file(let file, let suggestedFilename, let mediaType, let sizeBytes): + guard let fileAccess else { + continue + } + let export = try fileAccess.prepareExport( + RadrootsExportDocumentRequest( + source: .file(file), + suggestedFilename: try shareFilename( + explicitFilename: suggestedFilename, + fallbackFilename: NSString(string: file.relativePath).lastPathComponent + ), + mediaType: mediaType, + sizeBytes: sizeBytes + ) + ) + return try RadrootsShareTransferItem(preparedExport: export, subject: request.subject) + case .stagedBlob(let stagedBlob, let suggestedFilename): + guard let fileAccess else { + continue + } + let export = try fileAccess.prepareExport( + RadrootsExportDocumentRequest( + source: .stagedBlob(stagedBlob), + suggestedFilename: try shareFilename( + explicitFilename: suggestedFilename, + fallbackFilename: stagedBlob.filenameHint ?? stagedBlob.blobID + ), + mediaType: stagedBlob.mediaType, + sizeBytes: UInt64(stagedBlob.sizeBytes) + ) + ) + return try RadrootsShareTransferItem(preparedExport: export, subject: request.subject) } } - throw RadrootsDocumentInterchangeError.invalidRequest("share request does not contain a public text or url item") + throw RadrootsDocumentInterchangeError.invalidRequest("share request does not contain a supported public share item") + } + + private static func shareFilename(explicitFilename: String?, fallbackFilename: String) throws -> String { + if let explicitFilename { + return try RadrootsDocumentInterchangeValidation.normalizedFilename(explicitFilename) + } + let fallback = fallbackFilename.trimmingCharacters(in: .whitespacesAndNewlines) + if fallback.isEmpty { + return "radroots-share-item" + } + return try RadrootsDocumentInterchangeValidation.normalizedFilename(fallback) } } public struct RadrootsShareTransferItem: Transferable, Sendable, Equatable, Hashable { - public let text: String + public enum Payload: Sendable, Equatable, Hashable { + case text(String) + case url(URL) + case file(RadrootsPreparedExportDocument) + } + + public let payload: Payload public let subject: String? public init(text: String, subject: String? = nil) throws { - self.text = try RadrootsDocumentInterchangeValidation.normalizedPublicText(text, field: "share transfer text") + self.payload = .text(try RadrootsDocumentInterchangeValidation.normalizedPublicText(text, field: "share transfer text")) + self.subject = try RadrootsDocumentInterchangeValidation.normalizedOptionalPublicText( + subject, + field: "share transfer subject" + ) + } + + public init(url: URL, subject: String? = nil) throws { + self.payload = .url(try RadrootsDocumentInterchangeValidation.normalizedPublicURL(url)) + self.subject = try RadrootsDocumentInterchangeValidation.normalizedOptionalPublicText( + subject, + field: "share transfer subject" + ) + } + + public init(preparedExport: RadrootsPreparedExportDocument, subject: String? = nil) throws { + self.payload = .file(preparedExport) self.subject = try RadrootsDocumentInterchangeValidation.normalizedOptionalPublicText( subject, field: "share transfer subject" ) } + public var text: String? { + switch payload { + case .text(let text): + text + case .url(let url): + url.absoluteString + case .file: + nil + } + } + + public var url: URL? { + guard case .url(let url) = payload else { + return nil + } + return url + } + + public var preparedExport: RadrootsPreparedExportDocument? { + guard case .file(let preparedExport) = payload else { + return nil + } + return preparedExport + } + + public var transferText: String { + switch payload { + case .text(let text): + text + case .url(let url): + url.absoluteString + case .file(let preparedExport): + preparedExport.suggestedFilename + } + } + public static var transferRepresentation: some TransferRepresentation { - ProxyRepresentation(exporting: \.text) + ProxyRepresentation(exporting: \.transferText) } } @@ -225,13 +339,43 @@ public struct RadrootsSharePresentationLink<Label: View>: View { self.label = label } - public var body: some View { - ShareLink( - item: transferItem.text, - subject: transferItem.subject.map(Text.init) ?? Text(""), - message: Text(transferItem.text), - label: label + public init( + request: RadrootsShareRequest, + fileAccess: any RadrootsFileAccess, + @ViewBuilder label: @escaping () -> Label + ) throws { + self.transferItem = try RadrootsDocumentPresentationAdapter.transferItem( + for: request, + fileAccess: fileAccess ) + self.label = label + } + + @ViewBuilder + public var body: some View { + switch transferItem.payload { + case .text(let text): + ShareLink( + item: text, + subject: transferItem.subject.map(Text.init) ?? Text(""), + message: Text(text), + label: label + ) + case .url(let url): + ShareLink( + item: url, + subject: transferItem.subject.map(Text.init) ?? Text(""), + message: Text(url.absoluteString), + label: label + ) + case .file(let preparedExport): + ShareLink( + item: preparedExport.fileURL, + subject: transferItem.subject.map(Text.init) ?? Text(""), + message: Text(preparedExport.suggestedFilename), + label: label + ) + } } } diff --git a/Tests/RadrootsKitTests/RadrootsDocumentPresentationTests.swift b/Tests/RadrootsKitTests/RadrootsDocumentPresentationTests.swift @@ -39,12 +39,14 @@ import UniformTypeIdentifiers let textRequest = try RadrootsShareRequest(items: [.text(" public post ")], subject: " Radroots ") let textItem = try RadrootsDocumentPresentationAdapter.transferItem(for: textRequest) + #expect(textItem.payload == .text("public post")) #expect(textItem.text == "public post") #expect(textItem.subject == "Radroots") let urlRequest = try RadrootsShareRequest(items: [.url(URL(string: "https://radroots.org/posts/1")!)]) let urlItem = try RadrootsDocumentPresentationAdapter.transferItem(for: urlRequest) + #expect(urlItem.payload == .url(URL(string: "https://radroots.org/posts/1")!)) #expect(urlItem.text == "https://radroots.org/posts/1") let file = RadrootsFileReference(scope: .data, relativePath: "exports/diagnostics.json") @@ -57,6 +59,92 @@ import UniformTypeIdentifiers } } +@Test func documentPresentationPreparesScopedFileShareItems() throws { + let access = try testDocumentPresentationFileAccess() + let file = RadrootsFileReference(scope: .data, relativePath: "exports/diagnostics.json") + let data = Data(#"{"status":"ok"}"#.utf8) + try access.write(.inline(data), to: file) + let request = try RadrootsShareRequest( + items: [ + .file( + file, + suggestedFilename: " diagnostics.json ", + mediaType: " Application/JSON ", + sizeBytes: UInt64(data.count) + ) + ], + subject: " Radroots " + ) + + let item = try RadrootsDocumentPresentationAdapter.transferItem(for: request, fileAccess: access) + guard let prepared = item.preparedExport else { + Issue.record("expected prepared file share item") + return + } + + #expect(item.subject == "Radroots") + #expect(prepared.suggestedFilename == "diagnostics.json") + #expect(prepared.mediaType == "application/json") + #expect(prepared.sizeBytes == UInt64(data.count)) + #expect(try Data(contentsOf: prepared.fileURL) == data) + #expect(try access.preparedExportExists(prepared)) +} + +@Test func documentPresentationPreparesStagedBlobShareItems() throws { + let access = try testDocumentPresentationFileAccess() + let data = Data("staged export".utf8) + let staged = try access.stageBlob( + data, + mediaType: "text/plain", + filenameHint: "staged-note.txt" + ) + let request = try RadrootsShareRequest( + items: [.stagedBlob(staged, suggestedFilename: nil)], + subject: nil + ) + + let item = try RadrootsDocumentPresentationAdapter.transferItem(for: request, fileAccess: access) + guard let prepared = item.preparedExport else { + Issue.record("expected prepared staged blob share item") + return + } + + #expect(prepared.suggestedFilename == "staged-note.txt") + #expect(prepared.mediaType == "text/plain") + #expect(prepared.sizeBytes == UInt64(data.count)) + #expect(try Data(contentsOf: prepared.fileURL) == data) +} + +@Test func documentPresentationRejectsUnsafeShareFilesAndSecretMaterial() throws { + #expect(throws: RadrootsDocumentInterchangeError.self) { + _ = try RadrootsShareRequest( + items: [ + .file( + RadrootsFileReference(scope: .data, relativePath: "/tmp/private.txt"), + suggestedFilename: "private.txt", + mediaType: "text/plain", + sizeBytes: nil + ) + ] + ) + } + #expect(throws: RadrootsDocumentInterchangeError.self) { + _ = try RadrootsShareRequest(items: [.text("nostr:nsec1qqqqqq")]) + } + #expect(throws: RadrootsDocumentInterchangeError.self) { + _ = try RadrootsShareRequest( + items: [ + .file( + RadrootsFileReference(scope: .data, relativePath: "identity/public.json"), + suggestedFilename: "selected_secret_hex.json", + mediaType: "application/json", + sizeBytes: nil + ) + ] + ) + } +} + @Test func preparedExportFileDocumentWrapsPreparedExportURL() throws { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("radroots-prepared-export-\(UUID().uuidString)", isDirectory: true) @@ -77,3 +165,15 @@ import UniformTypeIdentifiers #expect(document.fileURL == fileURL.standardizedFileURL) #expect(RadrootsPreparedExportFileDocument.readableContentTypes == [.data]) } + +private func testDocumentPresentationFileAccess() throws -> RadrootsAppleFileAccess { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("radroots-document-presentation-\(UUID().uuidString)", isDirectory: true) + let roots = try RadrootsAppleFileRoots( + appIdentifier: "org.radroots.document-presentation.tests", + dataRoot: root.appendingPathComponent("data", isDirectory: true), + cacheRoot: root.appendingPathComponent("cache", isDirectory: true), + temporaryRoot: root.appendingPathComponent("tmp", isDirectory: true) + ) + return RadrootsAppleFileAccess(roots: roots) +}