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