RadrootsDocumentInterchange.swift (17169B)
1 import Foundation 2 3 public enum RadrootsDocumentContentKind: String, Sendable, Equatable, Hashable, CaseIterable { 4 case json 5 case plainText 6 case url 7 case file 8 case stagedBlob 9 } 10 11 public enum RadrootsDocumentInterchangeError: Error, Equatable, Sendable { 12 case invalidRequest(String) 13 case notFound(String) 14 case userCancelled(String) 15 case permissionDenied(String) 16 case transientFailure(String) 17 case permanentFailure(String) 18 } 19 20 extension RadrootsDocumentInterchangeError: LocalizedError { 21 public var errorDescription: String? { 22 switch self { 23 case .invalidRequest(let message): 24 message 25 case .notFound(let message): 26 message 27 case .userCancelled(let message): 28 message 29 case .permissionDenied(let message): 30 message 31 case .transientFailure(let message): 32 message 33 case .permanentFailure(let message): 34 message 35 } 36 } 37 } 38 39 public struct RadrootsDocumentImportRequest: Sendable, Equatable, Hashable { 40 public let allowedContentKinds: [RadrootsDocumentContentKind] 41 public let allowsMultipleSelection: Bool 42 public let destinationScope: RadrootsFileScope 43 44 public init( 45 allowedContentKinds: [RadrootsDocumentContentKind], 46 allowsMultipleSelection: Bool = false, 47 destinationScope: RadrootsFileScope = .temporary 48 ) throws { 49 let normalizedKinds = try Self.normalizedContentKinds(allowedContentKinds) 50 self.allowedContentKinds = normalizedKinds 51 self.allowsMultipleSelection = allowsMultipleSelection 52 self.destinationScope = destinationScope 53 } 54 55 public static func normalizedContentKinds( 56 _ allowedContentKinds: [RadrootsDocumentContentKind] 57 ) throws -> [RadrootsDocumentContentKind] { 58 var seen = Set<RadrootsDocumentContentKind>() 59 let normalized = allowedContentKinds.filter { kind in 60 if seen.contains(kind) { 61 return false 62 } 63 seen.insert(kind) 64 return true 65 } 66 guard !normalized.isEmpty else { 67 throw RadrootsDocumentInterchangeError.invalidRequest("document import must allow at least one content kind") 68 } 69 return normalized 70 } 71 } 72 73 public struct RadrootsImportedDocument: Sendable, Equatable, Hashable { 74 public let file: RadrootsFileReference 75 public let originalURL: URL? 76 public let suggestedFilename: String 77 public let mediaType: String? 78 public let sizeBytes: UInt64 79 80 public init( 81 file: RadrootsFileReference, 82 originalURL: URL?, 83 suggestedFilename: String, 84 mediaType: String?, 85 sizeBytes: UInt64 86 ) throws { 87 self.file = file 88 self.originalURL = try Self.normalizedOriginalURL(originalURL) 89 self.suggestedFilename = try RadrootsDocumentInterchangeValidation.normalizedFilename(suggestedFilename) 90 self.mediaType = try RadrootsDocumentInterchangeValidation.normalizedMediaType(mediaType) 91 self.sizeBytes = sizeBytes 92 } 93 94 public static func normalizedOriginalURL(_ originalURL: URL?) throws -> URL? { 95 guard let originalURL else { 96 return nil 97 } 98 guard originalURL.isFileURL else { 99 throw RadrootsDocumentInterchangeError.invalidRequest("imported document original url must be a file url") 100 } 101 return originalURL.standardizedFileURL 102 } 103 } 104 105 public struct RadrootsDocumentImportResult: Sendable, Equatable, Hashable { 106 public let documents: [RadrootsImportedDocument] 107 108 public init(documents: [RadrootsImportedDocument]) throws { 109 guard !documents.isEmpty else { 110 throw RadrootsDocumentInterchangeError.invalidRequest("document import result cannot be empty") 111 } 112 self.documents = documents 113 } 114 } 115 116 public enum RadrootsShareItem: Sendable, Equatable, Hashable { 117 case text(String) 118 case url(URL) 119 case file(RadrootsFileReference, suggestedFilename: String?, mediaType: String?, sizeBytes: UInt64?) 120 case stagedBlob(RadrootsStagedBlobReference, suggestedFilename: String?) 121 122 public static func validatedText(_ value: String) throws -> Self { 123 .text(try RadrootsDocumentInterchangeValidation.normalizedPublicText(value, field: "share text")) 124 } 125 126 public static func validatedURL(_ value: URL) throws -> Self { 127 .url(try RadrootsDocumentInterchangeValidation.normalizedPublicURL(value)) 128 } 129 130 public static func validatedFile( 131 _ file: RadrootsFileReference, 132 suggestedFilename: String? = nil, 133 mediaType: String? = nil, 134 sizeBytes: UInt64? = nil 135 ) throws -> Self { 136 let normalizedFile = try RadrootsDocumentInterchangeValidation.normalizedScopedFileReference(file) 137 return .file( 138 normalizedFile, 139 suggestedFilename: try RadrootsDocumentInterchangeValidation.normalizedOptionalFilename(suggestedFilename), 140 mediaType: try RadrootsDocumentInterchangeValidation.normalizedMediaType(mediaType), 141 sizeBytes: sizeBytes 142 ) 143 } 144 145 public static func validatedStagedBlob( 146 _ stagedBlob: RadrootsStagedBlobReference, 147 suggestedFilename: String? = nil 148 ) throws -> Self { 149 try RadrootsDocumentInterchangeValidation.validateNoSecretMaterial( 150 stagedBlob.filenameHint, 151 field: "staged blob filename hint" 152 ) 153 return .stagedBlob( 154 stagedBlob, 155 suggestedFilename: try RadrootsDocumentInterchangeValidation.normalizedOptionalFilename(suggestedFilename) 156 ) 157 } 158 159 public var normalized: Self { 160 get throws { 161 switch self { 162 case .text(let text): 163 try Self.validatedText(text) 164 case .url(let url): 165 try Self.validatedURL(url) 166 case .file(let file, let suggestedFilename, let mediaType, let sizeBytes): 167 try Self.validatedFile(file, suggestedFilename: suggestedFilename, mediaType: mediaType, sizeBytes: sizeBytes) 168 case .stagedBlob(let stagedBlob, let suggestedFilename): 169 try Self.validatedStagedBlob(stagedBlob, suggestedFilename: suggestedFilename) 170 } 171 } 172 } 173 } 174 175 public struct RadrootsShareRequest: Sendable, Equatable, Hashable { 176 public let items: [RadrootsShareItem] 177 public let subject: String? 178 179 public init(items: [RadrootsShareItem], subject: String? = nil) throws { 180 let normalizedItems = try items.map { try $0.normalized } 181 guard !normalizedItems.isEmpty else { 182 throw RadrootsDocumentInterchangeError.invalidRequest("share request must contain at least one item") 183 } 184 self.items = normalizedItems 185 self.subject = try RadrootsDocumentInterchangeValidation.normalizedOptionalPublicText(subject, field: "share subject") 186 } 187 } 188 189 public struct RadrootsShareResult: Sendable, Equatable, Hashable { 190 public let completed: Bool 191 192 public init(completed: Bool) { 193 self.completed = completed 194 } 195 } 196 197 public enum RadrootsExportDocumentSource: Sendable, Equatable, Hashable { 198 case inlineData(Data) 199 case file(RadrootsFileReference) 200 case stagedBlob(RadrootsStagedBlobReference) 201 } 202 203 public struct RadrootsExportDocumentRequest: Sendable, Equatable, Hashable { 204 public let source: RadrootsExportDocumentSource 205 public let suggestedFilename: String 206 public let mediaType: String? 207 public let sizeBytes: UInt64? 208 209 public init( 210 source: RadrootsExportDocumentSource, 211 suggestedFilename: String, 212 mediaType: String?, 213 sizeBytes: UInt64? = nil 214 ) throws { 215 self.source = source 216 self.suggestedFilename = try RadrootsDocumentInterchangeValidation.normalizedFilename(suggestedFilename) 217 self.mediaType = try RadrootsDocumentInterchangeValidation.normalizedMediaType(mediaType) 218 self.sizeBytes = try Self.normalizedSizeBytes(source: source, requestedSizeBytes: sizeBytes) 219 } 220 221 public static func normalizedSizeBytes( 222 source: RadrootsExportDocumentSource, 223 requestedSizeBytes: UInt64? 224 ) throws -> UInt64? { 225 switch source { 226 case .inlineData(let data): 227 let actualSize = UInt64(data.count) 228 if let requestedSizeBytes, requestedSizeBytes != actualSize { 229 throw RadrootsDocumentInterchangeError.invalidRequest("inline export byte count does not match data size") 230 } 231 return actualSize 232 case .file: 233 return requestedSizeBytes 234 case .stagedBlob(let stagedBlob): 235 let actualSize = UInt64(stagedBlob.sizeBytes) 236 if let requestedSizeBytes, requestedSizeBytes != actualSize { 237 throw RadrootsDocumentInterchangeError.invalidRequest("staged blob export byte count does not match reference size") 238 } 239 return actualSize 240 } 241 } 242 } 243 244 public struct RadrootsExportDocumentResult: Sendable, Equatable, Hashable { 245 public let exportedFilename: String 246 public let mediaType: String? 247 public let sizeBytes: UInt64? 248 249 public init( 250 exportedFilename: String, 251 mediaType: String?, 252 sizeBytes: UInt64? 253 ) throws { 254 self.exportedFilename = try RadrootsDocumentInterchangeValidation.normalizedFilename(exportedFilename) 255 self.mediaType = try RadrootsDocumentInterchangeValidation.normalizedMediaType(mediaType) 256 self.sizeBytes = sizeBytes 257 } 258 } 259 260 public struct RadrootsPreparedExportDocument: Sendable, Equatable, Hashable { 261 public let preparedID: String 262 public let fileURL: URL 263 public let suggestedFilename: String 264 public let mediaType: String? 265 public let sizeBytes: UInt64? 266 267 public init( 268 preparedID: String, 269 fileURL: URL, 270 suggestedFilename: String, 271 mediaType: String?, 272 sizeBytes: UInt64? 273 ) throws { 274 self.preparedID = try Self.normalizedPreparedID(preparedID) 275 self.fileURL = try Self.normalizedFileURL(fileURL) 276 self.suggestedFilename = try RadrootsDocumentInterchangeValidation.normalizedFilename(suggestedFilename) 277 self.mediaType = try RadrootsDocumentInterchangeValidation.normalizedMediaType(mediaType) 278 self.sizeBytes = sizeBytes 279 } 280 281 public static func normalizedPreparedID(_ preparedID: String) throws -> String { 282 let trimmed = preparedID.trimmingCharacters(in: .whitespacesAndNewlines) 283 guard !trimmed.isEmpty else { 284 throw RadrootsDocumentInterchangeError.invalidRequest("prepared export id cannot be empty") 285 } 286 let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") 287 guard trimmed.rangeOfCharacter(from: allowed.inverted) == nil else { 288 throw RadrootsDocumentInterchangeError.invalidRequest("prepared export id contains invalid characters") 289 } 290 return trimmed 291 } 292 293 public static func normalizedFileURL(_ fileURL: URL) throws -> URL { 294 guard fileURL.isFileURL else { 295 throw RadrootsDocumentInterchangeError.invalidRequest("prepared export url must be a file url") 296 } 297 return fileURL.standardizedFileURL 298 } 299 } 300 301 public enum RadrootsDocumentInterchangeValidation { 302 public static func normalizedFilename(_ filename: String) throws -> String { 303 let trimmed = filename.trimmingCharacters(in: .whitespacesAndNewlines) 304 guard !trimmed.isEmpty else { 305 throw RadrootsDocumentInterchangeError.invalidRequest("document filename cannot be empty") 306 } 307 guard trimmed != "." && trimmed != ".." else { 308 throw RadrootsDocumentInterchangeError.invalidRequest("document filename cannot be a path segment") 309 } 310 guard !NSString(string: trimmed).isAbsolutePath else { 311 throw RadrootsDocumentInterchangeError.invalidRequest("document filename cannot be absolute") 312 } 313 guard !trimmed.contains("/") && !trimmed.contains("\\") && !trimmed.contains("\0") else { 314 throw RadrootsDocumentInterchangeError.invalidRequest("document filename cannot contain path separators") 315 } 316 guard trimmed.rangeOfCharacter(from: .controlCharacters) == nil else { 317 throw RadrootsDocumentInterchangeError.invalidRequest("document filename cannot contain control characters") 318 } 319 guard trimmed.utf8.count <= 255 else { 320 throw RadrootsDocumentInterchangeError.invalidRequest("document filename is too long") 321 } 322 try validateNoSecretMaterial(trimmed, field: "document filename") 323 return trimmed 324 } 325 326 public static func normalizedOptionalFilename(_ filename: String?) throws -> String? { 327 guard let filename else { 328 return nil 329 } 330 return try normalizedFilename(filename) 331 } 332 333 public static func normalizedMediaType(_ mediaType: String?) throws -> String? { 334 guard let mediaType else { 335 return nil 336 } 337 let trimmed = mediaType.trimmingCharacters(in: .whitespacesAndNewlines) 338 guard !trimmed.isEmpty else { 339 throw RadrootsDocumentInterchangeError.invalidRequest("document media type cannot be empty") 340 } 341 guard trimmed.rangeOfCharacter(from: .whitespacesAndNewlines.union(.controlCharacters)) == nil else { 342 throw RadrootsDocumentInterchangeError.invalidRequest("document media type cannot contain whitespace") 343 } 344 let parts = trimmed.split(separator: "/", omittingEmptySubsequences: false) 345 guard parts.count == 2, parts.allSatisfy({ !$0.isEmpty }) else { 346 throw RadrootsDocumentInterchangeError.invalidRequest("document media type must be type/subtype") 347 } 348 return trimmed.lowercased() 349 } 350 351 public static func normalizedPublicText(_ text: String, field: String) throws -> String { 352 let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) 353 guard !trimmed.isEmpty else { 354 throw RadrootsDocumentInterchangeError.invalidRequest("\(field) cannot be empty") 355 } 356 try validateNoSecretMaterial(trimmed, field: field) 357 return trimmed 358 } 359 360 public static func normalizedOptionalPublicText(_ text: String?, field: String) throws -> String? { 361 guard let text else { 362 return nil 363 } 364 return try normalizedPublicText(text, field: field) 365 } 366 367 public static func normalizedPublicURL(_ url: URL) throws -> URL { 368 guard let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http" else { 369 throw RadrootsDocumentInterchangeError.invalidRequest("share url must be http or https") 370 } 371 guard url.host != nil else { 372 throw RadrootsDocumentInterchangeError.invalidRequest("share url must include a host") 373 } 374 try validateNoSecretMaterial(url.absoluteString, field: "share url") 375 return url 376 } 377 378 public static func normalizedScopedFileReference(_ file: RadrootsFileReference) throws -> RadrootsFileReference { 379 let trimmed = file.relativePath.trimmingCharacters(in: .whitespacesAndNewlines) 380 guard !trimmed.isEmpty else { 381 throw RadrootsDocumentInterchangeError.invalidRequest("share file path cannot be empty") 382 } 383 guard !NSString(string: trimmed).isAbsolutePath else { 384 throw RadrootsDocumentInterchangeError.invalidRequest("share file path cannot be absolute") 385 } 386 guard !trimmed.contains("\\") && !trimmed.contains("\0") else { 387 throw RadrootsDocumentInterchangeError.invalidRequest("share file path cannot contain unsafe separators") 388 } 389 guard trimmed.rangeOfCharacter(from: .controlCharacters) == nil else { 390 throw RadrootsDocumentInterchangeError.invalidRequest("share file path cannot contain control characters") 391 } 392 let components = trimmed.split(separator: "/", omittingEmptySubsequences: false) 393 guard components.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." }) else { 394 throw RadrootsDocumentInterchangeError.invalidRequest("share file path cannot contain empty or parent segments") 395 } 396 try validateNoSecretMaterial(trimmed, field: "share file path") 397 return RadrootsFileReference(scope: file.scope, relativePath: trimmed) 398 } 399 400 public static func validateNoSecretMaterial(_ value: String?, field: String) throws { 401 guard let value else { 402 return 403 } 404 let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() 405 guard !normalized.isEmpty else { 406 return 407 } 408 let unsafeFragments = [ 409 "nsec", 410 "secret_hex", 411 "selected_secret", 412 "private_key", 413 "private key", 414 "secret_key", 415 "secret key" 416 ] 417 guard !unsafeFragments.contains(where: normalized.contains) else { 418 throw RadrootsDocumentInterchangeError.invalidRequest("\(field) cannot contain secret material") 419 } 420 } 421 }