apple_kit

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

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 }