crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

PuzzleFileReader.swift (2643B)


      1 import Foundation
      2 
      3 /// Reads local/imported puzzle files (`.xd`, `.puz`) behind a streaming byte
      4 /// cap. Every filesystem puzzle ingress — the open-URL importer, the Imported
      5 /// tab, and the Files-picker copy — goes through here so no path materializes
      6 /// an unbounded file before validation. The read never consults the reported
      7 /// file size (a file-provider URL may not supply one); it streams up to the
      8 /// cap and fails as soon as more bytes remain.
      9 enum PuzzleFileReader {
     10     enum ReadError: LocalizedError, Equatable {
     11         case oversized(limit: Int)
     12         case notUTF8
     13         case unsupportedType(String)
     14 
     15         var errorDescription: String? {
     16             switch self {
     17             case .oversized(let limit):
     18                 "The file is larger than the \(limit / 1024) KB limit for puzzle files."
     19             case .notUTF8:
     20                 "The file is not UTF-8 text."
     21             case .unsupportedType(let ext):
     22                 "\u{201C}.\(ext)\u{201D} files are not supported."
     23             }
     24         }
     25     }
     26 
     27     /// Reads the puzzle file at `url` and returns its `.xd` source, bounding
     28     /// the raw bytes first: `.xd` at `XD.maxSourceBytes` and `.puz`
     29     /// independently at `PUZToXDConverter.maxSourceBytes` (a `.puz` can hide a
     30     /// large ignored tail behind a small grid). The produced `.xd` is still
     31     /// bounded by `XD.parse` downstream.
     32     static func readSource(at url: URL) throws -> String {
     33         switch url.pathExtension.lowercased() {
     34         case "xd":
     35             let data = try boundedData(at: url, limit: XD.maxSourceBytes)
     36             guard let source = String(data: data, encoding: .utf8) else {
     37                 throw ReadError.notUTF8
     38             }
     39             return source
     40         case "puz":
     41             let data = try boundedData(at: url, limit: PUZToXDConverter.maxSourceBytes)
     42             return try PUZToXDConverter.convert(puzData: data)
     43         default:
     44             throw ReadError.unsupportedType(url.pathExtension)
     45         }
     46     }
     47 
     48     /// Streams at most `limit` bytes from `url`, throwing `.oversized` if any
     49     /// bytes remain past the cap.
     50     static func boundedData(at url: URL, limit: Int) throws -> Data {
     51         let handle = try FileHandle(forReadingFrom: url)
     52         defer { try? handle.close() }
     53         var data = Data()
     54         while data.count <= limit {
     55             let remaining = limit + 1 - data.count
     56             guard let chunk = try handle.read(upToCount: min(65_536, remaining)),
     57                   !chunk.isEmpty
     58             else { return data }
     59             data.append(chunk)
     60         }
     61         throw ReadError.oversized(limit: limit)
     62     }
     63 }