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
This commit is contained in:
a1denvalu3
2026-07-26 11:52:55 +02:00
committed by GitHub
parent fc9abffe0c
commit 55f9e7cac4
9 changed files with 290 additions and 49 deletions
@@ -183,7 +183,6 @@ object BinaryProtocol {
private const val SENDER_ID_SIZE = 8
private const val RECIPIENT_ID_SIZE = 8
private const val SIGNATURE_SIZE = 64
object Flags {
const val HAS_RECIPIENT: UByte = 0x01u
const val HAS_SIGNATURE: UByte = 0x02u
@@ -200,6 +199,15 @@ object BinaryProtocol {
fun encode(packet: BitchatPacket, padding: Boolean = true): ByteArray? {
try {
val maxPayloadLength = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
if (packet.payload.size > maxPayloadLength) {
Log.w(
"BinaryProtocol",
"Cannot encode payload ${packet.payload.size} above receiver limit $maxPayloadLength"
)
return null
}
// Try to compress payload if beneficial
var payload = packet.payload
var originalPayloadSize: Int? = null
@@ -325,7 +333,7 @@ object BinaryProtocol {
}
fun decode(data: ByteArray): BitchatPacket? =
decode(data, CompressionUtil::decompress)
decode(data, CompressionUtil::decompressWithResourcesReserved)
/** Test seam used to prove rejected expansion sizes never reach inflation. */
internal fun decodeForTesting(
@@ -466,9 +474,6 @@ object BinaryProtocol {
Log.w("BinaryProtocol", "Compressed payload has no deflate bytes")
return null
}
val compressedPayload = ByteArray(compressedSize)
buffer.get(compressedPayload)
// Security check: Compression bomb protection
val ratio = originalSize.toDouble() / compressedSize.toDouble()
if (ratio > 50_000.0) {
@@ -476,8 +481,14 @@ object BinaryProtocol {
return null
}
// Decompress
val expandedPayload = decompress(compressedPayload, originalSize) ?: return null
// Reserve the compressed copy plus expanded output before either allocation.
// Small packets share the memory pool; packets wait only while its budget is full.
val resourceBytes = compressedSize.toLong() + originalSize.toLong()
val expandedPayload = CompressionUtil.withDecompressionResources(resourceBytes) {
val compressedPayload = ByteArray(compressedSize)
buffer.get(compressedPayload)
decompress(compressedPayload, originalSize)
} ?: return null
if (expandedPayload.size != originalSize) {
Log.w(
"BinaryProtocol",
@@ -13,9 +13,7 @@ import java.util.zip.Inflater
object CompressionUtil {
private const val COMPRESSION_THRESHOLD = com.bitchat.android.util.AppConstants.Protocol.COMPRESSION_THRESHOLD_BYTES // bytes - same as iOS
// Inflation allocates the full declared output buffer. Keep that allocation single-flight so
// concurrent packets cannot multiply the bounded per-packet memory cost.
private val decompressionLock = Any()
private val decompressionPool = DecompressionResourcePool.forRuntime()
/**
* Helper to check if compression is worth it - exact same logic as iOS
@@ -78,50 +76,83 @@ object CompressionUtil {
* 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 null
return false
}
if (originalSize <= 0 || originalSize > maxExpandedSize) {
Log.w(
"CompressionUtil",
"Refusing expanded payload size $originalSize outside 1..$maxExpandedSize"
)
return null
return false
}
return true
}
return synchronized(decompressionLock) {
// iOS COMPRESSION_ZLIB produces raw deflate format (no headers).
val rawResult = try {
inflateExact(compressedData, originalSize, nowrap = true)
} catch (e: Exception) {
Log.d(
"CompressionUtil",
"Raw deflate decompression failed: ${e.message}"
)
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 (rawResult != null) {
rawResult
if (zlibResult != null) {
zlibResult
} else {
// Fallback after either a format error or an incomplete/wrong-sized raw stream:
// accept the zlib-wrapped form used by some older/mixed clients, but only when it
// independently satisfies the same exact-size and complete-stream checks.
try {
inflateExact(compressedData, originalSize, nowrap = false)
} catch (fallbackException: Exception) {
Log.e(
"CompressionUtil",
"Both raw deflate and zlib decompression failed: ${fallbackException.message}"
)
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.
*
@@ -0,0 +1,73 @@
package com.bitchat.android.protocol
import java.util.concurrent.Semaphore
import java.util.concurrent.TimeUnit
import kotlin.math.ceil
/**
* Fair, weighted admission control for decompression allocations.
*
* Permits represent memory rather than workers: small packets can proceed concurrently while
* near-limit packets consume most of the budget. Callers must reserve before allocating any
* packet-specific compressed copy or expanded output.
*/
internal class DecompressionResourcePool(
budgetBytes: Long,
private val unitBytes: Int,
private val waitTimeoutMs: Long
) {
private val totalPermits = (budgetBytes / unitBytes).toInt().coerceAtLeast(1)
private val permits = Semaphore(totalPermits, true)
fun <T> withReservation(bytes: Long, block: () -> T): T? {
val requiredPermits = permitsFor(bytes)
val acquired = try {
permits.tryAcquire(requiredPermits, waitTimeoutMs, TimeUnit.MILLISECONDS)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
false
}
if (!acquired) return null
return try {
block()
} finally {
permits.release(requiredPermits)
}
}
internal fun permitsFor(bytes: Long): Int =
ceil(bytes.coerceAtLeast(1).toDouble() / unitBytes.toDouble())
.toInt()
.coerceAtMost(totalPermits)
internal val availablePermits: Int
get() = permits.availablePermits()
companion object {
private const val DEFAULT_UNIT_BYTES = 256 * 1024
private const val DEFAULT_WAIT_TIMEOUT_MS = 1_000L
private const val HEAP_BUDGET_DIVISOR = 8L
private const val MAX_BUDGET_BYTES = 64L * 1024 * 1024
fun forRuntime(
maxHeapBytes: Long = Runtime.getRuntime().maxMemory(),
maxPacketResourceBytes: Long =
2L * com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
): DecompressionResourcePool {
val budget = recommendedBudgetBytes(maxHeapBytes, maxPacketResourceBytes)
return DecompressionResourcePool(
budgetBytes = budget,
unitBytes = DEFAULT_UNIT_BYTES,
waitTimeoutMs = DEFAULT_WAIT_TIMEOUT_MS
)
}
internal fun recommendedBudgetBytes(
maxHeapBytes: Long,
maxPacketResourceBytes: Long
): Long = (maxHeapBytes / HEAP_BUDGET_DIVISOR)
.coerceAtLeast(maxPacketResourceBytes)
.coerceAtMost(MAX_BUDGET_BYTES.coerceAtLeast(maxPacketResourceBytes))
}
}
@@ -23,7 +23,7 @@ class MediaSendingManager(
get() = getMeshService()
companion object {
private const val TAG = "MediaSendingManager"
private const val MAX_FILE_SIZE = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES // 50MB limit
private const val MAX_FILE_SIZE = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES
}
// Track in-flight transfer progress: transferId -> messageId and reverse
@@ -129,7 +129,9 @@ object AppConstants {
}
object Media {
const val MAX_FILE_SIZE_BYTES: Long = 50L * 1024 * 1024
// A file is currently encoded into one protocol payload before BLE fragmentation.
// Reserve room for maximum filename/MIME TLVs and encryption envelope overhead.
const val MAX_FILE_SIZE_BYTES: Long = (10L * 1024 * 1024) - (132L * 1024)
}
object Services {
@@ -1139,6 +1139,18 @@ class BinaryProtocolTest {
assertArrayEquals(payload, decoded!!.payload)
}
@Test
fun `raw deflate with zlib-looking prefix falls back after non-exact zlib parse`() {
val payload = ByteArray(29) { index -> (index + 1).toByte() }
val compressed = byteArrayOf(
0x08, // non-final raw stored block; also zlib CMF
0x1d, 0x00, // LEN = 29; 0x08 0x1d passes the RFC 1950 header check
0xe2.toByte(), 0xff.toByte() // one's complement of LEN
) + payload + byteArrayOf(0x03, 0x00) // final empty fixed-Huffman block
assertArrayEquals(payload, CompressionUtil.decompress(compressed, payload.size))
}
@Test
fun `under-declared zlib expansion is rejected by fallback`() {
val payload = ByteArray(4_096) { 0x51 }
@@ -1259,7 +1271,7 @@ class BinaryProtocolTest {
}
@Test
fun `legacy 11 MiB public file transfer is explicitly rejected by bounded decoder`() {
fun `new sender refuses legacy 11 MiB public file transfer before transmission`() {
val content = ByteArray(11 * 1024 * 1024) { 0x41 }
val filePayload = BitchatFilePacket(
fileName = "legacy-11m.bin",
@@ -1281,15 +1293,9 @@ class BinaryProtocolTest {
),
padding = false
)
assertNotNull("A legacy sender can produce the highly-compressible wire packet", encoded)
assertTrue(
"The compressed wire body remains below the normal 10 MiB input cap",
encoded!!.size < com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
)
assertNull(
"The uniform bound intentionally rejects this legacy expansion until streaming admission exists",
BinaryProtocol.decode(encoded)
"Sender and receiver must enforce the same expanded-payload ceiling",
encoded
)
}
@@ -0,0 +1,117 @@
package com.bitchat.android.protocol
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
class DecompressionResourcePoolTest {
@Test
fun `small reservations run concurrently and next waits only when budget is full`() {
val pool = DecompressionResourcePool(
budgetBytes = 2_000,
unitBytes = 1_000,
waitTimeoutMs = 2_000
)
val executor = Executors.newFixedThreadPool(3)
val entered = CountDownLatch(2)
val release = CountDownLatch(1)
val thirdEntered = CountDownLatch(1)
try {
repeat(2) {
executor.submit {
pool.withReservation(1_000) {
entered.countDown()
release.await()
}
}
}
assertTrue(entered.await(1, TimeUnit.SECONDS))
executor.submit {
pool.withReservation(1_000) {
thirdEntered.countDown()
}
}
assertFalse("third reservation must wait while budget is full", thirdEntered.await(100, TimeUnit.MILLISECONDS))
release.countDown()
assertTrue("third reservation must proceed after release", thirdEntered.await(1, TimeUnit.SECONDS))
} finally {
release.countDown()
executor.shutdownNow()
}
}
@Test
fun `timed admission drops work instead of waiting indefinitely`() {
val pool = DecompressionResourcePool(
budgetBytes = 1_000,
unitBytes = 1_000,
waitTimeoutMs = 50
)
val entered = CountDownLatch(1)
val release = CountDownLatch(1)
val executor = Executors.newSingleThreadExecutor()
try {
executor.submit {
pool.withReservation(1_000) {
entered.countDown()
release.await()
}
}
assertTrue(entered.await(1, TimeUnit.SECONDS))
assertNull(pool.withReservation(1_000) { "unexpected" })
} finally {
release.countDown()
executor.shutdownNow()
}
}
@Test
fun `permits are released when decode throws`() {
val pool = DecompressionResourcePool(2_000, 1_000, 50)
try {
pool.withReservation(2_000) { error("boom") }
} catch (_: IllegalStateException) {
// Expected.
}
assertEquals(2, pool.availablePermits)
assertEquals("ok", pool.withReservation(2_000) { "ok" })
}
@Test
fun `runtime budget is based on heap memory and always admits one maximum packet`() {
val maxPacketResources = 20L * 1024 * 1024
assertEquals(
maxPacketResources,
DecompressionResourcePool.recommendedBudgetBytes(
maxHeapBytes = 64L * 1024 * 1024,
maxPacketResourceBytes = maxPacketResources
)
)
assertEquals(
32L * 1024 * 1024,
DecompressionResourcePool.recommendedBudgetBytes(
maxHeapBytes = 256L * 1024 * 1024,
maxPacketResourceBytes = maxPacketResources
)
)
assertEquals(
64L * 1024 * 1024,
DecompressionResourcePool.recommendedBudgetBytes(
maxHeapBytes = 2L * 1024 * 1024 * 1024,
maxPacketResourceBytes = maxPacketResources
)
)
}
}
@@ -237,7 +237,7 @@ class FileTransferTest {
// Given: Large file size (simulated)
val largeFileSize = 100L * 1024 * 1024 // 100MB
val maxAllowedSize = 50L * 1024 * 1024 // 50MB
val maxAllowedSize = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES
// When: Checking if file can be transferred
val isAllowed = largeFileSize <= maxAllowedSize