// RDEPUBParser+Archive.swift // EPUB 归档解压与 container.xml 解析 // 负责将 EPUB ZIP 文件解压到缓存目录(~/Library/Caches/ssreaderview-epub/), // 并解析 META-INF/container.xml 获取 OPF 文件路径。 import Foundation import ZIPFoundation extension RDEPUBParser { /// 解析 container.xml,提取 OPF(Package Document)的相对路径 /// - Parameter containerURL: container.xml 的文件路径 /// - Returns: OPF 文件的相对路径(如 "OEBPS/content.opf") func parseContainerRootFile(at containerURL: URL) throws -> String { guard let parser = XMLParser(contentsOf: containerURL) else { throw RDEPUBParserError.invalidXML(containerURL) } let delegate = ContainerXMLParserDelegate() parser.shouldProcessNamespaces = false parser.delegate = delegate guard parser.parse() else { throw parser.parserError ?? RDEPUBParserError.invalidXML(containerURL) } guard let rootFilePath = delegate.rootFilePath, !rootFilePath.isEmpty else { throw RDEPUBParserError.missingRootFile } return rootFilePath } /// 解压 EPUB ZIP 文件到缓存目录(幂等:已存在则跳过) /// 目录名由 slug + 文件大小 + 修改时间戳组成,避免冲突 /// - Parameter epubURL: EPUB 文件路径 /// - Returns: 解压目标目录 URL func extractArchiveIfNeeded(epubURL: URL) throws -> URL { let fileManager = FileManager.default let extractionURL = temporaryExtractionDirectory(for: epubURL) if fileManager.fileExists(atPath: extractionURL.path) { return extractionURL } guard let archive = Archive(url: epubURL, accessMode: .read) else { throw RDEPUBParserError.archiveOpenFailed(epubURL) } try fileManager.createDirectory(at: extractionURL, withIntermediateDirectories: true) for entry in archive { guard let destinationURL = validatedExtractionDestination(for: entry.path, extractionRoot: extractionURL) else { throw RDEPUBParserError.invalidArchiveEntryPath(entry.path) } switch entry.type { case .directory: try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true) case .file: try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true) _ = try archive.extract(entry, to: destinationURL) case .symlink: continue } } return extractionURL } /// 校验归档条目路径,防止路径穿越攻击 /// - Parameters: /// - entryPath: 归档中的原始条目路径 /// - extractionRoot: 解压根目录 /// - Returns: 校验通过的目标 URL,非法路径返回 nil private func validatedExtractionDestination(for entryPath: String, extractionRoot: URL) -> URL? { // 拒绝绝对路径 if entryPath.hasPrefix("/") { return nil } // 拒绝包含 .. 的路径段 let components = entryPath.split(separator: "/", omittingEmptySubsequences: true) if components.contains(where: { $0 == ".." }) { return nil } let destinationURL = extractionRoot.appendingPathComponent(entryPath) let standardizedDest = destinationURL.standardizedFileURL.path let standardizedRoot = extractionRoot.standardizedFileURL.path // 确保最终路径位于解压根目录下 guard standardizedDest.hasPrefix(standardizedRoot) else { return nil } return destinationURL } /// 计算 EPUB 的临时解压目录路径 /// 路径格式:~/Library/Caches/ssreaderview-epub/{slug}-{fileSize}-{modifiedTimestamp}/ func temporaryExtractionDirectory(for epubURL: URL) -> URL { let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first? .appendingPathComponent("ssreaderview-epub", isDirectory: true) ?? FileManager.default.temporaryDirectory.appendingPathComponent("ssreaderview-epub", isDirectory: true) let fileAttributes = try? FileManager.default.attributesOfItem(atPath: epubURL.path) let fileSize = (fileAttributes?[.size] as? NSNumber)?.stringValue ?? "0" let modifiedAt = (fileAttributes?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 let slug = epubURL.deletingPathExtension().lastPathComponent .replacingOccurrences(of: " ", with: "-") let signature = String(format: "%.0f", modifiedAt) return baseURL.appendingPathComponent("\(slug)-\(fileSize)-\(signature)", isDirectory: true) } /// 重置解析器状态(清除解压路径和出版物数据) func reset() { extractionRootURL = nil opfURL = nil resetPublicationState() } /// 重置出版物相关状态(metadata、manifest、spine、目录) func resetPublicationState() { metadata = RDEPUBMetadata() manifest = [:] spine = [] tableOfContents = [] } } /// container.xml 的 SAX 解析代理,提取 元素的 full-path 属性 private final class ContainerXMLParserDelegate: NSObject, XMLParserDelegate { /// 解析得到的 OPF 文件相对路径 private(set) var rootFilePath: String? func parser( _ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:] ) { let name = XMLName.localName(from: qName ?? elementName) guard name == "rootfile", rootFilePath == nil else { return } rootFilePath = attributeDict["full-path"]?.trimmingCharacters(in: .whitespacesAndNewlines) } }