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