world-manager/World Manager for Minecraft/Services/ArchiveInspection/ZipArchiveReader.swift

255 lines
9.3 KiB
Swift

// SPDX-FileCopyrightText: 2026 John Burwell and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
import zlib
nonisolated struct ZipArchiveEntry: Sendable, Hashable {
let path: String
let compressionMethod: UInt16
let compressedSize: UInt32
let uncompressedSize: UInt32
let localHeaderOffset: UInt32
let isDirectory: Bool
}
nonisolated enum ZipArchiveReaderError: LocalizedError {
case invalidArchive
case unsupportedCompressionMethod(UInt16)
case unsupportedFeatures(String)
case entryNotFound(String)
case decompressionFailed(Int32)
var errorDescription: String? {
switch self {
case .invalidArchive:
return "The ZIP archive is invalid or unsupported."
case .unsupportedCompressionMethod(let method):
return "Unsupported ZIP compression method: \(method)."
case .unsupportedFeatures(let message):
return message
case .entryNotFound(let path):
return "ZIP entry not found: \(path)"
case .decompressionFailed(let code):
return "ZIP decompression failed with zlib error \(code)."
}
}
}
nonisolated struct ZipArchiveReader {
private let data: Data
let entries: [ZipArchiveEntry]
init(url: URL) throws {
self.data = try Data(contentsOf: url)
self.entries = try ZipArchiveReader.parseEntries(in: data)
}
func entry(named path: String) -> ZipArchiveEntry? {
let normalizedPath = Self.normalizedPath(path)
return entries.first { $0.path == normalizedPath }
}
func extract(_ entry: ZipArchiveEntry) throws -> Data {
let localHeaderOffset = Int(entry.localHeaderOffset)
guard localHeaderOffset + 30 <= data.count else {
throw ZipArchiveReaderError.invalidArchive
}
guard data.readUInt32LE(at: localHeaderOffset) == 0x04034b50 else {
throw ZipArchiveReaderError.invalidArchive
}
let generalPurposeFlags = data.readUInt16LE(at: localHeaderOffset + 6)
if generalPurposeFlags & 0x0001 != 0 {
throw ZipArchiveReaderError.unsupportedFeatures("Encrypted ZIP entries are not supported.")
}
let filenameLength = Int(data.readUInt16LE(at: localHeaderOffset + 26))
let extraFieldLength = Int(data.readUInt16LE(at: localHeaderOffset + 28))
let payloadOffset = localHeaderOffset + 30 + filenameLength + extraFieldLength
let compressedSize = Int(entry.compressedSize)
guard payloadOffset >= 0, payloadOffset + compressedSize <= data.count else {
throw ZipArchiveReaderError.invalidArchive
}
let compressedData = data.subdata(in: payloadOffset ..< payloadOffset + compressedSize)
switch entry.compressionMethod {
case 0:
return compressedData
case 8:
return try Self.inflateRawDeflate(compressedData, expectedSize: Int(entry.uncompressedSize))
default:
throw ZipArchiveReaderError.unsupportedCompressionMethod(entry.compressionMethod)
}
}
private static func parseEntries(in data: Data) throws -> [ZipArchiveEntry] {
let endOfCentralDirectoryOffset = try locateEndOfCentralDirectory(in: data)
let totalEntries = Int(data.readUInt16LE(at: endOfCentralDirectoryOffset + 10))
let centralDirectorySize = Int(data.readUInt32LE(at: endOfCentralDirectoryOffset + 12))
let centralDirectoryOffset = Int(data.readUInt32LE(at: endOfCentralDirectoryOffset + 16))
guard centralDirectoryOffset >= 0, centralDirectorySize >= 0, centralDirectoryOffset + centralDirectorySize <= data.count else {
throw ZipArchiveReaderError.invalidArchive
}
var entries: [ZipArchiveEntry] = []
var offset = centralDirectoryOffset
for _ in 0 ..< totalEntries {
guard offset + 46 <= data.count else {
throw ZipArchiveReaderError.invalidArchive
}
guard data.readUInt32LE(at: offset) == 0x02014b50 else {
throw ZipArchiveReaderError.invalidArchive
}
let compressionMethod = data.readUInt16LE(at: offset + 10)
let compressedSize = data.readUInt32LE(at: offset + 20)
let uncompressedSize = data.readUInt32LE(at: offset + 24)
let filenameLength = Int(data.readUInt16LE(at: offset + 28))
let extraFieldLength = Int(data.readUInt16LE(at: offset + 30))
let fileCommentLength = Int(data.readUInt16LE(at: offset + 32))
let localHeaderOffset = data.readUInt32LE(at: offset + 42)
let filenameStart = offset + 46
let filenameEnd = filenameStart + filenameLength
guard filenameEnd <= data.count else {
throw ZipArchiveReaderError.invalidArchive
}
let filenameData = data.subdata(in: filenameStart ..< filenameEnd)
guard let filename = String(data: filenameData, encoding: .utf8) ?? String(data: filenameData, encoding: .isoLatin1) else {
throw ZipArchiveReaderError.invalidArchive
}
let normalizedPath = Self.normalizedPath(filename)
entries.append(
ZipArchiveEntry(
path: normalizedPath,
compressionMethod: compressionMethod,
compressedSize: compressedSize,
uncompressedSize: uncompressedSize,
localHeaderOffset: localHeaderOffset,
isDirectory: normalizedPath.hasSuffix("/")
)
)
offset = filenameEnd + extraFieldLength + fileCommentLength
}
return entries
}
private static func locateEndOfCentralDirectory(in data: Data) throws -> Int {
let minimumLength = 22
guard data.count >= minimumLength else {
throw ZipArchiveReaderError.invalidArchive
}
let searchStart = max(0, data.count - (minimumLength + 65_535))
let signature: UInt32 = 0x06054b50
for offset in stride(from: data.count - minimumLength, through: searchStart, by: -1) {
if data.readUInt32LE(at: offset) == signature {
return offset
}
}
throw ZipArchiveReaderError.invalidArchive
}
private static func normalizedPath(_ path: String) -> String {
let replaced = path.replacingOccurrences(of: "\\", with: "/")
let components = replaced
.split(separator: "/")
.filter { $0 != "." && !$0.isEmpty }
.map(String.init)
let trailingSlash = replaced.hasSuffix("/")
let joined = components.joined(separator: "/")
if trailingSlash, !joined.isEmpty {
return joined + "/"
}
return joined
}
private static func inflateRawDeflate(_ data: Data, expectedSize: Int) throws -> Data {
if data.isEmpty {
return Data()
}
var stream = z_stream()
stream.zalloc = nil
stream.zfree = nil
stream.opaque = nil
let initCode = inflateInit2_(&stream, -MAX_WBITS, ZLIB_VERSION, Int32(MemoryLayout<z_stream>.size))
guard initCode == Z_OK else {
throw ZipArchiveReaderError.decompressionFailed(initCode)
}
defer {
inflateEnd(&stream)
}
var output = Data()
let chunkSize = max(expectedSize, 64 * 1024)
var status: Int32 = Z_OK
try data.withUnsafeBytes { compressedBytes in
guard let compressedBase = compressedBytes.bindMemory(to: Bytef.self).baseAddress else {
throw ZipArchiveReaderError.decompressionFailed(Z_DATA_ERROR)
}
stream.next_in = UnsafeMutablePointer(mutating: compressedBase)
stream.avail_in = uInt(data.count)
var buffer = [UInt8](repeating: 0, count: chunkSize)
repeat {
try buffer.withUnsafeMutableBufferPointer { bufferPointer in
guard let baseAddress = bufferPointer.baseAddress else {
throw ZipArchiveReaderError.decompressionFailed(Z_DATA_ERROR)
}
stream.next_out = baseAddress
stream.avail_out = uInt(bufferPointer.count)
status = inflate(&stream, Z_NO_FLUSH)
if status != Z_OK && status != Z_STREAM_END {
throw ZipArchiveReaderError.decompressionFailed(status)
}
let producedByteCount = bufferPointer.count - Int(stream.avail_out)
if producedByteCount > 0 {
output.append(contentsOf: bufferPointer.prefix(producedByteCount))
}
}
} while status != Z_STREAM_END
}
return output
}
}
private nonisolated extension Data {
func readUInt16LE(at offset: Int) -> UInt16 {
return self.withUnsafeBytes { bytes in
let base = bytes.baseAddress!.advanced(by: offset)
return base.loadUnaligned(as: UInt16.self).littleEndian
}
}
func readUInt32LE(at offset: Int) -> UInt32 {
return self.withUnsafeBytes { bytes in
let base = bytes.baseAddress!.advanced(by: offset)
return base.loadUnaligned(as: UInt32.self).littleEndian
}
}
}