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 }