a1denvalu3 b251812b9e Align compressed payload send and receive bounds (#736)
* Align compressed payload send and receive bounds

* Preserve ambiguous raw deflate compatibility

* Pool decompression by memory budget
2026-07-27 22:10:27 +02:00

255 lines
10 KiB
Kotlin

package com.bitchat.android.protocol
import android.util.Log
import java.io.ByteArrayOutputStream
import java.util.zip.DataFormatException
import java.util.zip.Deflater
import java.util.zip.Inflater
/**
* Compression utilities - 100% iOS-compatible zlib implementation
* Uses the same zlib algorithm as iOS CompressionUtil.swift
*/
object CompressionUtil {
private const val COMPRESSION_THRESHOLD = com.bitchat.android.util.AppConstants.Protocol.COMPRESSION_THRESHOLD_BYTES // bytes - same as iOS
private val decompressionPool = DecompressionResourcePool.forRuntime()
/**
* Helper to check if compression is worth it - exact same logic as iOS
*/
fun shouldCompress(data: ByteArray): Boolean {
// Don't compress if:
// 1. Data is too small
// 2. Data appears to be already compressed (high entropy)
if (data.size < COMPRESSION_THRESHOLD) return false
// Simple entropy check - count unique bytes (exact same as iOS)
val byteFrequency = mutableMapOf<Byte, Int>()
for (byte in data) {
byteFrequency[byte] = (byteFrequency[byte] ?: 0) + 1
}
// If we have very high byte diversity, data is likely already compressed
val uniqueByteRatio = byteFrequency.size.toDouble() / minOf(data.size, 256).toDouble()
return uniqueByteRatio < 0.9 // Compress if less than 90% unique bytes
}
/**
* Compress data using deflate algorithm - exact same as iOS
* iOS COMPRESSION_ZLIB actually produces raw deflate data (no zlib headers)
*/
fun compress(data: ByteArray): ByteArray? {
// Skip compression for small data
if (data.size < COMPRESSION_THRESHOLD) return null
try {
// Use raw deflate format (no headers) to match iOS COMPRESSION_ZLIB behavior
val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, true) // true = raw deflate, no headers
deflater.setInput(data)
deflater.finish()
val outputStream = ByteArrayOutputStream(data.size)
val buffer = ByteArray(1024)
while (!deflater.finished()) {
val count = deflater.deflate(buffer)
outputStream.write(buffer, 0, count)
}
deflater.end()
val compressedData = outputStream.toByteArray()
// Only return if compression was beneficial (same logic as iOS)
return if (compressedData.size > 0 && compressedData.size < data.size) {
compressedData
} else {
null
}
} catch (e: Exception) {
return null
}
}
/**
* Decompress deflate compressed data - exact same as iOS
* iOS COMPRESSION_ZLIB produces raw deflate data (no headers)
*/
fun decompress(compressedData: ByteArray, originalSize: Int): ByteArray? {
if (!isValidRequest(compressedData, originalSize)) return null
return withDecompressionResources(originalSize.toLong()) {
decompressWithResourcesReserved(compressedData, originalSize)
}
}
internal fun <T> withDecompressionResources(bytes: Long, block: () -> T): T? =
decompressionPool.withReservation(bytes, block)
/**
* Inflate after the caller has reserved all packet-specific allocations.
* This avoids nested acquisition when BinaryProtocol reserves both its input copy and output.
*/
internal fun decompressWithResourcesReserved(
compressedData: ByteArray,
originalSize: Int
): ByteArray? {
if (!isValidRequest(compressedData, originalSize)) return null
return decompressExact(compressedData, originalSize)
}
private fun isValidRequest(compressedData: ByteArray, originalSize: Int): Boolean {
val maxExpandedSize = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
if (compressedData.isEmpty()) {
Log.w("CompressionUtil", "Refusing an empty compressed payload")
return false
}
if (originalSize <= 0 || originalSize > maxExpandedSize) {
Log.w(
"CompressionUtil",
"Refusing expanded payload size $originalSize outside 1..$maxExpandedSize"
)
return false
}
return true
}
private fun decompressExact(compressedData: ByteArray, originalSize: Int): ByteArray? {
return if (looksLikeZlib(compressedData)) {
// A raw stream can coincidentally begin with a valid-looking zlib header. The
// header therefore only determines which format to try first; any non-exact zlib
// result must still fall back to raw under the same size/completion bounds.
val zlibResult = try {
inflateExact(compressedData, originalSize, nowrap = false)
} catch (zlibException: DataFormatException) {
null
}
if (zlibResult != null) {
zlibResult
} else {
try {
inflateExact(compressedData, originalSize, nowrap = true)
} catch (rawException: DataFormatException) {
Log.d("CompressionUtil", "Invalid zlib/raw deflate stream")
null
}
}
} else {
try {
inflateExact(compressedData, originalSize, nowrap = true)
} catch (rawException: DataFormatException) {
Log.d("CompressionUtil", "Invalid raw deflate stream")
null
}
}
}
/** RFC 1950 header check used to avoid speculative double inflation. */
private fun looksLikeZlib(data: ByteArray): Boolean {
if (data.size < 2) return false
val cmf = data[0].toInt() and 0xFF
val flg = data[1].toInt() and 0xFF
return (cmf and 0x0F) == 8 &&
(cmf ushr 4) <= 7 &&
((cmf shl 8) or flg) % 31 == 0
}
/**
* Inflate one complete stream into exactly [originalSize] bytes.
*
* A full output buffer alone is not success: an attacker can under-declare a larger stream so
* the first inflate call fills the buffer while [Inflater.finished] remains false. Conversely,
* a truncated or over-declared stream can produce a non-empty prefix. Both forms are rejected,
* as are trailing bytes after the compressed stream.
*
* [DataFormatException] is deliberately allowed to escape so the caller can try the legacy
* zlib-wrapped format. Size/completion mismatches return null; the fallback must then prove the
* same bytes are a complete, exact-sized zlib stream before they can be accepted.
*/
@Throws(DataFormatException::class)
private fun inflateExact(
compressedData: ByteArray,
originalSize: Int,
nowrap: Boolean
): ByteArray? {
val inflater = Inflater(nowrap)
return try {
inflater.setInput(compressedData)
val output = ByteArray(originalSize)
var written = 0
while (written < originalSize) {
val count = inflater.inflate(output, written, originalSize - written)
if (count == 0) break
written += count
}
if (written != originalSize) return null
// Give Inflater one byte of room to consume the end marker. Any produced byte proves
// the declared size was smaller than the actual expansion.
val overflowProbe = ByteArray(1)
if (inflater.inflate(overflowProbe) != 0) return null
if (!inflater.finished() || inflater.remaining != 0) return null
output
} finally {
inflater.end()
}
}
/**
* Test function to verify deflate compression works correctly
* This can be called during app initialization to ensure compatibility
*/
fun testCompression(): Boolean {
try {
// Create test data that should compress well (repeating pattern like iOS would use)
val testMessage = "This is a test message that should compress well. ".repeat(10)
val originalData = testMessage.toByteArray()
Log.d("CompressionUtil", "Testing deflate compression with ${originalData.size} bytes")
// Test shouldCompress
val shouldCompress = shouldCompress(originalData)
Log.d("CompressionUtil", "shouldCompress() returned: $shouldCompress")
if (!shouldCompress) {
Log.e("CompressionUtil", "shouldCompress failed for test data")
return false
}
// Test compression
val compressed = compress(originalData)
if (compressed == null) {
Log.e("CompressionUtil", "Compression failed")
return false
}
Log.d("CompressionUtil", "Compressed ${originalData.size} bytes to ${compressed.size} bytes (${(compressed.size.toDouble() / originalData.size * 100).toInt()}%)")
// Test decompression
val decompressed = decompress(compressed, originalData.size)
if (decompressed == null) {
Log.e("CompressionUtil", "Decompression failed")
return false
}
// Verify data integrity
val isIdentical = originalData.contentEquals(decompressed)
Log.d("CompressionUtil", "Data integrity check: $isIdentical")
if (!isIdentical) {
Log.e("CompressionUtil", "Decompressed data doesn't match original")
return false
}
Log.i("CompressionUtil", "✅ deflate compression test PASSED - ready for iOS compatibility")
return true
} catch (e: Exception) {
Log.e("CompressionUtil", "deflate compression test failed: ${e.message}")
return false
}
}
}