apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README

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 }