import Foundation import ZIPFoundation extension RDEPUBParser { 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 } 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 } 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 } 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() } func resetPublicationState() { metadata = RDEPUBMetadata() manifest = [:] spine = [] tableOfContents = [] } } private final class ContainerXMLParserDelegate: NSObject, XMLParserDelegate { 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) } }