Signal-iOS/SignalServiceKit/Util/StreamTransform/Input/GzipDecompressingStreamTransform.swift

279 lines
9.6 KiB
Swift

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import Compression
public class GzipDecompressingStreamTransform: StreamTransform, FinalizableStreamTransform {
struct GZipHeader {
let id1: UInt8
let id2: UInt8
let cm: UInt8
let flg: UInt8
let xfl: UInt8
let os: UInt8
}
struct GZipFooter {
let crc: UInt32
let size: UInt32
}
public enum Error: Swift.Error {
case StreamInputError
case StreamMissingFooter
case InvalidHeader
case InvalidFooter
case InvalidChecksum
case InvalidFilesize
case UnsupportedMetadata
}
private enum Constants {
static let BufferSize = 32_768
static let HeaderSize = 10
static let FooterSize = 8
}
private let streamPointer: UnsafeMutablePointer<compression_stream>
private let destinationBufferPointer: UnsafeMutablePointer<UInt8>
private var crc = CRC32()
private var count: UInt32 = 0
private var initialized: Bool = false
private var finalized = false
public var hasFinalized: Bool { finalized }
private var inputBuffer = Data()
public var hasPendingBytes: Bool { return inputBuffer.count > Constants.FooterSize }
init() throws {
self.destinationBufferPointer = UnsafeMutablePointer<UInt8>.allocate(capacity: Constants.BufferSize)
self.streamPointer = UnsafeMutablePointer<compression_stream>.allocate(capacity: 1)
try initializeCompressionStream()
}
private func initializeCompressionStream() throws {
let status = compression_stream_init(streamPointer, COMPRESSION_STREAM_DECODE, COMPRESSION_ZLIB)
guard status != COMPRESSION_STATUS_ERROR else {
throw Error.StreamInputError
}
// Set up the initial destination buffer state
streamPointer.pointee.dst_ptr = destinationBufferPointer
streamPointer.pointee.dst_size = Constants.BufferSize
}
public func transform(data: Data) throws -> Data {
inputBuffer.append(data)
if !initialized {
guard inputBuffer.count >= Constants.HeaderSize else {
return Data()
}
inputBuffer = try readHeader(data: inputBuffer)
}
let reservedRange = (inputBuffer.count - Constants.FooterSize)
guard reservedRange >= 1 else {
// Nothing to operate on, return empty
return Data()
}
let targetData = inputBuffer.subdata(in: 0..<reservedRange)
inputBuffer = inputBuffer.subdata(in: reservedRange..<inputBuffer.count)
let decompressedData = try decompress(data: targetData)
// Original filesize recorded in the footer is wrapped if the size is larger than 2^32
(count, _) = count.addingReportingOverflow(UInt32(decompressedData.count))
// update the CRC as data is read
self.crc = self.crc.update(with: decompressedData)
return decompressedData
}
private func readHeader(data: Data) throws -> Data {
let header: GZipHeader = try data.withUnsafeBytes {
guard let ptr = $0.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
throw Error.InvalidHeader
}
// +---+---+---+---+---+---+---+---+---+---+
// |ID1|ID2|CM |FLG| MTIME |XFL|OS |
// +---+---+---+---+---+---+---+---+---+---+
return GZipHeader(
id1: ptr[0],
id2: ptr[1],
cm: ptr[2],
flg: ptr[3],
xfl: ptr[8],
os: ptr[9]
)
}
// Wrong gzip magic or unsupported compression method
guard header.id1 == 0x1f && header.id2 == 0x8b && header.cm == 0x08 else {
throw Error.InvalidHeader
}
// If there are any miscellany values, RFC doesn't require interpreting them,
// compliance just requires skipping them.
// For now, just throw an error if we encounter any extended metadata,
// since these values will cause zipping to fail, and shouldn't be present
// in other client backups
// +---+---+=================================+
// | XLEN |...XLEN bytes of "extra field"...|
// +---+---+=================================+
let hasExtra = header.flg & 0b00100 != 0
// +=========================================+
// |...original file name, zero-terminated...| (more-->)
// +=========================================+
let hasFname = header.flg & 0b01000 != 0
// +===================================+
// |...file comment, zero-terminated...| (more-->)
// +===================================+
let hasComment = header.flg & 0b10000 != 0
// +---+---+
// | CRC16 |
// +---+---+
let hasCrc16 = header.flg & 0b00010 != 0
if hasExtra || hasFname || hasComment || hasCrc16 {
throw Error.UnsupportedMetadata
}
self.initialized = true
return data.subdata(in: Constants.HeaderSize..<data.count)
}
private func decompress(data: Data, finalize: Bool = false) throws -> Data {
let flags = finalize ? Int32(COMPRESSION_STREAM_FINALIZE.rawValue) : 0
streamPointer.pointee.src_size = data.count
let status = data.withUnsafeBytes {
guard let baseAddress = $0.bindMemory(to: UInt8.self).baseAddress else {
return COMPRESSION_STATUS_ERROR
}
streamPointer.pointee.src_ptr = baseAddress
// Decompress the passed in data
return compression_stream_process(streamPointer, flags)
}
switch status {
case COMPRESSION_STATUS_OK, COMPRESSION_STATUS_END:
// Get the number of bytes put in the destination buffer.
// This is the difference between `stream.dst_size` before the
// call (`bufferSize`), and `stream.dst_size` after the call.
// Note that for small writes, compression_stream_process will often
// return no data in the destination buffer. Data is only returned
// when the compression method fills the buffer or determines the
// current output buffer is large enough that it should return the data,
// and allow it to begin a new chunk.
let destCount = Constants.BufferSize - streamPointer.pointee.dst_size
let outputData = Data(bytes: destinationBufferPointer, count: destCount)
// Reset the stream to receive the next batch of output.
streamPointer.pointee.dst_ptr = destinationBufferPointer
streamPointer.pointee.dst_size = Constants.BufferSize
return outputData
case COMPRESSION_STATUS_ERROR:
throw Error.StreamInputError
default:
throw Error.StreamInputError
}
}
private func readFooter(data: Data) throws -> GZipFooter {
guard data.count >= Constants.FooterSize else {
throw Error.InvalidFooter
}
return try data.withUnsafeBytes {
guard let baseAddress = $0.bindMemory(to: UInt8.self).baseAddress else {
throw Error.InvalidFooter
}
// 0 1 2 3 4 5 6 7
// +---+---+---+---+---+---+---+---+
// | CRC32 | ISIZE |
// +---+---+---+---+---+---+---+---+
let size = MemoryLayout<UInt32>.size
guard
let crc = UInt32(littleEndianData: Data(bytes: baseAddress, count: size)),
let originalSize = UInt32(littleEndianData: Data(bytes: baseAddress + size, count: size))
else {
throw Error.InvalidFooter
}
return GZipFooter(crc: crc, size: originalSize)
}
}
public func finalize() throws -> Data {
guard !finalized else { return Data() }
finalized = true
// Finalize means there is no more input, which is a sign that the
// CRC & Size footer values should be available.
// 1. Remove these values from the end of the buffer
// 2. Decompress any remaining bytes in the buffer
// 3. Validate the CRC & File Size
guard inputBuffer.count >= Constants.FooterSize else {
throw Error.StreamMissingFooter
}
let footer = try readFooter(data: inputBuffer)
let remainingData = inputBuffer.subdata(in: 0..<(inputBuffer.count - Constants.FooterSize))
// Clear out the input buffer to ensure hasBytesAvailable returns false
inputBuffer = Data()
// Pass in `finalize` with an empty write to signal to the compression
// layer that processing is finished and any remaining data should
// be returned.
let finalDecompressData = try decompress(data: remainingData, finalize: true)
crc = crc.update(with: finalDecompressData)
// append checksum
let checksum = crc.value.littleEndian
guard checksum == footer.crc else {
throw Error.InvalidChecksum
}
// append size of original data
let size = UInt32(truncatingIfNeeded: count).littleEndian
guard size == footer.size else {
throw Error.InvalidFilesize
}
return finalDecompressData
}
public func readBufferedData() throws -> Data {
let data = inputBuffer
inputBuffer = Data()
return data
}
public func close() throws {
destinationBufferPointer.deallocate()
compression_stream_destroy(streamPointer)
streamPointer.deallocate()
}
}